|
|
@@ -3,7 +3,7 @@ import { BsSearch, BsGrid3X3Gap, BsGlobe } from 'react-icons/bs'
|
|
|
import { api, AppMapping } from '../services/api'
|
|
|
import { logger } from '../utils/logger'
|
|
|
import { normalizeExternalUrl } from '../utils/urlUtils'
|
|
|
-import { getAvatarPlaceholder } from '../utils/avatarUtils'
|
|
|
+import { LaunchpadAppIcon } from './LaunchpadAppIcon'
|
|
|
import '../index.css'
|
|
|
|
|
|
interface AppMappingListProps {
|
|
|
@@ -24,16 +24,22 @@ const RECENT_APPS_MAX = 5
|
|
|
const SHOW_CATEGORY_KEY = 'launchpad-show-category'
|
|
|
const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
|
|
|
|
|
|
+const OPENABLE_PROTOCOLS: AppMapping['protocol_type'][] = ['SIMPLE_API', 'OIDC', 'NONE']
|
|
|
+
|
|
|
+function canOpenLaunch(mapping: AppMapping): boolean {
|
|
|
+ return mapping.is_active && OPENABLE_PROTOCOLS.includes(mapping.protocol_type)
|
|
|
+}
|
|
|
+
|
|
|
+function launchLoadingLabel(protocol: AppMapping['protocol_type']): string {
|
|
|
+ return protocol === 'NONE' ? '打开中...' : '登录中...'
|
|
|
+}
|
|
|
+
|
|
|
const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId }) => {
|
|
|
const [mappings, setMappings] = useState<AppMapping[]>([])
|
|
|
- const mappingsRef = useRef<AppMapping[]>([])
|
|
|
const [searchKeyword, setSearchKeyword] = useState('')
|
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
|
|
- const [isLoadingMore, setIsLoadingMore] = useState(false)
|
|
|
const isLoadingRef = useRef(false)
|
|
|
- const isLoadingMoreRef = useRef(false)
|
|
|
- const [hasMore, setHasMore] = useState(true)
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
const [loginLoading, setLoginLoading] = useState<Record<string, boolean>>({})
|
|
|
const [showCategory, setShowCategory] = useState(() => {
|
|
|
@@ -59,35 +65,13 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
|
|
|
[currentUserId]
|
|
|
)
|
|
|
|
|
|
- // 分页状态
|
|
|
- const [skip, setSkip] = useState(0)
|
|
|
- const skipRef = useRef(0)
|
|
|
- const pageSize = 20
|
|
|
- const [total, setTotal] = useState(0)
|
|
|
-
|
|
|
// 滚动容器引用
|
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
|
- const searchTimerRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
- // 请求版本号,避免慢请求覆盖新搜索结果
|
|
|
const loadIdRef = useRef(0)
|
|
|
- const searchKeywordRef = useRef(searchKeyword)
|
|
|
- searchKeywordRef.current = searchKeyword
|
|
|
-
|
|
|
- // 同步 skipRef
|
|
|
- useEffect(() => {
|
|
|
- skipRef.current = skip
|
|
|
- }, [skip])
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- mappingsRef.current = mappings
|
|
|
- }, [mappings])
|
|
|
|
|
|
useEffect(() => {
|
|
|
isLoadingRef.current = isLoading
|
|
|
}, [isLoading])
|
|
|
- useEffect(() => {
|
|
|
- isLoadingMoreRef.current = isLoadingMore
|
|
|
- }, [isLoadingMore])
|
|
|
|
|
|
// 从本地加载最近打开(按用户隔离;最多 5 条,越近期越靠前)
|
|
|
useEffect(() => {
|
|
|
@@ -125,65 +109,28 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
|
|
|
})
|
|
|
}, [recentStorageKey])
|
|
|
|
|
|
- // 加载账号映射列表;silent:不清空列表、不顶全屏 loading,失败仅 warn
|
|
|
+ // 加载启动台全量列表(无服务端分页);silent:不顶全屏 loading、不闪清空,失败仅 warn
|
|
|
const loadMappings = useCallback(
|
|
|
- async (isRefresh: boolean = false, options?: { silent?: boolean }) => {
|
|
|
+ async (options?: { silent?: boolean }) => {
|
|
|
const silent = options?.silent === true
|
|
|
|
|
|
- if (isLoadingMoreRef.current && !isRefresh) return
|
|
|
if (!silent && isLoadingRef.current) return
|
|
|
|
|
|
const currentLoadId = (loadIdRef.current += 1)
|
|
|
|
|
|
try {
|
|
|
- if (isRefresh) {
|
|
|
- setSkip(0)
|
|
|
- skipRef.current = 0
|
|
|
- if (!silent) {
|
|
|
- setIsRefreshing(true)
|
|
|
- setMappings([])
|
|
|
- }
|
|
|
- } else {
|
|
|
- setIsLoadingMore(true)
|
|
|
- }
|
|
|
if (!silent) {
|
|
|
+ setIsRefreshing(true)
|
|
|
+ setMappings([])
|
|
|
setIsLoading(true)
|
|
|
setError(null)
|
|
|
}
|
|
|
|
|
|
- const currentSkip = isRefresh ? 0 : skipRef.current
|
|
|
-
|
|
|
- const keyword = searchKeywordRef.current.trim() || undefined
|
|
|
- const result = await api.getLaunchpadApps(token, {
|
|
|
- skip: currentSkip,
|
|
|
- limit: pageSize,
|
|
|
- app_name: keyword
|
|
|
- })
|
|
|
+ const result = await api.getLaunchpadApps(token)
|
|
|
|
|
|
- // 仅应用最新请求的结果,避免慢请求覆盖搜索结果
|
|
|
if (currentLoadId !== loadIdRef.current) return
|
|
|
|
|
|
- if (isRefresh) {
|
|
|
- setMappings(result.items)
|
|
|
- setTotal(result.total)
|
|
|
- setHasMore(result.items.length < result.total)
|
|
|
- } else {
|
|
|
- setMappings(prev => {
|
|
|
- const existingIds = new Set(prev.map(m => `${m.app_id}_${m.mapped_key}`))
|
|
|
- const uniqueNewMappings = result.items.filter(
|
|
|
- m => !existingIds.has(`${m.app_id}_${m.mapped_key}`)
|
|
|
- )
|
|
|
- const nextList = [...prev, ...uniqueNewMappings]
|
|
|
- setHasMore(nextList.length < result.total)
|
|
|
- return nextList
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- if (isRefresh) {
|
|
|
- setIsRefreshing(false)
|
|
|
- } else {
|
|
|
- setIsLoadingMore(false)
|
|
|
- }
|
|
|
+ setMappings(result.items)
|
|
|
} catch (err: any) {
|
|
|
if (currentLoadId !== loadIdRef.current) return
|
|
|
if (silent) {
|
|
|
@@ -192,81 +139,55 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
|
|
|
logger.error('AppMappingList: Failed to load mappings', err)
|
|
|
setError(err.message || '加载应用列表失败')
|
|
|
}
|
|
|
- setIsRefreshing(false)
|
|
|
- setIsLoadingMore(false)
|
|
|
} finally {
|
|
|
if (currentLoadId === loadIdRef.current) {
|
|
|
+ setIsRefreshing(false)
|
|
|
if (!silent) {
|
|
|
setIsLoading(false)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
},
|
|
|
- [token, pageSize]
|
|
|
+ [token]
|
|
|
)
|
|
|
|
|
|
// 初始化加载
|
|
|
useEffect(() => {
|
|
|
- loadMappings(true)
|
|
|
+ void loadMappings()
|
|
|
}, [token, loadMappings])
|
|
|
|
|
|
- // 处理搜索(防抖);已有列表时静默刷新,避免整页闪白
|
|
|
- useEffect(() => {
|
|
|
- if (searchTimerRef.current) {
|
|
|
- clearTimeout(searchTimerRef.current)
|
|
|
- }
|
|
|
-
|
|
|
- searchTimerRef.current = setTimeout(() => {
|
|
|
- loadMappings(true, { silent: mappingsRef.current.length > 0 })
|
|
|
- }, 500)
|
|
|
-
|
|
|
- return () => {
|
|
|
- if (searchTimerRef.current) {
|
|
|
- clearTimeout(searchTimerRef.current)
|
|
|
- }
|
|
|
- }
|
|
|
- }, [searchKeyword, loadMappings])
|
|
|
-
|
|
|
// 窗口从后台切回前台时静默刷新
|
|
|
useEffect(() => {
|
|
|
const onVis = () => {
|
|
|
if (document.visibilityState === 'visible' && token) {
|
|
|
- void loadMappings(true, { silent: true })
|
|
|
+ void loadMappings({ silent: true })
|
|
|
}
|
|
|
}
|
|
|
document.addEventListener('visibilitychange', onVis)
|
|
|
return () => document.removeEventListener('visibilitychange', onVis)
|
|
|
}, [token, loadMappings])
|
|
|
|
|
|
- // 处理滚动事件(使用节流避免频繁触发)
|
|
|
+ // 处理滚动事件(下拉刷新)
|
|
|
const scrollTimerRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
- const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
|
- const container = e.currentTarget
|
|
|
- if (!container) return
|
|
|
+ const handleScroll = useCallback(
|
|
|
+ (e: React.UIEvent<HTMLDivElement>) => {
|
|
|
+ const container = e.currentTarget
|
|
|
+ if (!container) return
|
|
|
|
|
|
- const { scrollTop, scrollHeight, clientHeight } = container
|
|
|
+ const { scrollTop } = container
|
|
|
|
|
|
- // 节流处理加载逻辑
|
|
|
- if (scrollTimerRef.current) {
|
|
|
- clearTimeout(scrollTimerRef.current)
|
|
|
- }
|
|
|
-
|
|
|
- scrollTimerRef.current = setTimeout(() => {
|
|
|
- // 下拉刷新:滚动到顶部附近(往上拉)
|
|
|
- if (scrollTop < 50 && !isRefreshing && !isLoading) {
|
|
|
- loadMappings(true)
|
|
|
+ if (scrollTimerRef.current) {
|
|
|
+ clearTimeout(scrollTimerRef.current)
|
|
|
}
|
|
|
|
|
|
- // 上拉加载更多:滚动到底部附近(往下拉)
|
|
|
- const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
|
|
- if (distanceToBottom < 100 && hasMore && !isLoadingMore && !isLoading) {
|
|
|
- const nextSkip = skipRef.current + pageSize
|
|
|
- setSkip(nextSkip)
|
|
|
- skipRef.current = nextSkip
|
|
|
- loadMappings(false)
|
|
|
- }
|
|
|
- }, 100)
|
|
|
- }, [isRefreshing, isLoading, isLoadingMore, hasMore, loadMappings])
|
|
|
+ scrollTimerRef.current = setTimeout(() => {
|
|
|
+ if (scrollTop < 50 && !isRefreshing && !isLoading) {
|
|
|
+ void loadMappings()
|
|
|
+ }
|
|
|
+ }, 100)
|
|
|
+ },
|
|
|
+ [isRefreshing, isLoading, loadMappings]
|
|
|
+ )
|
|
|
|
|
|
// 前端按关键词过滤展示列表(兜底:后端未过滤时也能正确显示)
|
|
|
const displayedMappings = useMemo(() => {
|
|
|
@@ -300,74 +221,96 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
|
|
|
return groups
|
|
|
}, [displayedMappings])
|
|
|
|
|
|
- // 处理 SSO 登录
|
|
|
- const handleSsoLogin = async (mapping: AppMapping) => {
|
|
|
- if (!mapping.is_active) {
|
|
|
- alert('该账号已禁用,无法登录')
|
|
|
- return
|
|
|
- }
|
|
|
+ // 处理 SSO / 导航打开(NONE 与同接口返回 redirect_url)
|
|
|
+ const handleSsoLogin = useCallback(
|
|
|
+ async (mapping: AppMapping) => {
|
|
|
+ if (!mapping.is_active) {
|
|
|
+ alert('该账号已禁用,无法登录')
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- setLoginLoading(prev => ({ ...prev, [mapping.app_id]: true }))
|
|
|
-
|
|
|
- try {
|
|
|
- const result = await api.ssoLogin(token, mapping.app_id)
|
|
|
-
|
|
|
- if (result.redirect_url) {
|
|
|
- const normalized = normalizeExternalUrl(result.redirect_url)
|
|
|
- if (!normalized) {
|
|
|
- alert('无效的跳转地址')
|
|
|
- return
|
|
|
- }
|
|
|
- if (openInSystemBrowser && window.electron?.ipcRenderer) {
|
|
|
- window.electron.ipcRenderer.send('open-url-external', normalized)
|
|
|
+ setLoginLoading(prev => ({ ...prev, [mapping.app_id]: true }))
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await api.ssoLogin(token, mapping.app_id)
|
|
|
+
|
|
|
+ if (result.redirect_url) {
|
|
|
+ const normalized = normalizeExternalUrl(result.redirect_url)
|
|
|
+ if (!normalized) {
|
|
|
+ alert('无效的跳转地址')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (openInSystemBrowser && window.electron?.ipcRenderer) {
|
|
|
+ window.electron.ipcRenderer.send('open-url-external', normalized)
|
|
|
+ } else {
|
|
|
+ window.open(normalized, '_blank')
|
|
|
+ }
|
|
|
+ saveRecentApp({
|
|
|
+ app_id: mapping.app_id,
|
|
|
+ mapped_key: mapping.mapped_key ?? '',
|
|
|
+ app_name: mapping.app_name
|
|
|
+ })
|
|
|
} else {
|
|
|
- window.open(normalized, '_blank')
|
|
|
+ alert('SSO 登录失败:未返回跳转地址')
|
|
|
}
|
|
|
- saveRecentApp({ app_id: mapping.app_id, mapped_key: mapping.mapped_key, app_name: mapping.app_name })
|
|
|
- } else {
|
|
|
- alert('SSO 登录失败:未返回跳转地址')
|
|
|
+ } catch (err: any) {
|
|
|
+ logger.error('AppMappingList: SSO login failed', err)
|
|
|
+ alert(err.message || 'SSO 登录失败')
|
|
|
+ } finally {
|
|
|
+ setLoginLoading(prev => ({ ...prev, [mapping.app_id]: false }))
|
|
|
}
|
|
|
- } catch (err: any) {
|
|
|
- logger.error('AppMappingList: SSO login failed', err)
|
|
|
- alert(err.message || 'SSO 登录失败')
|
|
|
- } finally {
|
|
|
- setLoginLoading(prev => ({ ...prev, [mapping.app_id]: false }))
|
|
|
- }
|
|
|
- }
|
|
|
+ },
|
|
|
+ [token, openInSystemBrowser, saveRecentApp]
|
|
|
+ )
|
|
|
+
|
|
|
+ const recentLoadingText = useCallback(
|
|
|
+ (item: RecentAppItem) => {
|
|
|
+ const m = mappings.find(
|
|
|
+ x => x.app_id === item.app_id && (x.mapped_key ?? '') === item.mapped_key
|
|
|
+ )
|
|
|
+ return m ? launchLoadingLabel(m.protocol_type) : '登录中...'
|
|
|
+ },
|
|
|
+ [mappings]
|
|
|
+ )
|
|
|
|
|
|
// 点击最近打开项:优先用当前列表中的 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) 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) {
|
|
|
- const normalized = normalizeExternalUrl(result.redirect_url)
|
|
|
- if (!normalized) {
|
|
|
- alert('无效的跳转地址')
|
|
|
- return
|
|
|
- }
|
|
|
- if (openInSystemBrowser && window.electron?.ipcRenderer) {
|
|
|
- window.electron.ipcRenderer.send('open-url-external', normalized)
|
|
|
+ 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) 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) {
|
|
|
+ const normalized = normalizeExternalUrl(result.redirect_url)
|
|
|
+ if (!normalized) {
|
|
|
+ alert('无效的跳转地址')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (openInSystemBrowser && window.electron?.ipcRenderer) {
|
|
|
+ window.electron.ipcRenderer.send('open-url-external', normalized)
|
|
|
+ } else {
|
|
|
+ window.open(normalized, '_blank')
|
|
|
+ }
|
|
|
+ saveRecentApp(item)
|
|
|
} else {
|
|
|
- window.open(normalized, '_blank')
|
|
|
+ alert('SSO 登录失败:未返回跳转地址')
|
|
|
}
|
|
|
- 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 }))
|
|
|
}
|
|
|
- } 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])
|
|
|
+ },
|
|
|
+ [token, mappings, saveRecentApp, openInSystemBrowser, handleSsoLogin]
|
|
|
+ )
|
|
|
|
|
|
return (
|
|
|
<div style={{ flex: 1, padding: '40px', overflowY: 'auto' }}>
|
|
|
@@ -499,26 +442,40 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
|
|
|
最近打开
|
|
|
</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}
|
|
|
- >
|
|
|
- {getAvatarPlaceholder(item.app_name, item.app_id, 28, { marginRight: 10 }, 'applicationIcon', 'roundedSquare')}
|
|
|
- <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>
|
|
|
- )}
|
|
|
+ {recentApps.map((item) => {
|
|
|
+ const mappingForIcon =
|
|
|
+ mappings.find(m => m.app_id === item.app_id && (m.mapped_key ?? '') === item.mapped_key) ??
|
|
|
+ mappings.find(m => m.app_id === item.app_id)
|
|
|
+ return (
|
|
|
+ <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}
|
|
|
+ >
|
|
|
+ <LaunchpadAppIcon
|
|
|
+ appName={item.app_name}
|
|
|
+ appId={item.app_id}
|
|
|
+ iconUrl={mappingForIcon?.icon_url}
|
|
|
+ iconObjectKey={mappingForIcon?.icon_object_key}
|
|
|
+ sizePx={28}
|
|
|
+ extraStyle={{ marginRight: 10 }}
|
|
|
+ />
|
|
|
+ <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' }}>
|
|
|
+ {recentLoadingText(item)}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
+ )
|
|
|
+ })}
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
@@ -563,71 +520,81 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
|
|
|
</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' || mapping.protocol_type === 'OIDC') && !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'
|
|
|
- }}
|
|
|
- >
|
|
|
- {getAvatarPlaceholder(mapping.app_name, mapping.app_id, 40, { marginRight: 15 }, 'applicationIcon', 'roundedSquare')}
|
|
|
- <div>
|
|
|
- <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
|
|
|
- {loginLoading[mapping.app_id] && (
|
|
|
- <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
|
|
|
- )}
|
|
|
+ {group.apps.map((mapping) => {
|
|
|
+ const rowBusy = !canOpenLaunch(mapping) || loginLoading[mapping.app_id]
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={`${mapping.app_id}_${mapping.mapped_key ?? ''}`}
|
|
|
+ className={`browser-card ${rowBusy ? 'disabled' : ''}`}
|
|
|
+ onClick={() => {
|
|
|
+ if (canOpenLaunch(mapping) && !loginLoading[mapping.app_id]) {
|
|
|
+ void handleSsoLogin(mapping)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ style={{
|
|
|
+ opacity: rowBusy ? 0.6 : 1,
|
|
|
+ cursor: rowBusy ? 'not-allowed' : 'pointer'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <LaunchpadAppIcon
|
|
|
+ appName={mapping.app_name}
|
|
|
+ appId={mapping.app_id}
|
|
|
+ iconUrl={mapping.icon_url}
|
|
|
+ iconObjectKey={mapping.icon_object_key}
|
|
|
+ sizePx={40}
|
|
|
+ extraStyle={{ marginRight: 15 }}
|
|
|
+ />
|
|
|
+ <div>
|
|
|
+ <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
|
|
|
+ {loginLoading[mapping.app_id] && (
|
|
|
+ <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
|
|
+ {launchLoadingLabel(mapping.protocol_type)}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
+ )
|
|
|
+ })}
|
|
|
</div>
|
|
|
</div>
|
|
|
))
|
|
|
) : (
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '20px' }}>
|
|
|
- {displayedMappings.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' || mapping.protocol_type === 'OIDC') && !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'
|
|
|
- }}
|
|
|
- >
|
|
|
- {getAvatarPlaceholder(mapping.app_name, mapping.app_id, 40, { marginRight: 15 }, 'applicationIcon', 'roundedSquare')}
|
|
|
- <div>
|
|
|
- <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
|
|
|
- {loginLoading[mapping.app_id] && (
|
|
|
- <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>登录中...</div>
|
|
|
- )}
|
|
|
+ {displayedMappings.map((mapping) => {
|
|
|
+ const rowBusy = !canOpenLaunch(mapping) || loginLoading[mapping.app_id]
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={`${mapping.app_id}_${mapping.mapped_key ?? ''}`}
|
|
|
+ className={`browser-card ${rowBusy ? 'disabled' : ''}`}
|
|
|
+ onClick={() => {
|
|
|
+ if (canOpenLaunch(mapping) && !loginLoading[mapping.app_id]) {
|
|
|
+ void handleSsoLogin(mapping)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ style={{
|
|
|
+ opacity: rowBusy ? 0.6 : 1,
|
|
|
+ cursor: rowBusy ? 'not-allowed' : 'pointer'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <LaunchpadAppIcon
|
|
|
+ appName={mapping.app_name}
|
|
|
+ appId={mapping.app_id}
|
|
|
+ iconUrl={mapping.icon_url}
|
|
|
+ iconObjectKey={mapping.icon_object_key}
|
|
|
+ sizePx={40}
|
|
|
+ extraStyle={{ marginRight: 15 }}
|
|
|
+ />
|
|
|
+ <div>
|
|
|
+ <div style={{ fontWeight: 'bold' }}>{mapping.app_name}</div>
|
|
|
+ {loginLoading[mapping.app_id] && (
|
|
|
+ <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
|
|
+ {launchLoadingLabel(mapping.protocol_type)}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 加载更多提示 */}
|
|
|
- {isLoadingMore && (
|
|
|
- <div style={{ textAlign: 'center', padding: '20px', color: '#999' }}>
|
|
|
- 加载中...
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 没有更多数据提示 */}
|
|
|
- {!hasMore && displayedMappings.length > 0 && (
|
|
|
- <div style={{ textAlign: 'center', padding: '20px', color: '#999' }}>
|
|
|
- 没有更多了
|
|
|
+ )
|
|
|
+ })}
|
|
|
</div>
|
|
|
)}
|
|
|
|