|
|
@@ -1,23 +1,33 @@
|
|
|
-import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
|
-import { BsSearch } from 'react-icons/bs'
|
|
|
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|
|
+import { BsSearch, BsGrid3X3Gap, BsGlobe } from 'react-icons/bs'
|
|
|
import { api, AppMapping } from '../services/api'
|
|
|
import { logger } from '../utils/logger'
|
|
|
+import { stringToColor, calculateFontSize } from '../utils/iconGradientUtils'
|
|
|
import '../index.css'
|
|
|
|
|
|
interface AppMappingListProps {
|
|
|
token: string
|
|
|
}
|
|
|
|
|
|
-// 根据应用名称生成图标
|
|
|
-const getAppIcon = (appName: string): React.ReactNode => {
|
|
|
+/** 最近打开本地存储项 */
|
|
|
+export interface RecentAppItem {
|
|
|
+ app_id: string
|
|
|
+ mapped_key: string
|
|
|
+ app_name: string
|
|
|
+}
|
|
|
+
|
|
|
+const RECENT_APPS_KEY = 'launchpad-recent-apps'
|
|
|
+const RECENT_APPS_MAX = 5
|
|
|
+const SHOW_CATEGORY_KEY = 'launchpad-show-category'
|
|
|
+const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
|
|
|
+
|
|
|
+// 根据应用名称生成图标,可选尺寸(默认 40)
|
|
|
+const getAppIcon = (appName: string, containerSize: number = 40): React.ReactNode => {
|
|
|
const getDisplayChars = (name: string): string => {
|
|
|
- // 提取所有中文字符
|
|
|
const chineseChars = name.match(/[\u4e00-\u9fa5]/g);
|
|
|
if (chineseChars && chineseChars.length > 0) {
|
|
|
- // 只显示前2个字符,更简洁美观
|
|
|
return chineseChars.slice(0, 2).join('');
|
|
|
}
|
|
|
- // 英文:提取首字母,最多2个
|
|
|
const words = name.split(/\s+/).filter(w => w.length > 0);
|
|
|
if (words.length >= 2) {
|
|
|
return (words[0][0] + words[1][0]).toUpperCase();
|
|
|
@@ -25,36 +35,22 @@ const getAppIcon = (appName: string): React.ReactNode => {
|
|
|
return name.substring(0, 2).toUpperCase();
|
|
|
};
|
|
|
|
|
|
- const getBackgroundColor = (name: string): string => {
|
|
|
- // 使用更柔和的颜色
|
|
|
- const colors = [
|
|
|
- '#667eea', '#764ba2', '#f093fb', '#4facfe',
|
|
|
- '#43e97b', '#fa709a', '#30cfd0', '#a8edea',
|
|
|
- '#ff9a9e', '#ffecd2', '#ff8a80', '#f6d365',
|
|
|
- '#84fab0', '#a1c4fd', '#ffd89b', '#c471ed'
|
|
|
- ];
|
|
|
- let hash = 0;
|
|
|
- for (let i = 0; i < name.length; i++) {
|
|
|
- hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
|
- }
|
|
|
- return colors[Math.abs(hash) % colors.length];
|
|
|
- };
|
|
|
-
|
|
|
const chars = getDisplayChars(appName);
|
|
|
- const bgColor = getBackgroundColor(appName);
|
|
|
|
|
|
return (
|
|
|
- <div style={{
|
|
|
- width: '40px',
|
|
|
- height: '40px',
|
|
|
- background: bgColor,
|
|
|
- borderRadius: '8px', // 改为圆角矩形
|
|
|
- marginRight: '15px',
|
|
|
- display: 'flex',
|
|
|
- alignItems: 'center',
|
|
|
- justifyContent: 'center',
|
|
|
- color: 'white',
|
|
|
- fontSize: '16px',
|
|
|
+ <div style={{
|
|
|
+ width: `${containerSize}px`,
|
|
|
+ height: `${containerSize}px`,
|
|
|
+ minWidth: `${containerSize}px`,
|
|
|
+ minHeight: `${containerSize}px`,
|
|
|
+ background: stringToColor(appName),
|
|
|
+ borderRadius: '8px',
|
|
|
+ marginRight: containerSize > 32 ? '15px' : '10px',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ color: 'white',
|
|
|
+ fontSize: `${calculateFontSize(containerSize, chars.length)}px`,
|
|
|
fontWeight: '600',
|
|
|
overflow: 'hidden',
|
|
|
lineHeight: '1',
|
|
|
@@ -76,6 +72,23 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
|
|
|
const [hasMore, setHasMore] = useState(true)
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
const [loginLoading, setLoginLoading] = useState<Record<string, boolean>>({})
|
|
|
+ const [showCategory, setShowCategory] = useState(() => {
|
|
|
+ try {
|
|
|
+ const raw = localStorage.getItem(SHOW_CATEGORY_KEY)
|
|
|
+ return raw === null ? true : raw === 'true'
|
|
|
+ } catch {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const [openInSystemBrowser, setOpenInSystemBrowser] = useState(() => {
|
|
|
+ try {
|
|
|
+ const raw = localStorage.getItem(OPEN_IN_SYSTEM_BROWSER_KEY)
|
|
|
+ return raw === 'true'
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const [recentApps, setRecentApps] = useState<RecentAppItem[]>([])
|
|
|
|
|
|
// 分页状态
|
|
|
const [skip, setSkip] = useState(0)
|
|
|
@@ -92,6 +105,34 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
|
|
|
skipRef.current = skip
|
|
|
}, [skip])
|
|
|
|
|
|
+ // 从本地加载最近打开(最多 5 条,越近期越靠前)
|
|
|
+ useEffect(() => {
|
|
|
+ try {
|
|
|
+ const raw = localStorage.getItem(RECENT_APPS_KEY)
|
|
|
+ if (raw) {
|
|
|
+ const list = JSON.parse(raw) as RecentAppItem[]
|
|
|
+ if (Array.isArray(list)) {
|
|
|
+ setRecentApps(list.slice(0, RECENT_APPS_MAX))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (_) {
|
|
|
+ setRecentApps([])
|
|
|
+ }
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ const saveRecentApp = useCallback((item: { app_id: string; mapped_key: string; app_name: string }) => {
|
|
|
+ setRecentApps(prev => {
|
|
|
+ const next = [
|
|
|
+ item,
|
|
|
+ ...prev.filter(x => !(x.app_id === item.app_id && x.mapped_key === item.mapped_key))
|
|
|
+ ].slice(0, RECENT_APPS_MAX)
|
|
|
+ try {
|
|
|
+ localStorage.setItem(RECENT_APPS_KEY, JSON.stringify(next))
|
|
|
+ } catch (_) {}
|
|
|
+ return next
|
|
|
+ })
|
|
|
+ }, [])
|
|
|
+
|
|
|
// 加载账号映射列表
|
|
|
const loadMappings = useCallback(async (isRefresh: boolean = false) => {
|
|
|
if (isLoading || (isLoadingMore && !isRefresh)) return
|
|
|
@@ -110,7 +151,7 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
|
|
|
|
|
|
const currentSkip = isRefresh ? 0 : skipRef.current
|
|
|
|
|
|
- const result = await api.getMyMappings(token, {
|
|
|
+ const result = await api.getLaunchpadApps(token, {
|
|
|
skip: currentSkip,
|
|
|
limit: pageSize,
|
|
|
app_name: searchKeyword || undefined
|
|
|
@@ -201,6 +242,31 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
|
|
|
}, 100)
|
|
|
}, [isRefreshing, isLoading, isLoadingMore, hasMore, loadMappings])
|
|
|
|
|
|
+ const categorizedApps = useMemo(() => {
|
|
|
+ const groupMap = new Map<number | null, { categoryId: number | null; categoryName: string; apps: AppMapping[] }>()
|
|
|
+
|
|
|
+ for (const app of mappings) {
|
|
|
+ const key = app.category_id
|
|
|
+ if (!groupMap.has(key)) {
|
|
|
+ groupMap.set(key, {
|
|
|
+ categoryId: key,
|
|
|
+ categoryName: key === null ? '未分类' : (app.category_name || '未分类'),
|
|
|
+ apps: [],
|
|
|
+ })
|
|
|
+ }
|
|
|
+ groupMap.get(key)!.apps.push(app)
|
|
|
+ }
|
|
|
+
|
|
|
+ const groups = Array.from(groupMap.values())
|
|
|
+ groups.sort((a, b) => {
|
|
|
+ if (a.categoryId === null && b.categoryId !== null) return 1
|
|
|
+ if (a.categoryId !== null && b.categoryId === null) return -1
|
|
|
+ return a.categoryName.localeCompare(b.categoryName, 'zh-CN')
|
|
|
+ })
|
|
|
+
|
|
|
+ return groups
|
|
|
+ }, [mappings])
|
|
|
+
|
|
|
// 处理 SSO 登录
|
|
|
const handleSsoLogin = async (mapping: AppMapping) => {
|
|
|
if (!mapping.is_active) {
|
|
|
@@ -219,8 +285,12 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
|
|
|
const result = await api.ssoLogin(token, mapping.app_id)
|
|
|
|
|
|
if (result.redirect_url) {
|
|
|
- // 使用 window.open,主进程会自动拦截并在 BrowserWindow 中打开
|
|
|
- window.open(result.redirect_url, '_blank')
|
|
|
+ if (openInSystemBrowser && window.electron?.ipcRenderer) {
|
|
|
+ window.electron.ipcRenderer.send('open-url-external', result.redirect_url)
|
|
|
+ } else {
|
|
|
+ window.open(result.redirect_url, '_blank')
|
|
|
+ }
|
|
|
+ saveRecentApp({ app_id: mapping.app_id, mapped_key: mapping.mapped_key, app_name: mapping.app_name })
|
|
|
} else {
|
|
|
alert('SSO 登录失败:未返回跳转地址')
|
|
|
}
|
|
|
@@ -232,9 +302,98 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // 点击最近打开项:优先用当前列表中的 mapping,否则用本地记录发起 SSO
|
|
|
+ const handleRecentAppClick = useCallback(async (item: RecentAppItem) => {
|
|
|
+ const mapping = mappings.find(m => m.app_id === item.app_id && m.mapped_key === item.mapped_key)
|
|
|
+ if (mapping) {
|
|
|
+ if (!mapping.is_active || mapping.protocol_type !== 'SIMPLE_API') return
|
|
|
+ await handleSsoLogin(mapping)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ setLoginLoading(prev => ({ ...prev, [item.app_id]: true }))
|
|
|
+ try {
|
|
|
+ const result = await api.ssoLogin(token, item.app_id)
|
|
|
+ if (result.redirect_url) {
|
|
|
+ if (openInSystemBrowser && window.electron?.ipcRenderer) {
|
|
|
+ window.electron.ipcRenderer.send('open-url-external', result.redirect_url)
|
|
|
+ } else {
|
|
|
+ window.open(result.redirect_url, '_blank')
|
|
|
+ }
|
|
|
+ saveRecentApp(item)
|
|
|
+ } else {
|
|
|
+ alert('SSO 登录失败:未返回跳转地址')
|
|
|
+ }
|
|
|
+ } catch (err: any) {
|
|
|
+ logger.error('AppMappingList: SSO login failed (recent)', err)
|
|
|
+ alert((err as Error).message || 'SSO 登录失败')
|
|
|
+ } finally {
|
|
|
+ setLoginLoading(prev => ({ ...prev, [item.app_id]: false }))
|
|
|
+ }
|
|
|
+ }, [token, mappings, saveRecentApp, openInSystemBrowser])
|
|
|
+
|
|
|
return (
|
|
|
<div style={{ flex: 1, padding: '40px', overflowY: 'auto' }}>
|
|
|
- <h2 style={{ marginBottom: '30px', color: '#333', WebkitAppRegion: 'drag' } as React.CSSProperties}>应用中心</h2>
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '30px' }}>
|
|
|
+ <h2 style={{ margin: 0, color: '#333', WebkitAppRegion: 'drag' } as React.CSSProperties}>应用中心</h2>
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
|
|
+ <button
|
|
|
+ title={openInSystemBrowser ? '在应用内打开' : '用系统浏览器打开'}
|
|
|
+ onClick={() => {
|
|
|
+ setOpenInSystemBrowser(prev => {
|
|
|
+ const next = !prev
|
|
|
+ try {
|
|
|
+ localStorage.setItem(OPEN_IN_SYSTEM_BROWSER_KEY, next ? 'true' : 'false')
|
|
|
+ } catch (_) {}
|
|
|
+ return next
|
|
|
+ })
|
|
|
+ }}
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ width: '36px',
|
|
|
+ height: '36px',
|
|
|
+ padding: 0,
|
|
|
+ borderRadius: '6px',
|
|
|
+ border: '1px solid #e0e0e0',
|
|
|
+ backgroundColor: openInSystemBrowser ? '#1890ff' : '#f5f5f5',
|
|
|
+ color: openInSystemBrowser ? '#fff' : '#666',
|
|
|
+ cursor: 'pointer',
|
|
|
+ fontSize: '16px',
|
|
|
+ transition: 'all 0.2s',
|
|
|
+ } as React.CSSProperties}
|
|
|
+ >
|
|
|
+ <BsGlobe style={{ fontSize: '18px' }} />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setShowCategory(prev => {
|
|
|
+ const next = !prev
|
|
|
+ try {
|
|
|
+ localStorage.setItem(SHOW_CATEGORY_KEY, JSON.stringify(next))
|
|
|
+ } catch (_) {}
|
|
|
+ return next
|
|
|
+ })
|
|
|
+ }}
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ gap: '6px',
|
|
|
+ padding: '6px 14px',
|
|
|
+ borderRadius: '6px',
|
|
|
+ border: '1px solid #e0e0e0',
|
|
|
+ backgroundColor: showCategory ? '#1890ff' : '#f5f5f5',
|
|
|
+ color: showCategory ? '#fff' : '#666',
|
|
|
+ cursor: 'pointer',
|
|
|
+ fontSize: '13px',
|
|
|
+ transition: 'all 0.2s',
|
|
|
+ } as React.CSSProperties}
|
|
|
+ >
|
|
|
+ <BsGrid3X3Gap style={{ fontSize: '14px' }} />
|
|
|
+ {showCategory ? '取消分类' : '显示分类'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
|
|
|
{/* 搜索框 */}
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
@@ -285,6 +444,47 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
+ {/* 最近打开:仅显示 5 条,与全部应用用分隔线隔开 */}
|
|
|
+ {recentApps.length > 0 && (
|
|
|
+ <div style={{ marginBottom: '24px', paddingBottom: '24px', borderBottom: '1px solid #eee' }}>
|
|
|
+ <div style={{
|
|
|
+ fontSize: '15px',
|
|
|
+ fontWeight: 'bold',
|
|
|
+ color: '#333',
|
|
|
+ marginBottom: '12px',
|
|
|
+ paddingBottom: '8px',
|
|
|
+ borderBottom: '1px solid #eee',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ gap: '8px',
|
|
|
+ }}>
|
|
|
+ 最近打开
|
|
|
+ </div>
|
|
|
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: '12px' }}>
|
|
|
+ {recentApps.map((item) => (
|
|
|
+ <div
|
|
|
+ key={`${item.app_id}_${item.mapped_key}`}
|
|
|
+ className={`browser-card browser-card--recent ${loginLoading[item.app_id] ? 'disabled' : ''}`}
|
|
|
+ onClick={() => !loginLoading[item.app_id] && handleRecentAppClick(item)}
|
|
|
+ style={{
|
|
|
+ opacity: loginLoading[item.app_id] ? 0.6 : 1,
|
|
|
+ cursor: loginLoading[item.app_id] ? 'not-allowed' : 'pointer',
|
|
|
+ WebkitAppRegion: 'no-drag',
|
|
|
+ } as React.CSSProperties}
|
|
|
+ >
|
|
|
+ {getAppIcon(item.app_name, 28)}
|
|
|
+ <div style={{ minWidth: 0 }}>
|
|
|
+ <div style={{ fontWeight: 'bold', fontSize: '13px' }}>{item.app_name}</div>
|
|
|
+ {loginLoading[item.app_id] && (
|
|
|
+ <div style={{ fontSize: '11px', color: '#888', marginTop: '2px' }}>登录中...</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
{/* 应用列表 */}
|
|
|
<div
|
|
|
ref={scrollContainerRef}
|
|
|
@@ -305,32 +505,79 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token }) => {
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- {/* 应用项 - 使用原来的 browser-card 样式 */}
|
|
|
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
|
|
|
- {mappings.map((mapping) => (
|
|
|
- <div
|
|
|
- key={`${mapping.app_id}_${mapping.mapped_key}`}
|
|
|
- className={`browser-card ${!mapping.is_active || loginLoading[mapping.app_id] ? 'disabled' : ''}`}
|
|
|
- onClick={() => {
|
|
|
- if (mapping.is_active && mapping.protocol_type === 'SIMPLE_API' && !loginLoading[mapping.app_id]) {
|
|
|
- handleSsoLogin(mapping)
|
|
|
- }
|
|
|
- }}
|
|
|
- style={{
|
|
|
- opacity: !mapping.is_active || loginLoading[mapping.app_id] ? 0.6 : 1,
|
|
|
- cursor: !mapping.is_active || loginLoading[mapping.app_id] ? 'not-allowed' : 'pointer'
|
|
|
- }}
|
|
|
- >
|
|
|
- {getAppIcon(mapping.app_name)}
|
|
|
- <div>
|
|
|
- <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
|
|
|
- {loginLoading[mapping.app_id] && (
|
|
|
- <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
|
|
|
- )}
|
|
|
+ {showCategory ? (
|
|
|
+ categorizedApps.map((group) => (
|
|
|
+ <div key={group.categoryId ?? 'uncategorized'} style={{ marginBottom: '28px' }}>
|
|
|
+ <div style={{
|
|
|
+ fontSize: '15px',
|
|
|
+ fontWeight: 'bold',
|
|
|
+ color: '#333',
|
|
|
+ marginBottom: '12px',
|
|
|
+ paddingBottom: '8px',
|
|
|
+ borderBottom: '1px solid #eee',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ gap: '8px',
|
|
|
+ }}>
|
|
|
+ {group.categoryName}
|
|
|
+ <span style={{ fontSize: '12px', color: '#999', fontWeight: '400' }}>
|
|
|
+ ({group.apps.length})
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
|
|
|
+ {group.apps.map((mapping) => (
|
|
|
+ <div
|
|
|
+ key={`${mapping.app_id}_${mapping.mapped_key}`}
|
|
|
+ className={`browser-card ${!mapping.is_active || loginLoading[mapping.app_id] ? 'disabled' : ''}`}
|
|
|
+ onClick={() => {
|
|
|
+ if (mapping.is_active && mapping.protocol_type === 'SIMPLE_API' && !loginLoading[mapping.app_id]) {
|
|
|
+ handleSsoLogin(mapping)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ style={{
|
|
|
+ opacity: !mapping.is_active || loginLoading[mapping.app_id] ? 0.6 : 1,
|
|
|
+ cursor: !mapping.is_active || loginLoading[mapping.app_id] ? 'not-allowed' : 'pointer'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {getAppIcon(mapping.app_name)}
|
|
|
+ <div>
|
|
|
+ <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
|
|
|
+ {loginLoading[mapping.app_id] && (
|
|
|
+ <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
</div>
|
|
|
</div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
+ ))
|
|
|
+ ) : (
|
|
|
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
|
|
|
+ {mappings.map((mapping) => (
|
|
|
+ <div
|
|
|
+ key={`${mapping.app_id}_${mapping.mapped_key}`}
|
|
|
+ className={`browser-card ${!mapping.is_active || loginLoading[mapping.app_id] ? 'disabled' : ''}`}
|
|
|
+ onClick={() => {
|
|
|
+ if (mapping.is_active && mapping.protocol_type === 'SIMPLE_API' && !loginLoading[mapping.app_id]) {
|
|
|
+ handleSsoLogin(mapping)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ style={{
|
|
|
+ opacity: !mapping.is_active || loginLoading[mapping.app_id] ? 0.6 : 1,
|
|
|
+ cursor: !mapping.is_active || loginLoading[mapping.app_id] ? 'not-allowed' : 'pointer'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {getAppIcon(mapping.app_name)}
|
|
|
+ <div>
|
|
|
+ <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
|
|
|
+ {loginLoading[mapping.app_id] && (
|
|
|
+ <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
{/* 加载更多提示 */}
|
|
|
{isLoadingMore && (
|