From ca1a386146533553847487b888c0fc0d31237fc4 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:01:31 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E4=BC=98=E5=8C=96html=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportHtml.css | 156 ++++++++++---------------- electron/services/exportHtmlStyles.ts | 149 ++++++++++++------------ src/pages/ExportPage.tsx | 153 +++++++++++++++++++++++-- 3 files changed, 276 insertions(+), 182 deletions(-) diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css index c7898d2..c03d459 100644 --- a/electron/services/exportHtml.css +++ b/electron/services/exportHtml.css @@ -25,83 +25,87 @@ body { .page { max-width: 1080px; - margin: 32px auto 60px; - padding: 0 20px; + margin: 0 auto; + padding: 8px 20px; + height: 100vh; + display: flex; + flex-direction: column; } .header { background: var(--card); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 24px; - margin-bottom: 24px; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); + padding: 12px 20px; + flex-shrink: 0; } .title { - font-size: 24px; + font-size: 16px; font-weight: 600; - margin: 0 0 8px; + margin: 0; + display: inline; } .meta { color: var(--muted); - font-size: 14px; - display: flex; - flex-wrap: wrap; - gap: 12px; + font-size: 13px; + display: inline; + margin-left: 12px; +} + +.meta span { + margin-right: 10px; } .controls { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 20px; -} - -.control { display: flex; - flex-direction: column; - gap: 6px; + align-items: center; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; } -.control label { - font-size: 13px; - color: var(--muted); -} - -.control input, -.control select, -.control button { - border-radius: 12px; +.controls input, +.controls button { + border-radius: 8px; border: 1px solid var(--border); - padding: 10px 12px; - font-size: 14px; + padding: 6px 10px; + font-size: 13px; font-family: inherit; } -.control button { +.controls input[type="search"] { + width: 200px; +} + +.controls input[type="datetime-local"] { + width: 200px; +} + +.controls button { background: var(--accent); color: #fff; border: none; cursor: pointer; - transition: transform 0.1s ease; + padding: 6px 14px; } -.control button:active { +.controls button:active { transform: scale(0.98); } .stats { font-size: 13px; color: var(--muted); - display: flex; - align-items: flex-end; + margin-left: auto; } .message-list { display: flex; flex-direction: column; - gap: 18px; + gap: 12px; + padding: 4px 0; } .message { @@ -248,50 +252,11 @@ body { cursor: zoom-out; } -body[data-theme="cloud-dancer"] { - --accent: #6b8cff; - --sent: #e0e7ff; - --received: #ffffff; - --border: #d8e0f7; - --bg: #f6f7fb; -} - -body[data-theme="corundum-blue"] { - --accent: #2563eb; - --sent: #dbeafe; - --received: #ffffff; - --border: #c7d2fe; - --bg: #eef2ff; -} - -body[data-theme="kiwi-green"] { - --accent: #16a34a; - --sent: #dcfce7; - --received: #ffffff; - --border: #bbf7d0; - --bg: #f0fdf4; -} - -body[data-theme="spicy-red"] { - --accent: #e11d48; - --sent: #ffe4e6; - --received: #ffffff; - --border: #fecdd3; - --bg: #fff1f2; -} - -body[data-theme="teal-water"] { - --accent: #0f766e; - --sent: #ccfbf1; - --received: #ffffff; - --border: #99f6e4; - --bg: #f0fdfa; -} - .highlight { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 18px; + transition: outline-color 0.3s; } .empty { @@ -300,32 +265,29 @@ body[data-theme="teal-water"] { padding: 40px; } -/* Virtual Scroll */ -.virtual-scroll-container { - height: calc(100vh - 180px); - /* Adjust based on header height */ +/* Scroll Container */ +.scroll-container { + flex: 1; + min-height: 0; overflow-y: auto; - position: relative; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); - margin-top: 20px; + margin-top: 8px; + margin-bottom: 8px; + padding: 12px; + -webkit-overflow-scrolling: touch; } -.virtual-scroll-spacer { - opacity: 0; - pointer-events: none; - width: 1px; +.scroll-container::-webkit-scrollbar { + width: 6px; } -.virtual-scroll-content { - position: absolute; - top: 0; - left: 0; - width: 100%; +.scroll-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; } -.message-list { - /* Override message-list to be inside virtual scroll */ - display: block; -} \ No newline at end of file +.load-sentinel { + height: 1px; +} diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts index adb3e61..42d4e07 100644 --- a/electron/services/exportHtmlStyles.ts +++ b/electron/services/exportHtmlStyles.ts @@ -25,83 +25,87 @@ body { .page { max-width: 1080px; - margin: 32px auto 60px; - padding: 0 20px; + margin: 0 auto; + padding: 8px 20px; + height: 100vh; + display: flex; + flex-direction: column; } .header { background: var(--card); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 24px; - margin-bottom: 24px; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); + padding: 12px 20px; + flex-shrink: 0; } .title { - font-size: 24px; + font-size: 16px; font-weight: 600; - margin: 0 0 8px; + margin: 0; + display: inline; } .meta { color: var(--muted); - font-size: 14px; - display: flex; - flex-wrap: wrap; - gap: 12px; + font-size: 13px; + display: inline; + margin-left: 12px; +} + +.meta span { + margin-right: 10px; } .controls { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 20px; -} - -.control { display: flex; - flex-direction: column; - gap: 6px; + align-items: center; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; } -.control label { - font-size: 13px; - color: var(--muted); -} - -.control input, -.control select, -.control button { - border-radius: 12px; +.controls input, +.controls button { + border-radius: 8px; border: 1px solid var(--border); - padding: 10px 12px; - font-size: 14px; + padding: 6px 10px; + font-size: 13px; font-family: inherit; } -.control button { +.controls input[type="search"] { + width: 200px; +} + +.controls input[type="datetime-local"] { + width: 200px; +} + +.controls button { background: var(--accent); color: #fff; border: none; cursor: pointer; - transition: transform 0.1s ease; + padding: 6px 14px; } -.control button:active { +.controls button:active { transform: scale(0.98); } .stats { font-size: 13px; color: var(--muted); - display: flex; - align-items: flex-end; + margin-left: auto; } .message-list { display: flex; flex-direction: column; - gap: 18px; + gap: 12px; + padding: 4px 0; } .message { @@ -248,50 +252,11 @@ body { cursor: zoom-out; } -body[data-theme="cloud-dancer"] { - --accent: #6b8cff; - --sent: #e0e7ff; - --received: #ffffff; - --border: #d8e0f7; - --bg: #f6f7fb; -} - -body[data-theme="corundum-blue"] { - --accent: #2563eb; - --sent: #dbeafe; - --received: #ffffff; - --border: #c7d2fe; - --bg: #eef2ff; -} - -body[data-theme="kiwi-green"] { - --accent: #16a34a; - --sent: #dcfce7; - --received: #ffffff; - --border: #bbf7d0; - --bg: #f0fdf4; -} - -body[data-theme="spicy-red"] { - --accent: #e11d48; - --sent: #ffe4e6; - --received: #ffffff; - --border: #fecdd3; - --bg: #fff1f2; -} - -body[data-theme="teal-water"] { - --accent: #0f766e; - --sent: #ccfbf1; - --received: #ffffff; - --border: #99f6e4; - --bg: #f0fdfa; -} - .highlight { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 18px; + transition: outline-color 0.3s; } .empty { @@ -299,4 +264,32 @@ body[data-theme="teal-water"] { color: var(--muted); padding: 40px; } + +/* Scroll Container */ +.scroll-container { + flex: 1; + min-height: 0; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + margin-top: 8px; + margin-bottom: 8px; + padding: 12px; + -webkit-overflow-scrolling: touch; +} + +.scroll-container::-webkit-scrollbar { + width: 6px; +} + +.scroll-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.load-sentinel { + height: 1px; +} `; + diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c94f12f..93ebdfc 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -46,13 +46,22 @@ function ExportPage() { const [searchKeyword, setSearchKeyword] = useState('') const [exportFolder, setExportFolder] = useState('') const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' }) + const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) const [exportResult, setExportResult] = useState(null) const [showDatePicker, setShowDatePicker] = useState(false) const [calendarDate, setCalendarDate] = useState(new Date()) const [selectingStart, setSelectingStart] = useState(true) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) + const [showPreExportDialog, setShowPreExportDialog] = useState(false) + const [preExportStats, setPreExportStats] = useState<{ + totalMessages: number; voiceMessages: number; cachedVoiceCount: number; + needTranscribeCount: number; mediaMessages: number; estimatedSeconds: number + } | null>(null) + const [isLoadingStats, setIsLoadingStats] = useState(false) + const [pendingLayout, setPendingLayout] = useState('shared') + const exportStartTime = useRef(0) + const [elapsedSeconds, setElapsedSeconds] = useState(0) const displayNameDropdownRef = useRef(null) const [options, setOptions] = useState({ @@ -189,17 +198,30 @@ function ExportPage() { }, [loadSessions]) useEffect(() => { - const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => { + const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string; phaseProgress?: number; phaseTotal?: number; phaseLabel?: string }) => { setExportProgress({ current: payload.current, total: payload.total, - currentName: payload.currentSession + currentName: payload.currentSession, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0 }) }) return () => { removeListener?.() } }, []) + + // 导出计时器 + useEffect(() => { + if (!isExporting) return + const timer = setInterval(() => { + setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000)) + }, 1000) + return () => clearInterval(timer) + }, [isExporting]) + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node @@ -278,8 +300,10 @@ function ExportPage() { if (selectedSessions.size === 0 || !exportFolder) return setIsExporting(true) - setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' }) + setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) setExportResult(null) + exportStartTime.current = Date.now() + setElapsedSeconds(0) try { const sessionList = Array.from(selectedSessions) @@ -322,9 +346,41 @@ function ExportPage() { } } - const startExport = () => { + const startExport = async () => { if (selectedSessions.size === 0 || !exportFolder) return + // 先获取预估统计 + setIsLoadingStats(true) + setShowPreExportDialog(true) + try { + const sessionList = Array.from(selectedSessions) + const exportOptions = { + format: options.format, + exportVoiceAsText: options.exportVoiceAsText, + exportMedia: options.exportMedia, + exportImages: options.exportMedia && options.exportImages, + exportVoices: options.exportMedia && options.exportVoices, + exportVideos: options.exportMedia && options.exportVideos, + exportEmojis: options.exportMedia && options.exportEmojis, + dateRange: options.useAllTime ? null : options.dateRange ? { + start: Math.floor(options.dateRange.start.getTime() / 1000), + end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) + } : null + } + const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions) + setPreExportStats(stats) + } catch (e) { + console.error('获取导出统计失败:', e) + setPreExportStats(null) + } finally { + setIsLoadingStats(false) + } + } + + const confirmExport = () => { + setShowPreExportDialog(false) + setPreExportStats(null) + if (options.exportMedia && selectedSessions.size > 1) { setShowMediaLayoutPrompt(true) return @@ -814,6 +870,71 @@ function ExportPage() { )} + {/* 导出前预估弹窗 */} + {showPreExportDialog && ( +
+
e.stopPropagation()}> +

导出预估

+ {isLoadingStats ? ( +
+ + 正在统计消息... +
+ ) : preExportStats ? ( +
+
+
+ 会话数 +
{selectedSessions.size}
+
+
+ 总消息 +
{preExportStats.totalMessages.toLocaleString()}
+
+ {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && ( + <> +
+ 语音消息 +
{preExportStats.voiceMessages}
+
+
+ 已有缓存 +
{preExportStats.cachedVoiceCount}
+
+ + )} +
+ {options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && ( +
+ + {' '}需要转写 {preExportStats.needTranscribeCount} 条语音,预计耗时约 {preExportStats.estimatedSeconds > 60 + ? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟` + : `${preExportStats.estimatedSeconds} 秒` + } +
+ )} + {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && ( +
+ + {' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写 +
+ )} +
+ ) : ( +

统计信息获取失败,仍可继续导出

+ )} +
+ + +
+
+
+ )} + {/* 导出进度弹窗 */} {isExporting && (
@@ -823,13 +944,31 @@ function ExportPage() {

正在导出

{exportProgress.currentName}

+ {exportProgress.phaseLabel && ( +

+ {exportProgress.phaseLabel} +

+ )} + {exportProgress.phaseTotal > 0 && ( +
+
+
+ )}
0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }} />
-

{exportProgress.current} / {exportProgress.total}

+

+ {exportProgress.current} / {exportProgress.total} 个会话 + + {elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`} + +

)} From fe0e2e6592d0d5c049260671de1dd3e67cc544cf Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:09:01 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E6=89=B9=E9=87=8F=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=BD=AC=E6=96=87=E5=AD=97=E6=94=B9=E6=88=90=E5=8F=B3=E4=B8=8B?= =?UTF-8?q?=E8=A7=92=E5=B8=B8=E9=A9=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 4 + src/components/BatchTranscribeGlobal.tsx | 101 ++++++++++ src/pages/ChatPage.scss | 224 +-------------------- src/pages/ChatPage.tsx | 119 ++---------- src/stores/batchTranscribeStore.ts | 60 ++++++ src/styles/batchTranscribe.scss | 238 +++++++++++++++++++++++ 6 files changed, 428 insertions(+), 318 deletions(-) create mode 100644 src/components/BatchTranscribeGlobal.tsx create mode 100644 src/stores/batchTranscribeStore.ts create mode 100644 src/styles/batchTranscribe.scss diff --git a/src/App.tsx b/src/App.tsx index bcfdce9..2491727 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import UpdateDialog from './components/UpdateDialog' import UpdateProgressCapsule from './components/UpdateProgressCapsule' import LockScreen from './components/LockScreen' import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' +import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' function App() { const navigate = useNavigate() @@ -360,6 +361,9 @@ function App() { {/* 全局会话监听与通知 */} + {/* 全局批量转写进度浮窗 */} + + {/* 用户协议弹窗 */} {showAgreement && !agreementLoading && (
diff --git a/src/components/BatchTranscribeGlobal.tsx b/src/components/BatchTranscribeGlobal.tsx new file mode 100644 index 0000000..b6f15b6 --- /dev/null +++ b/src/components/BatchTranscribeGlobal.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import { Loader2, X, CheckCircle, XCircle, AlertCircle } from 'lucide-react' +import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' +import '../styles/batchTranscribe.scss' + +/** + * 全局批量转写进度浮窗 + 结果弹窗 + * 挂载在 App 层,切换页面时不会消失 + */ +export const BatchTranscribeGlobal: React.FC = () => { + const { + isBatchTranscribing, + progress, + showToast, + showResult, + result, + setShowToast, + setShowResult + } = useBatchTranscribeStore() + + return ( + <> + {/* 批量转写进度浮窗(非阻塞) */} + {showToast && isBatchTranscribing && createPortal( +
+
+
+ + 批量转写中 +
+ +
+
+
+ {progress.current} / {progress.total} + + {progress.total > 0 + ? Math.round((progress.current / progress.total) * 100) + : 0}% + +
+
+
0 + ? (progress.current / progress.total) * 100 + : 0}%` + }} + /> +
+
+
, + document.body + )} + + {/* 批量转写结果对话框 */} + {showResult && createPortal( +
setShowResult(false)}> +
e.stopPropagation()}> +
+ +

转写完成

+
+
+
+
+ + 成功: + {result.success} 条 +
+ {result.fail > 0 && ( +
+ + 失败: + {result.fail} 条 +
+ )} +
+ {result.fail > 0 && ( +
+ + 部分语音转写失败,可能是语音文件损坏或网络问题 +
+ )} +
+
+ +
+
+
, + document.body + )} + + ) +} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 3887409..108352e 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2616,42 +2616,14 @@ &:hover:not(:disabled) { color: var(--primary-color); } + &.transcribing { + color: var(--primary-color); + cursor: pointer; + opacity: 1 !important; + } } -// 批量转写模态框基础样式 -.batch-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; - animation: batchFadeIn 0.2s ease-out; -} - -.batch-modal-content { - background: var(--bg-primary); - border-radius: 12px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); - max-height: 90vh; - overflow-y: auto; - animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); -} - -@keyframes batchFadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes batchSlideUp { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } -} +// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss) // 批量转写确认对话框 .batch-confirm-modal { @@ -2845,187 +2817,3 @@ } } } - -// 批量转写进度对话框 -.batch-progress-modal { - width: 420px; - max-width: 90vw; - - .batch-modal-header { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); - - svg { color: var(--primary-color); } - - h3 { - margin: 0; - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - } - } - - .batch-modal-body { - padding: 1.5rem; - - .progress-info { - .progress-text { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.75rem; - font-size: 14px; - color: var(--text-secondary); - - .progress-percent { - font-weight: 600; - color: var(--primary-color); - font-size: 16px; - } - } - - .progress-bar { - height: 8px; - background: var(--bg-tertiary); - border-radius: 4px; - overflow: hidden; - margin-bottom: 1rem; - - .progress-fill { - height: 100%; - background: linear-gradient(90deg, var(--primary-color), var(--primary-color)); - border-radius: 4px; - transition: width 0.3s ease; - } - } - } - - .batch-tip { - display: flex; - align-items: center; - justify-content: center; - padding: 0.75rem; - background: var(--bg-tertiary); - border-radius: 8px; - - span { - font-size: 13px; - color: var(--text-secondary); - } - } - } -} - -// 批量转写结果对话框 -.batch-result-modal { - width: 420px; - max-width: 90vw; - - .batch-modal-header { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); - - svg { color: #4caf50; } - - h3 { - margin: 0; - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - } - } - - .batch-modal-body { - padding: 1.5rem; - - .result-summary { - display: flex; - flex-direction: column; - gap: 0.75rem; - margin-bottom: 1rem; - - .result-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1rem; - border-radius: 8px; - background: var(--bg-tertiary); - - svg { flex-shrink: 0; } - - .label { - font-size: 14px; - color: var(--text-secondary); - } - - .value { - margin-left: auto; - font-size: 18px; - font-weight: 600; - } - - &.success { - svg { color: #4caf50; } - .value { color: #4caf50; } - } - - &.fail { - svg { color: #f44336; } - .value { color: #f44336; } - } - } - } - - .result-tip { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.75rem; - background: rgba(255, 152, 0, 0.1); - border-radius: 8px; - border: 1px solid rgba(255, 152, 0, 0.3); - - svg { - flex-shrink: 0; - margin-top: 2px; - color: #ff9800; - } - - span { - font-size: 13px; - color: var(--text-secondary); - line-height: 1.5; - } - } - } - - .batch-modal-footer { - display: flex; - justify-content: flex-end; - padding: 1rem 1.5rem; - border-top: 1px solid var(--border-color); - - button { - padding: 0.5rem 1.5rem; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - border: none; - - &.btn-primary { - background: var(--primary-color); - color: white; - &:hover { opacity: 0.9; } - } - } - } -} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index bbbab33..8d118c0 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, XCircle, Copy, Check } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check } from 'lucide-react' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' +import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' @@ -175,17 +176,13 @@ function ChatPage(_props: ChatPageProps) { const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) - // 批量语音转文字相关状态 - const [isBatchTranscribing, setIsBatchTranscribing] = useState(false) - const [batchTranscribeProgress, setBatchTranscribeProgress] = useState({ current: 0, total: 0 }) + // 批量语音转文字相关状态(进度/结果 由全局 store 管理) + const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceMessages, setBatchVoiceMessages] = useState(null) const [batchVoiceDates, setBatchVoiceDates] = useState([]) const [batchSelectedDates, setBatchSelectedDates] = useState>(new Set()) - const [showBatchProgress, setShowBatchProgress] = useState(false) - const [showBatchResult, setShowBatchResult] = useState(false) - const [batchResult, setBatchResult] = useState({ success: 0, fail: 0 }) // 联系人信息加载控制 const isEnrichingRef = useRef(false) @@ -1280,16 +1277,13 @@ function ChatPage(_props: ChatPageProps) { const session = sessions.find(s => s.username === currentSessionId) if (!session) return - setIsBatchTranscribing(true) - setShowBatchProgress(true) - setBatchTranscribeProgress({ current: 0, total: voiceMessages.length }) + startTranscribe(voiceMessages.length) // 检查模型状态 const modelStatus = await window.electronAPI.whisper.getModelStatus() if (!modelStatus?.exists) { alert('SenseVoice 模型未下载,请先在设置中下载模型') - setIsBatchTranscribing(false) - setShowBatchProgress(false) + finishTranscribe(0, 0) return } @@ -1319,15 +1313,12 @@ function ChatPage(_props: ChatPageProps) { if (result.success) successCount++ else failCount++ completedCount++ - setBatchTranscribeProgress({ current: completedCount, total: voiceMessages.length }) + updateProgress(completedCount, voiceMessages.length) }) } - setIsBatchTranscribing(false) - setShowBatchProgress(false) - setBatchResult({ success: successCount, fail: failCount }) - setShowBatchResult(true) - }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages]) + finishTranscribe(successCount, failCount) + }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, startTranscribe, updateProgress, finishTranscribe]) // 批量转写:按日期的消息数量 const batchCountByDate = useMemo(() => { @@ -1475,10 +1466,16 @@ function ChatPage(_props: ChatPageProps) {
, document.body )} - - {/* 批量转写进度对话框 */} - {showBatchProgress && createPortal( -
-
e.stopPropagation()}> -
- -

正在转写...

-
-
-
-
- 已完成 {batchTranscribeProgress.current} / {batchTranscribeProgress.total} 条 - - {batchTranscribeProgress.total > 0 - ? Math.round((batchTranscribeProgress.current / batchTranscribeProgress.total) * 100) - : 0}% - -
-
-
0 - ? (batchTranscribeProgress.current / batchTranscribeProgress.total) * 100 - : 0}%` - }} - /> -
-
-
- 转写过程中可以继续使用其他功能 -
-
-
-
, - document.body - )} - - {/* 批量转写结果对话框 */} - {showBatchResult && createPortal( -
setShowBatchResult(false)}> -
e.stopPropagation()}> -
- -

转写完成

-
-
-
-
- - 成功: - {batchResult.success} 条 -
- {batchResult.fail > 0 && ( -
- - 失败: - {batchResult.fail} 条 -
- )} -
- {batchResult.fail > 0 && ( -
- - 部分语音转写失败,可能是语音文件损坏或网络问题 -
- )} -
-
- -
-
-
, - document.body - )}
) } diff --git a/src/stores/batchTranscribeStore.ts b/src/stores/batchTranscribeStore.ts new file mode 100644 index 0000000..b8ae357 --- /dev/null +++ b/src/stores/batchTranscribeStore.ts @@ -0,0 +1,60 @@ +import { create } from 'zustand' + +export interface BatchTranscribeState { + /** 是否正在批量转写 */ + isBatchTranscribing: boolean + /** 转写进度 */ + progress: { current: number; total: number } + /** 是否显示进度浮窗 */ + showToast: boolean + /** 是否显示结果弹窗 */ + showResult: boolean + /** 转写结果 */ + result: { success: number; fail: number } + + // Actions + startTranscribe: (total: number) => void + updateProgress: (current: number, total: number) => void + finishTranscribe: (success: number, fail: number) => void + setShowToast: (show: boolean) => void + setShowResult: (show: boolean) => void + reset: () => void +} + +export const useBatchTranscribeStore = create((set) => ({ + isBatchTranscribing: false, + progress: { current: 0, total: 0 }, + showToast: false, + showResult: false, + result: { success: 0, fail: 0 }, + + startTranscribe: (total) => set({ + isBatchTranscribing: true, + showToast: true, + progress: { current: 0, total }, + showResult: false, + result: { success: 0, fail: 0 } + }), + + updateProgress: (current, total) => set({ + progress: { current, total } + }), + + finishTranscribe: (success, fail) => set({ + isBatchTranscribing: false, + showToast: false, + showResult: true, + result: { success, fail } + }), + + setShowToast: (show) => set({ showToast: show }), + setShowResult: (show) => set({ showResult: show }), + + reset: () => set({ + isBatchTranscribing: false, + progress: { current: 0, total: 0 }, + showToast: false, + showResult: false, + result: { success: 0, fail: 0 } + }) +})) diff --git a/src/styles/batchTranscribe.scss b/src/styles/batchTranscribe.scss new file mode 100644 index 0000000..175cc2c --- /dev/null +++ b/src/styles/batchTranscribe.scss @@ -0,0 +1,238 @@ +// 批量转写 - 共享基础样式(overlay / modal-content / animations) +// 被 ChatPage.scss 和 BatchTranscribeGlobal.tsx 同时使用 + +.batch-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: batchFadeIn 0.2s ease-out; +} + +.batch-modal-content { + background: var(--bg-primary); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-height: 90vh; + overflow-y: auto; + animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes batchFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes batchSlideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +// 批量转写进度浮窗(非阻塞 toast) +.batch-progress-toast { + position: fixed; + bottom: 24px; + right: 24px; + width: 320px; + background: var(--bg-primary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); + border: 1px solid var(--border-color); + z-index: 10000; + animation: batchToastSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; + + .batch-progress-toast-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border-color); + + .batch-progress-toast-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + + svg { color: var(--primary-color); } + } + } + + .batch-progress-toast-close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + } + + .batch-progress-toast-body { + padding: 12px 14px; + + .progress-text { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + color: var(--text-secondary); + + .progress-percent { + font-weight: 600; + color: var(--primary-color); + font-size: 13px; + } + } + + .progress-bar { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary-color), var(--primary-color)); + border-radius: 3px; + transition: width 0.3s ease; + } + } + } +} + +@keyframes batchToastSlideIn { + from { opacity: 0; transform: translateY(16px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +// 批量转写结果对话框 +.batch-result-modal { + width: 420px; + max-width: 90vw; + + .batch-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + + svg { color: #4caf50; } + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + } + + .batch-modal-body { + padding: 1.5rem; + + .result-summary { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + + .result-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + background: var(--bg-tertiary); + + svg { flex-shrink: 0; } + + .label { + font-size: 14px; + color: var(--text-secondary); + } + + .value { + margin-left: auto; + font-size: 18px; + font-weight: 600; + } + + &.success { + svg { color: #4caf50; } + .value { color: #4caf50; } + } + + &.fail { + svg { color: #f44336; } + .value { color: #f44336; } + } + } + } + + .result-tip { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(255, 152, 0, 0.1); + border-radius: 8px; + border: 1px solid rgba(255, 152, 0, 0.3); + + svg { + flex-shrink: 0; + margin-top: 2px; + color: #ff9800; + } + + span { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + } + } + + .batch-modal-footer { + display: flex; + justify-content: flex-end; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + + button { + padding: 0.5rem 1.5rem; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; + + &.btn-primary { + background: var(--primary-color); + color: white; + &:hover { opacity: 0.9; } + } + } + } +} From 63ac715792be7f4fa081d4458df969d49c8a0bfc Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:09:20 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86html=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 4 + electron/preload.ts | 2 + electron/services/chatService.ts | 75 +++- electron/services/exportService.ts | 648 ++++++++++++++++++----------- src/types/electron.d.ts | 14 +- 5 files changed, 488 insertions(+), 255 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 308444c..ebfd428 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -959,6 +959,10 @@ function registerIpcHandlers() { }) // 导出相关 + ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => { + return exportService.getExportStats(sessionIds, options) + }) + ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { const onProgress = (progress: ExportProgress) => { if (!event.sender.isDestroyed()) { diff --git a/electron/preload.ts b/electron/preload.ts index 849e11d..b6a8559 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -239,6 +239,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 导出 export: { + getExportStats: (sessionIds: string[], options: any) => + ipcRenderer.invoke('export:getExportStats', sessionIds, options), exportSessions: (sessionIds: string[], outputDir: string, options: any) => ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), exportSession: (sessionId: string, outputPath: string, options: any) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index c0568e2..2a20a06 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -117,10 +117,13 @@ class ChatService { private voiceWavCache = new Map() private voiceTranscriptCache = new Map() private voiceTranscriptPending = new Map>() + private transcriptCacheLoaded = false + private transcriptCacheDirty = false + private transcriptFlushTimer: ReturnType | null = null private mediaDbsCache: string[] | null = null private mediaDbsCacheTime = 0 private readonly mediaDbsCacheTtl = 300000 // 5分钟 - private readonly voiceCacheMaxEntries = 50 + private readonly voiceWavCacheMaxEntries = 50 // 缓存 media.db 的表结构信息 private mediaDbSchemaCache = new Map { const startTime = Date.now() + // 确保磁盘缓存已加载 + this.loadTranscriptCacheIfNeeded() try { let msgCreateTime = createTime @@ -3625,18 +3630,76 @@ class ChatService { private cacheVoiceWav(cacheKey: string, wavData: Buffer): void { this.voiceWavCache.set(cacheKey, wavData) - if (this.voiceWavCache.size > this.voiceCacheMaxEntries) { + if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) { const oldestKey = this.voiceWavCache.keys().next().value if (oldestKey) this.voiceWavCache.delete(oldestKey) } } + /** 获取持久化转写缓存文件路径 */ + private getTranscriptCachePath(): string { + const cachePath = this.configService.get('cachePath') + const base = cachePath || join(app.getPath('documents'), 'WeFlow') + return join(base, 'Voices', 'transcripts.json') + } + + /** 首次访问时从磁盘加载转写缓存 */ + private loadTranscriptCacheIfNeeded(): void { + if (this.transcriptCacheLoaded) return + this.transcriptCacheLoaded = true + try { + const filePath = this.getTranscriptCachePath() + if (existsSync(filePath)) { + const raw = readFileSync(filePath, 'utf-8') + const data = JSON.parse(raw) as Record + for (const [k, v] of Object.entries(data)) { + if (typeof v === 'string') this.voiceTranscriptCache.set(k, v) + } + console.log(`[Transcribe] 从磁盘加载了 ${this.voiceTranscriptCache.size} 条转写缓存`) + } + } catch (e) { + console.error('[Transcribe] 加载转写缓存失败:', e) + } + } + + /** 将转写缓存持久化到磁盘(防抖 3 秒) */ + private scheduleTranscriptFlush(): void { + if (this.transcriptFlushTimer) return + this.transcriptFlushTimer = setTimeout(() => { + this.transcriptFlushTimer = null + this.flushTranscriptCache() + }, 3000) + } + + /** 立即写入转写缓存到磁盘 */ + flushTranscriptCache(): void { + if (!this.transcriptCacheDirty) return + try { + const filePath = this.getTranscriptCachePath() + const dir = dirname(filePath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const obj: Record = {} + for (const [k, v] of this.voiceTranscriptCache) obj[k] = v + writeFileSync(filePath, JSON.stringify(obj), 'utf-8') + this.transcriptCacheDirty = false + } catch (e) { + console.error('[Transcribe] 写入转写缓存失败:', e) + } + } + private cacheVoiceTranscript(cacheKey: string, transcript: string): void { this.voiceTranscriptCache.set(cacheKey, transcript) - if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) { - const oldestKey = this.voiceTranscriptCache.keys().next().value - if (oldestKey) this.voiceTranscriptCache.delete(oldestKey) - } + this.transcriptCacheDirty = true + this.scheduleTranscriptFlush() + } + + /** + * 检查某个语音消息是否已有缓存的转写结果 + */ + hasTranscriptCache(sessionId: string, msgId: string, createTime?: number): boolean { + this.loadTranscriptCacheIfNeeded() + const cacheKey = this.getVoiceCacheKey(sessionId, msgId, createTime) + return this.voiceTranscriptCache.has(cacheKey) } /** diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 82864e1..3a5c939 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -106,6 +106,9 @@ export interface ExportProgress { total: number currentSession: string phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' + phaseProgress?: number + phaseTotal?: number + phaseLabel?: string } // 并发控制:限制同时执行的 Promise 数量 @@ -847,16 +850,30 @@ class ExportService { } private escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') + return value.replace(/[&<>"']/g, c => { + switch (c) { + case '&': return '&' + case '<': return '<' + case '>': return '>' + case '"': return '"' + case "'": return ''' + default: return c + } + }) } private escapeAttribute(value: string): string { - return this.escapeHtml(value).replace(/`/g, '`') + return value.replace(/[&<>"'`]/g, c => { + switch (c) { + case '&': return '&' + case '<': return '<' + case '>': return '>' + case '"': return '"' + case "'": return ''' + case '`': return '`' + default: return c + } + }) } private getAvatarFallback(name: string): string { @@ -997,7 +1014,9 @@ class ExportService { if (index % 2 === 1) { const emojiDataUrl = this.getInlineEmojiDataUrl(part) if (emojiDataUrl) { - return `[${this.escapeAttribute(part)}]` + // Cache full tag to avoid re-escaping data URL every time + const escapedName = this.escapeAttribute(part) + return `[${escapedName}]` } return this.escapeHtml(`[${part}]`) } @@ -1135,22 +1154,19 @@ class ExportService { } // 复制文件 - if (fs.existsSync(sourcePath)) { - const ext = path.extname(sourcePath) || '.jpg' - const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` - const destPath = path.join(imagesDir, fileName) + if (!fs.existsSync(sourcePath)) return null + const ext = path.extname(sourcePath) || '.jpg' + const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` + const destPath = path.join(imagesDir, fileName) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(sourcePath, destPath) - } - - return { - relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), - kind: 'image' - } + if (!fs.existsSync(destPath)) { + fs.copyFileSync(sourcePath, destPath) } - return null + return { + relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), + kind: 'image' + } } catch (e) { return null } @@ -1771,9 +1787,10 @@ class ExportService { fs.mkdirSync(avatarsDir, { recursive: true }) } - for (const member of members) { + const AVATAR_CONCURRENCY = 8 + await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => { const fileInfo = this.resolveAvatarFile(member.avatarUrl) - if (!fileInfo) continue + if (!fileInfo) return try { let data: Buffer | null = null let mime = fileInfo.mime @@ -1788,7 +1805,7 @@ class ExportService { mime = downloaded.mime || mime } } - if (!data) continue + if (!data) return // 优先使用内容检测出的 MIME 类型 const detectedMime = this.detectMimeType(data) @@ -1805,15 +1822,19 @@ class ExportService { const filename = `${sanitizedUsername}${ext}` const avatarPath = path.join(avatarsDir, filename) - // 保存头像文件 - await fs.promises.writeFile(avatarPath, data) + // 跳过已存在文件 + try { + await fs.promises.access(avatarPath) + } catch { + await fs.promises.writeFile(avatarPath, data) + } // 返回相对路径 result.set(member.username, `avatars/${filename}`) } catch { - continue + return } - } + }) return result } @@ -2001,11 +2022,15 @@ class ExportService { current: 20, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) // 并行导出媒体,并发数跟随导出设置 const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -2018,6 +2043,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -2029,14 +2066,28 @@ class ExportService { current: 40, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) // 并行转写语音,限制 4 个并发(转写比较耗资源) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 40, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -2335,10 +2386,14 @@ class ExportService { current: 15, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -2351,6 +2406,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 15, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -2362,13 +2429,27 @@ class ExportService { current: 35, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 35, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -2744,10 +2825,14 @@ class ExportService { current: 35, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -2760,6 +2845,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 35, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -2771,13 +2868,27 @@ class ExportService { current: 50, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 50, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -3074,10 +3185,14 @@ class ExportService { current: 25, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -3090,6 +3205,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 25, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -3100,13 +3227,27 @@ class ExportService { current: 45, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 45, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -3231,158 +3372,83 @@ class ExportService { private getVirtualScrollScript(): string { return ` - class VirtualScroller { - constructor(container, list, data, renderItem) { + class ChunkedRenderer { + constructor(container, data, renderItem) { this.container = container; - this.list = list; this.data = data; this.renderItem = renderItem; - - this.rowHeight = 80; // Estimated height - this.buffer = 5; - this.heightCache = new Map(); - this.visibleItems = new Set(); - - this.spacer = document.createElement('div'); - this.spacer.className = 'virtual-scroll-spacer'; - this.content = document.createElement('div'); - this.content.className = 'virtual-scroll-content'; - - this.container.appendChild(this.spacer); - this.container.appendChild(this.content); - - this.container.addEventListener('scroll', () => this.onScroll()); - window.addEventListener('resize', () => this.onScroll()); - - this.updateTotalHeight(); - this.onScroll(); + this.batchSize = 100; + this.rendered = 0; + this.loading = false; + + this.list = document.createElement('div'); + this.list.className = 'message-list'; + this.container.appendChild(this.list); + + this.sentinel = document.createElement('div'); + this.sentinel.className = 'load-sentinel'; + this.container.appendChild(this.sentinel); + + this.renderBatch(); + + this.observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !this.loading) { + this.renderBatch(); + } + }, { root: this.container, rootMargin: '600px' }); + this.observer.observe(this.sentinel); + } + + renderBatch() { + if (this.rendered >= this.data.length) return; + this.loading = true; + const end = Math.min(this.rendered + this.batchSize, this.data.length); + const fragment = document.createDocumentFragment(); + for (let i = this.rendered; i < end; i++) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = this.renderItem(this.data[i], i); + if (wrapper.firstElementChild) fragment.appendChild(wrapper.firstElementChild); + } + this.list.appendChild(fragment); + this.rendered = end; + this.loading = false; } setData(newData) { this.data = newData; - this.heightCache.clear(); - this.content.innerHTML = ''; + this.rendered = 0; + this.list.innerHTML = ''; this.container.scrollTop = 0; - this.updateTotalHeight(); - this.onScroll(); - - // Show/Hide empty state if (this.data.length === 0) { - this.content.innerHTML = '
暂无消息
'; + this.list.innerHTML = '
暂无消息
'; + return; } + this.renderBatch(); } - updateTotalHeight() { - let total = 0; - for (let i = 0; i < this.data.length; i++) { - total += this.heightCache.get(i) || this.rowHeight; - } - this.spacer.style.height = total + 'px'; - } - - onScroll() { - if (this.data.length === 0) return; - - const scrollTop = this.container.scrollTop; - const containerHeight = this.container.clientHeight; - - // Find start index - let currentY = 0; - let startIndex = 0; - for (let i = 0; i < this.data.length; i++) { - const h = this.heightCache.get(i) || this.rowHeight; - if (currentY + h > scrollTop) { - startIndex = i; - break; - } - currentY += h; - } - - // Find end index - let endIndex = startIndex; - let visibleHeight = 0; - for (let i = startIndex; i < this.data.length; i++) { - const h = this.heightCache.get(i) || this.rowHeight; - visibleHeight += h; - endIndex = i; - if (visibleHeight > containerHeight) break; - } - - const start = Math.max(0, startIndex - this.buffer); - const end = Math.min(this.data.length - 1, endIndex + this.buffer); - - this.renderRange(start, end, currentY); - } - - renderRange(start, end, startY) { - // Calculate offset for start item - let topOffset = 0; - for(let i=0; i 1) { - this.heightCache.set(i, actualHeight); - // If height changed significantly, we might need to adjust total height - // But for performance, maybe just do it on next scroll or rarely? - // For now, let's keep it simple. If we update inline style top, we need to know exact previous heights. - } - } - - el.style.top = currentTop + 'px'; - currentTop += (this.heightCache.get(i) || this.rowHeight); - } - - // Cleanup - Array.from(this.content.children).forEach(child => { - if (child.classList.contains('empty')) return; - const idx = parseInt(child.getAttribute('data-index')); - if (!newKeys.has(idx)) { - child.remove(); - } - }); - - this.updateTotalHeight(); - } - scrollToTime(timestamp) { - const idx = this.data.findIndex(item => item.ts >= timestamp); - if (idx !== -1) { - this.scrollToIndex(idx); - } - } - - scrollToIndex(index) { - let top = 0; - for(let i=0; i item.t >= timestamp); + if (idx === -1) return; + // Ensure all messages up to target are rendered + while (this.rendered <= idx) { + this.renderBatch(); + } + const el = this.list.children[idx]; + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('highlight'); + setTimeout(() => el.classList.remove('highlight'), 2500); + } + } + + scrollToIndex(index) { + while (this.rendered <= index) { + this.renderBatch(); + } + const el = this.list.children[index]; + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } - this.container.scrollTop = top; } } `; @@ -3447,10 +3513,14 @@ class ExportService { current: 20, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-media' + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` }) const MEDIA_CONCURRENCY = 6 + let mediaExported = 0 await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { @@ -3464,6 +3534,18 @@ class ExportService { }) mediaCache.set(mediaKey, mediaItem) } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } }) } @@ -3478,13 +3560,27 @@ class ExportService { current: 40, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting-voice' + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` }) const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 40, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) }) } @@ -3535,43 +3631,23 @@ class ExportService {
-

