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(
-
)
}
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