批量语音转文字改成右下角常驻

This commit is contained in:
xuncha
2026-02-06 23:09:01 +08:00
parent ca1a386146
commit fe0e2e6592
6 changed files with 428 additions and 318 deletions

View File

@@ -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() {
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (
<div className="agreement-overlay">

View File

@@ -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(
<div className="batch-progress-toast">
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<Loader2 size={14} className="spin" />
<span></span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="progress-text">
<span>{progress.current} / {progress.total}</span>
<span className="progress-percent">
{progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0}%
</span>
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${progress.total > 0
? (progress.current / progress.total) * 100
: 0}%`
}}
/>
</div>
</div>
</div>,
document.body
)}
{/* 批量转写结果对话框 */}
{showResult && createPortal(
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<CheckCircle size={20} />
<h3></h3>
</div>
<div className="batch-modal-body">
<div className="result-summary">
<div className="result-item success">
<CheckCircle size={18} />
<span className="label">:</span>
<span className="value">{result.success} </span>
</div>
{result.fail > 0 && (
<div className="result-item fail">
<XCircle size={18} />
<span className="label">:</span>
<span className="value">{result.fail} </span>
</div>
)}
</div>
{result.fail > 0 && (
<div className="result-tip">
<AlertCircle size={16} />
<span></span>
</div>
)}
</div>
<div className="batch-modal-footer">
<button className="btn-primary" onClick={() => setShowResult(false)}>
</button>
</div>
</div>
</div>,
document.body
)}
</>
)
}

View File

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

View File

@@ -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<Message[] | null>(null)
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(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) {
</div>
<div className="header-actions">
<button
className="icon-btn batch-transcribe-btn"
onClick={handleBatchTranscribe}
disabled={isBatchTranscribing || !currentSessionId}
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total})` : '批量语音转文字'}
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
onClick={() => {
if (isBatchTranscribing) {
setShowBatchProgress(true)
} else {
handleBatchTranscribe()
}
}}
disabled={!currentSessionId}
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` : '批量语音转文字'}
>
{isBatchTranscribing ? (
<Loader2 size={18} className="spin" />
@@ -1813,84 +1810,6 @@ function ChatPage(_props: ChatPageProps) {
</div>,
document.body
)}
{/* 批量转写进度对话框 */}
{showBatchProgress && createPortal(
<div className="batch-modal-overlay">
<div className="batch-modal-content batch-progress-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<Loader2 size={20} className="spin" />
<h3>...</h3>
</div>
<div className="batch-modal-body">
<div className="progress-info">
<div className="progress-text">
<span> {batchTranscribeProgress.current} / {batchTranscribeProgress.total} </span>
<span className="progress-percent">
{batchTranscribeProgress.total > 0
? Math.round((batchTranscribeProgress.current / batchTranscribeProgress.total) * 100)
: 0}%
</span>
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${batchTranscribeProgress.total > 0
? (batchTranscribeProgress.current / batchTranscribeProgress.total) * 100
: 0}%`
}}
/>
</div>
</div>
<div className="batch-tip">
<span>使</span>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 批量转写结果对话框 */}
{showBatchResult && createPortal(
<div className="batch-modal-overlay" onClick={() => setShowBatchResult(false)}>
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<CheckCircle size={20} />
<h3></h3>
</div>
<div className="batch-modal-body">
<div className="result-summary">
<div className="result-item success">
<CheckCircle size={18} />
<span className="label">:</span>
<span className="value">{batchResult.success} </span>
</div>
{batchResult.fail > 0 && (
<div className="result-item fail">
<XCircle size={18} />
<span className="label">:</span>
<span className="value">{batchResult.fail} </span>
</div>
)}
</div>
{batchResult.fail > 0 && (
<div className="result-tip">
<AlertCircle size={16} />
<span></span>
</div>
)}
</div>
<div className="batch-modal-footer">
<button className="btn-primary" onClick={() => setShowBatchResult(false)}>
</button>
</div>
</div>
</div>,
document.body
)}
</div>
)
}

View File

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

View File

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