${this.escapeHtml(sessionInfo.displayName)} 的聊天记录

+

${this.escapeHtml(sessionInfo.displayName)}

- 导出时间:${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))} - 消息数量:${sortedMessages.length} - 会话类型:${isGroup ? '群聊' : '私聊'} + ${sortedMessages.length} 条消息 + ${isGroup ? '群聊' : '私聊'} + ${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))}
-
- - -
-
- - -
-
- - -
-
- - -
+ + +
共 ${sortedMessages.length} 条
- -
+
@@ -3584,7 +3660,23 @@ class ExportService { window.WEFLOW_DATA = [ `); - // Write messages in chunks + // Pre-build avatar HTML lookup to avoid per-message rebuilds + const avatarHtmlCache = new Map() + const getAvatarHtml = (username: string, name: string): string => { + const cached = avatarHtmlCache.get(username) + if (cached !== undefined) return cached + const avatarData = avatarMap.get(username) + const html = avatarData + ? `${this.escapeAttribute(name)}` + : `${this.escapeHtml(this.getAvatarFallback(name))}` + avatarHtmlCache.set(username, html) + return html + } + + // Write messages in buffered chunks + const WRITE_BATCH = 100 + let writeBuf: string[] = [] + for (let i = 0; i < sortedMessages.length; i++) { const msg = sortedMessages[i] const mediaKey = `${msg.localType}_${msg.localId}` @@ -3597,10 +3689,8 @@ class ExportService { : (isGroup ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) : (sessionInfo.displayName || sessionId)) - const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername) - const avatarHtml = avatarData - ? `${this.escapeAttribute(senderName)}` - : `${this.escapeHtml(this.getAvatarFallback(senderName))}` + + const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName) const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) @@ -3634,14 +3724,7 @@ class ExportService { ? `
${this.escapeHtml(senderName)}
` : '' const timeHtml = `
${this.escapeHtml(timeText)}
` - const messageBody = ` - ${timeHtml} - ${senderNameHtml} -
- ${mediaHtml} - ${textHtml} -
- ` + const messageBody = `${timeHtml}${senderNameHtml}
${mediaHtml}${textHtml}
` // Compact JSON object const itemObj = { @@ -3652,8 +3735,15 @@ class ExportService { b: messageBody // body HTML } - const jsonStr = JSON.stringify(itemObj) - await writePromise(jsonStr + (i < sortedMessages.length - 1 ? ',\n' : '\n')) + writeBuf.push(JSON.stringify(itemObj)) + + // Flush buffer periodically + if (writeBuf.length >= WRITE_BATCH || i === sortedMessages.length - 1) { + const isLast = i === sortedMessages.length - 1 + const chunk = writeBuf.join(',\n') + (isLast ? '\n' : ',\n') + await writePromise(chunk) + writeBuf = [] + } // Report progress occasionally if ((i + 1) % 500 === 0) { @@ -3676,10 +3766,9 @@ class ExportService { const timeInput = document.getElementById('timeInput') const jumpBtn = document.getElementById('jumpBtn') const resultCount = document.getElementById('resultCount') - const themeSelect = document.getElementById('themeSelect') const imagePreview = document.getElementById('imagePreview') const imagePreviewTarget = document.getElementById('imagePreviewTarget') - const container = document.getElementById('virtualScrollContainer') + const container = document.getElementById('scrollContainer') let imageZoom = 1 // Initial Data @@ -3701,7 +3790,7 @@ class ExportService { \`; }; - const scroller = new VirtualScroller(container, [], currentList, renderItem); + const renderer = new ChunkedRenderer(container, currentList, renderItem); const updateCount = () => { resultCount.textContent = \`共 \${currentList.length} 条\` @@ -3716,14 +3805,11 @@ class ExportService { if (!keyword) { currentList = allData; } else { - // Simplified search: check raw html content (contains body text and sender name) - // Ideally we should search raw text, but we only have pre-rendered HTML in JSON 'b' (body) - // 'b' contains message content and sender name. currentList = allData.filter(item => { return item.b.toLowerCase().includes(keyword); }); } - scroller.setData(currentList); + renderer.setData(currentList); updateCount(); }, 300); }) @@ -3733,21 +3819,7 @@ class ExportService { const value = timeInput.value if (!value) return const target = Math.floor(new Date(value).getTime() / 1000) - // Find in current list - scroller.scrollToTime(target); - }) - - // Theme Logic - const applyTheme = (value) => { - document.body.setAttribute('data-theme', value) - localStorage.setItem('weflow-export-theme', value) - } - const storedTheme = localStorage.getItem('weflow-export-theme') || 'cloud-dancer' - themeSelect.value = storedTheme - applyTheme(storedTheme) - - themeSelect.addEventListener('change', (event) => { - applyTheme(event.target.value) + renderer.scrollToTime(target); }) // Image Preview (Delegation) @@ -3788,7 +3860,6 @@ class ExportService { }) updateCount() - console.log('WeFlow Export Loaded', allData.length); `); @@ -3811,6 +3882,77 @@ class ExportService { } } + /** + * 获取导出前的预估统计信息 + */ + async getExportStats( + sessionIds: string[], + options: ExportOptions + ): Promise<{ + totalMessages: number + voiceMessages: number + cachedVoiceCount: number + needTranscribeCount: number + mediaMessages: number + estimatedSeconds: number + sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> + }> { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) { + return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } + } + const cleanedMyWxid = conn.cleanedWxid + const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] + let totalMessages = 0 + let voiceMessages = 0 + let cachedVoiceCount = 0 + let mediaMessages = 0 + + for (const sessionId of sessionIds) { + const sessionInfo = await this.getContactInfo(sessionId) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const msgs = collected.rows + const voiceMsgs = msgs.filter(m => m.localType === 34) + const mediaMsgs = msgs.filter(m => { + const t = m.localType + return (t === 3) || (t === 47) || (t === 43) || (t === 34) + }) + + // 检查已缓存的转写数量 + let cached = 0 + for (const msg of voiceMsgs) { + if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) { + cached++ + } + } + + totalMessages += msgs.length + voiceMessages += voiceMsgs.length + cachedVoiceCount += cached + mediaMessages += mediaMsgs.length + sessionsStats.push({ + sessionId, + displayName: sessionInfo.displayName, + totalCount: msgs.length, + voiceCount: voiceMsgs.length + }) + } + + const needTranscribeCount = voiceMessages - cachedVoiceCount + // 预估:每条语音转文字约 2 秒 + const estimatedSeconds = needTranscribeCount * 2 + + return { + totalMessages, + voiceMessages, + cachedVoiceCount, + needTranscribeCount, + mediaMessages, + estimatedSeconds, + sessions: sessionsStats + } + } + /** * 批量导出多个会话 */ @@ -3850,7 +3992,17 @@ class ExportService { await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => { const sessionInfo = await this.getContactInfo(sessionId) - onProgress?.({ + // 创建包装后的进度回调,自动附加会话级信息 + const sessionProgress = (progress: ExportProgress) => { + onProgress?.({ + ...progress, + current: completedCount, + total: sessionIds.length, + currentSession: sessionInfo.displayName + }) + } + + sessionProgress({ current: completedCount, total: sessionIds.length, currentSession: sessionInfo.displayName, @@ -3874,15 +4026,15 @@ class ExportService { let result: { success: boolean; error?: string } if (options.format === 'json') { - result = await this.exportSessionToDetailedJson(sessionId, outputPath, options) + result = await this.exportSessionToDetailedJson(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') { - result = await this.exportSessionToChatLab(sessionId, outputPath, options) + result = await this.exportSessionToChatLab(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'excel') { - result = await this.exportSessionToExcel(sessionId, outputPath, options) + result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'txt') { - result = await this.exportSessionToTxt(sessionId, outputPath, options) + result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'html') { - result = await this.exportSessionToHtml(sessionId, outputPath, options) + result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 371c9b9..e66004c 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -403,6 +403,15 @@ export interface ElectronAPI { onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void } export: { + getExportStats: (sessionIds: string[], options: any) => Promise<{ + totalMessages: number + voiceMessages: number + cachedVoiceCount: number + needTranscribeCount: number + mediaMessages: number + estimatedSeconds: number + sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> + }> exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{ success: boolean successCount?: number @@ -494,7 +503,10 @@ export interface ExportProgress { current: number total: number currentSession: string - phase: 'preparing' | 'exporting' | 'writing' | 'complete' + phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' + phaseProgress?: number + phaseTotal?: number + phaseLabel?: string } export interface WxidInfo { From c988e4accf427f685449866592968d4a0b28d2a4 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:11:03 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E8=BD=AC=E5=86=99=E7=9A=84=E6=98=BE=E7=A4=BA=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/BatchTranscribeGlobal.tsx | 3 ++- src/pages/ChatPage.tsx | 2 +- src/stores/batchTranscribeStore.ts | 13 +++++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/BatchTranscribeGlobal.tsx b/src/components/BatchTranscribeGlobal.tsx index b6f15b6..51932be 100644 --- a/src/components/BatchTranscribeGlobal.tsx +++ b/src/components/BatchTranscribeGlobal.tsx @@ -15,6 +15,7 @@ export const BatchTranscribeGlobal: React.FC = () => { showToast, showResult, result, + sessionName, setShowToast, setShowResult } = useBatchTranscribeStore() @@ -27,7 +28,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
- 批量转写中 + 批量转写中{sessionName ? `(${sessionName})` : ''}