|
|
@@ -26,10 +26,13 @@ const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
|
|
|
|
|
|
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>>({})
|
|
|
@@ -75,6 +78,17 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
|
|
|
skipRef.current = skip
|
|
|
}, [skip])
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
+ mappingsRef.current = mappings
|
|
|
+ }, [mappings])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ isLoadingRef.current = isLoading
|
|
|
+ }, [isLoading])
|
|
|
+ useEffect(() => {
|
|
|
+ isLoadingMoreRef.current = isLoadingMore
|
|
|
+ }, [isLoadingMore])
|
|
|
+
|
|
|
// 从本地加载最近打开(按用户隔离;最多 5 条,越近期越靠前)
|
|
|
useEffect(() => {
|
|
|
if (!recentStorageKey) {
|
|
|
@@ -111,83 +125,99 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
|
|
|
})
|
|
|
}, [recentStorageKey])
|
|
|
|
|
|
- // 加载账号映射列表
|
|
|
- const loadMappings = useCallback(async (isRefresh: boolean = false) => {
|
|
|
- if (isLoading || (isLoadingMore && !isRefresh)) return
|
|
|
+ // 加载账号映射列表;silent:不清空列表、不顶全屏 loading,失败仅 warn
|
|
|
+ const loadMappings = useCallback(
|
|
|
+ async (isRefresh: boolean = false, options?: { silent?: boolean }) => {
|
|
|
+ const silent = options?.silent === true
|
|
|
|
|
|
- const currentLoadId = (loadIdRef.current += 1)
|
|
|
-
|
|
|
- try {
|
|
|
- if (isRefresh) {
|
|
|
- setIsRefreshing(true)
|
|
|
- setSkip(0)
|
|
|
- skipRef.current = 0
|
|
|
- setMappings([])
|
|
|
- } else {
|
|
|
- setIsLoadingMore(true)
|
|
|
- }
|
|
|
- setIsLoading(true)
|
|
|
- setError(null)
|
|
|
+ if (isLoadingMoreRef.current && !isRefresh) return
|
|
|
+ if (!silent && isLoadingRef.current) return
|
|
|
|
|
|
- const currentSkip = isRefresh ? 0 : skipRef.current
|
|
|
+ const currentLoadId = (loadIdRef.current += 1)
|
|
|
|
|
|
- const keyword = searchKeywordRef.current.trim() || undefined
|
|
|
- const result = await api.getLaunchpadApps(token, {
|
|
|
- skip: currentSkip,
|
|
|
- limit: pageSize,
|
|
|
- app_name: keyword
|
|
|
- })
|
|
|
+ try {
|
|
|
+ if (isRefresh) {
|
|
|
+ setSkip(0)
|
|
|
+ skipRef.current = 0
|
|
|
+ if (!silent) {
|
|
|
+ setIsRefreshing(true)
|
|
|
+ setMappings([])
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ setIsLoadingMore(true)
|
|
|
+ }
|
|
|
+ if (!silent) {
|
|
|
+ setIsLoading(true)
|
|
|
+ setError(null)
|
|
|
+ }
|
|
|
|
|
|
- // 仅应用最新请求的结果,避免慢请求覆盖搜索结果
|
|
|
- if (currentLoadId !== loadIdRef.current) return
|
|
|
+ const currentSkip = isRefresh ? 0 : skipRef.current
|
|
|
|
|
|
- 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
|
|
|
+ const keyword = searchKeywordRef.current.trim() || undefined
|
|
|
+ const result = await api.getLaunchpadApps(token, {
|
|
|
+ skip: currentSkip,
|
|
|
+ limit: pageSize,
|
|
|
+ app_name: keyword
|
|
|
})
|
|
|
- }
|
|
|
|
|
|
- if (isRefresh) {
|
|
|
+ // 仅应用最新请求的结果,避免慢请求覆盖搜索结果
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ } catch (err: any) {
|
|
|
+ if (currentLoadId !== loadIdRef.current) return
|
|
|
+ if (silent) {
|
|
|
+ logger.warn('AppMappingList: silent refresh failed', err)
|
|
|
+ } else {
|
|
|
+ logger.error('AppMappingList: Failed to load mappings', err)
|
|
|
+ setError(err.message || '加载应用列表失败')
|
|
|
+ }
|
|
|
setIsRefreshing(false)
|
|
|
- } else {
|
|
|
setIsLoadingMore(false)
|
|
|
+ } finally {
|
|
|
+ if (currentLoadId === loadIdRef.current) {
|
|
|
+ if (!silent) {
|
|
|
+ setIsLoading(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
- } catch (err: any) {
|
|
|
- if (currentLoadId !== loadIdRef.current) return
|
|
|
- logger.error('AppMappingList: Failed to load mappings', err)
|
|
|
- setError(err.message || '加载应用列表失败')
|
|
|
- setIsRefreshing(false)
|
|
|
- setIsLoadingMore(false)
|
|
|
- } finally {
|
|
|
- if (currentLoadId === loadIdRef.current) {
|
|
|
- setIsLoading(false)
|
|
|
- }
|
|
|
- }
|
|
|
- }, [token, pageSize])
|
|
|
+ },
|
|
|
+ [token, pageSize]
|
|
|
+ )
|
|
|
|
|
|
// 初始化加载
|
|
|
useEffect(() => {
|
|
|
loadMappings(true)
|
|
|
- }, [token])
|
|
|
+ }, [token, loadMappings])
|
|
|
|
|
|
- // 处理搜索(防抖)
|
|
|
+ // 处理搜索(防抖);已有列表时静默刷新,避免整页闪白
|
|
|
useEffect(() => {
|
|
|
if (searchTimerRef.current) {
|
|
|
clearTimeout(searchTimerRef.current)
|
|
|
}
|
|
|
|
|
|
searchTimerRef.current = setTimeout(() => {
|
|
|
- loadMappings(true)
|
|
|
+ loadMappings(true, { silent: mappingsRef.current.length > 0 })
|
|
|
}, 500)
|
|
|
|
|
|
return () => {
|
|
|
@@ -195,7 +225,18 @@ const AppMappingList: React.FC<AppMappingListProps> = ({ token, currentUserId })
|
|
|
clearTimeout(searchTimerRef.current)
|
|
|
}
|
|
|
}
|
|
|
- }, [searchKeyword])
|
|
|
+ }, [searchKeyword, loadMappings])
|
|
|
+
|
|
|
+ // 窗口从后台切回前台时静默刷新
|
|
|
+ useEffect(() => {
|
|
|
+ const onVis = () => {
|
|
|
+ if (document.visibilityState === 'visible' && token) {
|
|
|
+ void loadMappings(true, { silent: true })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ document.addEventListener('visibilitychange', onVis)
|
|
|
+ return () => document.removeEventListener('visibilitychange', onVis)
|
|
|
+ }, [token, loadMappings])
|
|
|
|
|
|
// 处理滚动事件(使用节流避免频繁触发)
|
|
|
const scrollTimerRef = useRef<NodeJS.Timeout | null>(null)
|