feat(chat-export): open single export dialog in chat with init feedback

This commit is contained in:
tisonhuang
2026-03-02 16:33:09 +08:00
parent 815a440082
commit 2e8f55d7a8
5 changed files with 353 additions and 28 deletions

View File

@@ -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;

View File

@@ -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<string | null>(null)
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(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<Record<string, SessionPreviewCacheEntry>>({})
const previewPersistTimerRef = useRef<number | null>(null)
const sessionListPersistTimerRef = useRef<number | null>(null)
const pendingExportRequestIdRef = useRef<string | null>(null)
const exportPrepareLongWaitTimerRef = useRef<number | null>(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) {
</button>
)}
<button
className="icon-btn export-session-btn"
className={`icon-btn export-session-btn${isExportActionBusy ? ' exporting' : ''}`}
onClick={handleExportCurrentSession}
disabled={!currentSessionId}
title="导出当前会话"
disabled={!currentSessionId || isExportActionBusy}
title={isCurrentSessionExporting ? '导出中' : isPreparingExportDialog ? '正在准备导出模块' : '导出当前会话'}
>
<Download size={18} />
{isExportActionBusy ? (
<Loader2 size={18} className="spin" />
) : (
<Download size={18} />
)}
</button>
<button
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
@@ -2751,6 +2856,13 @@ function ChatPage(_props: ChatPageProps) {
</div>
</div>
{isPreparingExportDialog && exportPrepareHint && (
<div className="export-prepare-hint" role="status" aria-live="polite">
<Loader2 size={14} className="spin" />
<span>{exportPrepareHint}</span>
</div>
)}
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
<div className="loading-messages loading-overlay">

View File

@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from 'react'
import { useLocation } from 'react-router-dom'
import { TableVirtuoso } from 'react-virtuoso'
import { createPortal } from 'react-dom'
import {
Aperture,
Calendar,
@@ -29,6 +30,12 @@ import {
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
import * as configService from '../services/config'
import {
emitExportSessionStatus,
emitSingleExportDialogStatus,
onExportSessionStatusRequest,
onOpenSingleExport
} from '../services/exportBridge'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import './ExportPage.scss'
@@ -654,6 +661,8 @@ function ExportPage() {
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
const contactsListRef = useRef<HTMLDivElement>(null)
const detailRequestSeqRef = useRef(0)
const inProgressSessionIdsRef = useRef<string[]>([])
const hasBaseConfigReadyRef = useRef(false)
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) {
@@ -1033,8 +1042,9 @@ function ExportPage() {
return () => clearInterval(timer)
}, [isExportRoute])
const loadBaseConfig = useCallback(async () => {
const loadBaseConfig = useCallback(async (): Promise<boolean> => {
setIsBaseConfigLoading(true)
let isReady = true
try {
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, exportCacheScope] = await Promise.all([
configService.getExportPath(),
@@ -1085,10 +1095,15 @@ function ExportPage() {
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
}))
} catch (error) {
isReady = false
console.error('加载导出配置失败:', error)
} finally {
setIsBaseConfigLoading(false)
}
if (isReady) {
hasBaseConfigReadyRef.current = true
}
return isReady
}, [ensureExportCacheScope])
const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => {
@@ -1475,7 +1490,7 @@ function ExportPage() {
const clearSelection = () => setSelectedSessions(new Set())
const openExportDialog = (payload: Omit<ExportDialogState, 'open'>) => {
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
setExportDialog({ open: true, ...payload })
setOptions(prev => {
@@ -1516,11 +1531,63 @@ function ExportPage() {
return next
})
}
}, [])
const closeExportDialog = () => {
const closeExportDialog = useCallback(() => {
setExportDialog(prev => ({ ...prev, open: false }))
}
}, [])
useEffect(() => {
const unsubscribe = onOpenSingleExport((payload) => {
void (async () => {
const sessionId = typeof payload?.sessionId === 'string'
? payload.sessionId.trim()
: ''
if (!sessionId) return
const sessionName = typeof payload?.sessionName === 'string'
? payload.sessionName.trim()
: ''
const displayName = sessionName || sessionId
const requestId = typeof payload?.requestId === 'string'
? payload.requestId.trim()
: ''
const emitStatus = (
status: 'initializing' | 'opened' | 'failed',
message?: string
) => {
if (!requestId) return
emitSingleExportDialogStatus({ requestId, status, message })
}
try {
if (!hasBaseConfigReadyRef.current) {
emitStatus('initializing')
const ready = await loadBaseConfig()
if (!ready) {
emitStatus('failed', '导出模块初始化失败,请重试')
return
}
}
setSelectedSessions(new Set([sessionId]))
openExportDialog({
scope: 'single',
sessionIds: [sessionId],
sessionNames: [displayName],
title: `导出会话:${displayName}`
})
emitStatus('opened')
} catch (error) {
console.error('聊天页唤起导出弹窗失败:', error)
emitStatus('failed', String(error))
}
})()
})
return unsubscribe
}, [loadBaseConfig, openExportDialog])
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
@@ -2103,6 +2170,41 @@ function ExportPage() {
return set
}, [tasks])
const inProgressSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'running' && task.status !== 'queued') continue
for (const id of task.payload.sessionIds) {
set.add(id)
}
}
return Array.from(set).sort()
}, [tasks])
const inProgressSessionIdsKey = useMemo(
() => inProgressSessionIds.join('||'),
[inProgressSessionIds]
)
useEffect(() => {
inProgressSessionIdsRef.current = inProgressSessionIds
}, [inProgressSessionIds])
useEffect(() => {
emitExportSessionStatus({
inProgressSessionIds: inProgressSessionIdsRef.current
})
}, [inProgressSessionIdsKey])
useEffect(() => {
const unsubscribe = onExportSessionStatusRequest(() => {
emitExportSessionStatus({
inProgressSessionIds: inProgressSessionIdsRef.current
})
})
return unsubscribe
}, [])
const runningCardTypes = useMemo(() => {
const set = new Set<ContentCardType>()
for (const task of tasks) {
@@ -3223,7 +3325,7 @@ function ExportPage() {
</div>
</div>
{exportDialog.open && (
{exportDialog.open && createPortal(
<div className="export-dialog-overlay" onClick={closeExportDialog}>
<div className="export-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<div className="dialog-header">
@@ -3365,7 +3467,8 @@ function ExportPage() {
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
)