diff --git a/src/App.tsx b/src/App.tsx index adc32cc..e9f634c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,7 +63,6 @@ function App() { const isNotificationWindow = location.pathname === '/notification-window' const isExportRoute = location.pathname === '/export' const [themeHydrated, setThemeHydrated] = useState(false) - const [hasVisitedExport, setHasVisitedExport] = useState(isExportRoute) // 锁定状态 // const [isLocked, setIsLocked] = useState(false) // Moved to store @@ -101,12 +100,6 @@ function App() { } }, [isOnboardingWindow]) - useEffect(() => { - if (isExportRoute) { - setHasVisitedExport(true) - } - }, [isExportRoute]) - // 应用主题 useEffect(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)') @@ -462,11 +455,9 @@ function App() {
- {hasVisitedExport && ( -
- -
- )} +
+ +
} /> diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index d54b2c4..911912a 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -534,6 +534,22 @@ overflow: hidden; } + .export-prepare-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 24px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + -webkit-app-region: no-drag; + + .spin { + animation: spin 1s linear infinite; + } + } + .message-list { flex: 1; background: var(--chat-pattern); @@ -1642,6 +1658,10 @@ opacity: 0.5; cursor: not-allowed; } + + .spin { + animation: spin 1s linear infinite; + } } } @@ -1683,6 +1703,21 @@ } } +.export-prepare-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + + .spin { + animation: spin 1s linear infinite; + } +} + .message-list { flex: 1; overflow-y: auto; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index a8c2f36..195d67e 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -12,6 +12,12 @@ import { LivePhotoIcon } from '../components/LivePhotoIcon' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import JumpToDateDialog from '../components/JumpToDateDialog' import * as configService from '../services/config' +import { + emitOpenSingleExport, + onExportSessionStatus, + onSingleExportDialogStatus, + requestExportSessionStatus +} from '../services/exportBridge' import './ChatPage.scss' // 系统消息类型常量 @@ -385,6 +391,9 @@ function ChatPage(_props: ChatPageProps) { const [fallbackDisplayName, setFallbackDisplayName] = useState(null) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) + const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState>(new Set()) + const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false) + const [exportPrepareHint, setExportPrepareHint] = useState('') // 消息右键菜单 const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null) @@ -454,6 +463,85 @@ function ChatPage(_props: ChatPageProps) { const previewCacheRef = useRef>({}) const previewPersistTimerRef = useRef(null) const sessionListPersistTimerRef = useRef(null) + const pendingExportRequestIdRef = useRef(null) + const exportPrepareLongWaitTimerRef = useRef(null) + + const clearExportPrepareState = useCallback(() => { + pendingExportRequestIdRef.current = null + setIsPreparingExportDialog(false) + setExportPrepareHint('') + if (exportPrepareLongWaitTimerRef.current) { + window.clearTimeout(exportPrepareLongWaitTimerRef.current) + exportPrepareLongWaitTimerRef.current = null + } + }, []) + + useEffect(() => { + const unsubscribe = onExportSessionStatus((payload) => { + const ids = Array.isArray(payload?.inProgressSessionIds) + ? payload.inProgressSessionIds + .filter((id): id is string => typeof id === 'string') + .map(id => id.trim()) + .filter(Boolean) + : [] + setInProgressExportSessionIds(new Set(ids)) + }) + + requestExportSessionStatus() + const timer = window.setTimeout(() => { + requestExportSessionStatus() + }, 0) + return () => { + window.clearTimeout(timer) + unsubscribe() + } + }, []) + + useEffect(() => { + const unsubscribe = onSingleExportDialogStatus((payload) => { + const requestId = typeof payload?.requestId === 'string' ? payload.requestId.trim() : '' + if (!requestId || requestId !== pendingExportRequestIdRef.current) return + + if (payload.status === 'initializing') { + setExportPrepareHint('正在准备导出模块(首次会稍慢,通常 1-3 秒)') + if (exportPrepareLongWaitTimerRef.current) { + window.clearTimeout(exportPrepareLongWaitTimerRef.current) + } + exportPrepareLongWaitTimerRef.current = window.setTimeout(() => { + if (pendingExportRequestIdRef.current !== requestId) return + setExportPrepareHint('仍在准备导出模块,请稍候...') + }, 8000) + return + } + + if (payload.status === 'opened') { + clearExportPrepareState() + return + } + + if (payload.status === 'failed') { + const message = (typeof payload.message === 'string' && payload.message.trim()) + ? payload.message.trim() + : '导出模块初始化失败,请重试' + clearExportPrepareState() + window.alert(message) + } + }) + + return () => { + unsubscribe() + if (exportPrepareLongWaitTimerRef.current) { + window.clearTimeout(exportPrepareLongWaitTimerRef.current) + exportPrepareLongWaitTimerRef.current = null + } + } + }, [clearExportPrepareState]) + + useEffect(() => { + if (!isPreparingExportDialog || !currentSessionId) return + if (!inProgressExportSessionIds.has(currentSessionId)) return + clearExportPrepareState() + }, [clearExportPrepareState, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog]) // 加载当前用户头像 const loadMyAvatar = useCallback(async () => { @@ -1844,6 +1932,8 @@ function ChatPage(_props: ChatPageProps) { displayName: fallbackDisplayName || currentSessionId, } as ChatSession })() + const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId)) + const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog // 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称 useEffect(() => { @@ -1960,12 +2050,23 @@ function ChatPage(_props: ChatPageProps) { const handleExportCurrentSession = useCallback(() => { if (!currentSessionId) return - navigate('/export', { - state: { - preselectSessionIds: [currentSessionId] - } + if (inProgressExportSessionIds.has(currentSessionId) || isPreparingExportDialog) return + + const requestId = `chat-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const sessionName = currentSession?.displayName || currentSession?.username || currentSessionId + pendingExportRequestIdRef.current = requestId + setIsPreparingExportDialog(true) + setExportPrepareHint('') + if (exportPrepareLongWaitTimerRef.current) { + window.clearTimeout(exportPrepareLongWaitTimerRef.current) + exportPrepareLongWaitTimerRef.current = null + } + emitOpenSingleExport({ + sessionId: currentSessionId, + sessionName, + requestId }) - }, [currentSessionId, navigate]) + }, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog]) const handleGroupAnalytics = useCallback(() => { if (!currentSessionId || !isGroupChat(currentSessionId)) return @@ -2641,12 +2742,16 @@ function ChatPage(_props: ChatPageProps) { )} - + , + document.body )} ) diff --git a/src/services/exportBridge.ts b/src/services/exportBridge.ts new file mode 100644 index 0000000..eabc155 --- /dev/null +++ b/src/services/exportBridge.ts @@ -0,0 +1,84 @@ +export interface OpenSingleExportPayload { + sessionId: string + sessionName?: string + requestId?: string +} + +export interface ExportSessionStatusPayload { + inProgressSessionIds: string[] +} + +export interface SingleExportDialogStatusPayload { + requestId: string + status: 'initializing' | 'opened' | 'failed' + message?: string +} + +const OPEN_SINGLE_EXPORT_EVENT = 'weflow:open-single-export' +const EXPORT_SESSION_STATUS_EVENT = 'weflow:export-session-status' +const EXPORT_SESSION_STATUS_REQUEST_EVENT = 'weflow:export-session-status-request' +const SINGLE_EXPORT_DIALOG_STATUS_EVENT = 'weflow:single-export-dialog-status' + +export const emitOpenSingleExport = (payload: OpenSingleExportPayload) => { + window.dispatchEvent(new CustomEvent(OPEN_SINGLE_EXPORT_EVENT, { + detail: payload + })) +} + +export const onOpenSingleExport = ( + listener: (payload: OpenSingleExportPayload) => void +): (() => void) => { + const handler = (event: Event) => { + const customEvent = event as CustomEvent + listener(customEvent.detail) + } + + window.addEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener) + return () => window.removeEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener) +} + +export const emitExportSessionStatus = (payload: ExportSessionStatusPayload) => { + window.dispatchEvent(new CustomEvent(EXPORT_SESSION_STATUS_EVENT, { + detail: payload + })) +} + +export const onExportSessionStatus = ( + listener: (payload: ExportSessionStatusPayload) => void +): (() => void) => { + const handler = (event: Event) => { + const customEvent = event as CustomEvent + listener(customEvent.detail) + } + + window.addEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener) + return () => window.removeEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener) +} + +export const requestExportSessionStatus = () => { + window.dispatchEvent(new CustomEvent(EXPORT_SESSION_STATUS_REQUEST_EVENT)) +} + +export const onExportSessionStatusRequest = (listener: () => void): (() => void) => { + const handler = () => listener() + window.addEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler) + return () => window.removeEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler) +} + +export const emitSingleExportDialogStatus = (payload: SingleExportDialogStatusPayload) => { + window.dispatchEvent(new CustomEvent(SINGLE_EXPORT_DIALOG_STATUS_EVENT, { + detail: payload + })) +} + +export const onSingleExportDialogStatus = ( + listener: (payload: SingleExportDialogStatusPayload) => void +): (() => void) => { + const handler = (event: Event) => { + const customEvent = event as CustomEvent + listener(customEvent.detail) + } + + window.addEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener) + return () => window.removeEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener) +}