mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
图片批量解密 图片解密优化
This commit is contained in:
@@ -34,6 +34,7 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
@@ -385,6 +386,7 @@ function App() {
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
<BatchImageDecryptGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
|
||||
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||
import '../styles/batchTranscribe.scss'
|
||||
|
||||
export const BatchImageDecryptGlobal: React.FC = () => {
|
||||
const {
|
||||
isBatchDecrypting,
|
||||
progress,
|
||||
showToast,
|
||||
showResultToast,
|
||||
result,
|
||||
sessionName,
|
||||
startTime,
|
||||
setShowToast,
|
||||
setShowResultToast
|
||||
} = useBatchImageDecryptStore()
|
||||
|
||||
const voiceToastOccupied = useBatchTranscribeStore(
|
||||
state => state.isBatchTranscribing && state.showToast
|
||||
)
|
||||
|
||||
const [eta, setEta] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBatchDecrypting || !startTime || progress.current === 0) {
|
||||
setEta('')
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed <= 0) return
|
||||
const rate = progress.current / elapsed
|
||||
const remain = progress.total - progress.current
|
||||
if (remain <= 0 || rate <= 0) {
|
||||
setEta('')
|
||||
return
|
||||
}
|
||||
const seconds = Math.ceil((remain / rate) / 1000)
|
||||
if (seconds < 60) {
|
||||
setEta(`${seconds}秒`)
|
||||
} else {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
setEta(`${m}分${s}秒`)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [isBatchDecrypting, progress.current, progress.total, startTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showResultToast) return
|
||||
const timer = window.setTimeout(() => setShowResultToast(false), 6000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [showResultToast, setShowResultToast])
|
||||
|
||||
const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied])
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToast && isBatchDecrypting && createPortal(
|
||||
<div className="batch-progress-toast" style={{ bottom: toastBottom }}>
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>批量解密图片{sessionName ? `(${sessionName})` : ''}</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-info-row">
|
||||
<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>
|
||||
{eta && (
|
||||
<div className="progress-eta">
|
||||
<Clock size={12} />
|
||||
<span>剩余 {eta}</span>
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
)}
|
||||
|
||||
{showResultToast && createPortal(
|
||||
<div className="batch-progress-toast batch-inline-result-toast" style={{ bottom: toastBottom }}>
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<ImageIcon size={14} />
|
||||
<span>图片批量解密完成</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowResultToast(false)} title="关闭">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="batch-progress-toast-body">
|
||||
<div className="batch-inline-result-summary">
|
||||
<div className="batch-inline-result-item success">
|
||||
<CheckCircle size={14} />
|
||||
<span>成功 {result.success}</span>
|
||||
</div>
|
||||
<div className={`batch-inline-result-item ${result.fail > 0 ? 'fail' : 'muted'}`}>
|
||||
<XCircle size={14} />
|
||||
<span>失败 {result.fail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
@@ -27,6 +28,12 @@ interface XmlField {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface BatchImageDecryptCandidate {
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
}
|
||||
|
||||
// 尝试解析 XML 为可编辑字段
|
||||
function parseXmlToFields(xml: string): XmlField[] {
|
||||
const fields: XmlField[] = []
|
||||
@@ -301,11 +308,16 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
||||
const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore()
|
||||
const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore()
|
||||
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 [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false)
|
||||
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
|
||||
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
||||
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
|
||||
|
||||
// 批量删除相关状态
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -1434,6 +1446,37 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setShowBatchConfirm(true)
|
||||
}, [sessions, currentSessionId, isBatchTranscribing])
|
||||
|
||||
const handleBatchDecrypt = useCallback(async () => {
|
||||
if (!currentSessionId || isBatchDecrypting) return
|
||||
const session = sessions.find(s => s.username === currentSessionId)
|
||||
if (!session) {
|
||||
alert('未找到当前会话')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.chat.getAllImageMessages(currentSessionId)
|
||||
if (!result.success || !result.images) {
|
||||
alert(`获取图片消息失败: ${result.error || '未知错误'}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.images.length === 0) {
|
||||
alert('当前会话没有图片消息')
|
||||
return
|
||||
}
|
||||
|
||||
const dateSet = new Set<string>()
|
||||
result.images.forEach((img: BatchImageDecryptCandidate) => {
|
||||
if (img.createTime) dateSet.add(new Date(img.createTime * 1000).toISOString().slice(0, 10))
|
||||
})
|
||||
const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a))
|
||||
|
||||
setBatchImageMessages(result.images)
|
||||
setBatchImageDates(sortedDates)
|
||||
setBatchImageSelectedDates(new Set(sortedDates))
|
||||
setShowBatchDecryptConfirm(true)
|
||||
}, [currentSessionId, isBatchDecrypting, sessions])
|
||||
|
||||
const handleExportCurrentSession = useCallback(() => {
|
||||
if (!currentSessionId) return
|
||||
navigate('/export', {
|
||||
@@ -1557,6 +1600,88 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
|
||||
const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), [])
|
||||
|
||||
const confirmBatchDecrypt = useCallback(async () => {
|
||||
if (!currentSessionId) return
|
||||
|
||||
const selected = batchImageSelectedDates
|
||||
if (selected.size === 0) {
|
||||
alert('请至少选择一个日期')
|
||||
return
|
||||
}
|
||||
|
||||
const images = (batchImageMessages || []).filter(img =>
|
||||
img.createTime && selected.has(new Date(img.createTime * 1000).toISOString().slice(0, 10))
|
||||
)
|
||||
if (images.length === 0) {
|
||||
alert('所选日期下没有图片消息')
|
||||
return
|
||||
}
|
||||
|
||||
const session = sessions.find(s => s.username === currentSessionId)
|
||||
if (!session) return
|
||||
|
||||
setShowBatchDecryptConfirm(false)
|
||||
setBatchImageMessages(null)
|
||||
setBatchImageDates([])
|
||||
setBatchImageSelectedDates(new Set())
|
||||
|
||||
startDecrypt(images.length, session.displayName || session.username)
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i]
|
||||
try {
|
||||
const r = await window.electronAPI.image.decrypt({
|
||||
sessionId: session.username,
|
||||
imageMd5: img.imageMd5,
|
||||
imageDatName: img.imageDatName,
|
||||
force: false
|
||||
})
|
||||
if (r?.success) successCount++
|
||||
else failCount++
|
||||
} catch {
|
||||
failCount++
|
||||
}
|
||||
|
||||
updateDecryptProgress(i + 1, images.length)
|
||||
if (i % 5 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
finishDecrypt(successCount, failCount)
|
||||
}, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
||||
|
||||
const batchImageCountByDate = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
if (!batchImageMessages) return map
|
||||
batchImageMessages.forEach(img => {
|
||||
if (!img.createTime) return
|
||||
const d = new Date(img.createTime * 1000).toISOString().slice(0, 10)
|
||||
map.set(d, (map.get(d) ?? 0) + 1)
|
||||
})
|
||||
return map
|
||||
}, [batchImageMessages])
|
||||
|
||||
const batchImageSelectedCount = useMemo(() => {
|
||||
if (!batchImageMessages) return 0
|
||||
return batchImageMessages.filter(img =>
|
||||
img.createTime && batchImageSelectedDates.has(new Date(img.createTime * 1000).toISOString().slice(0, 10))
|
||||
).length
|
||||
}, [batchImageMessages, batchImageSelectedDates])
|
||||
|
||||
const toggleBatchImageDate = useCallback((date: string) => {
|
||||
setBatchImageSelectedDates(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(date)) next.delete(date)
|
||||
else next.add(date)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates])
|
||||
const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), [])
|
||||
|
||||
const lastSelectedIdRef = useRef<number | null>(null)
|
||||
|
||||
const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
|
||||
@@ -1996,6 +2121,26 @@ function ChatPage(_props: ChatPageProps) {
|
||||
<Mic size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBatchDecrypting) {
|
||||
setShowBatchDecryptToast(true)
|
||||
} else {
|
||||
handleBatchDecrypt()
|
||||
}
|
||||
}}
|
||||
disabled={!currentSessionId}
|
||||
title={isBatchDecrypting
|
||||
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
|
||||
: '批量解密图片'}
|
||||
>
|
||||
{isBatchDecrypting ? (
|
||||
<Loader2 size={18} className="spin" />
|
||||
) : (
|
||||
<ImageIcon size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn jump-to-time-btn"
|
||||
onClick={async () => {
|
||||
@@ -2361,6 +2506,66 @@ function ChatPage(_props: ChatPageProps) {
|
||||
document.body
|
||||
)}
|
||||
{/* 消息右键菜单 */}
|
||||
{showBatchDecryptConfirm && createPortal(
|
||||
<div className="batch-modal-overlay" onClick={() => setShowBatchDecryptConfirm(false)}>
|
||||
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="batch-modal-header">
|
||||
<ImageIcon size={20} />
|
||||
<h3>批量解密图片</h3>
|
||||
</div>
|
||||
<div className="batch-modal-body">
|
||||
<p>选择要解密的日期(仅显示有图片的日期),然后开始解密。</p>
|
||||
{batchImageDates.length > 0 && (
|
||||
<div className="batch-dates-list-wrap">
|
||||
<div className="batch-dates-actions">
|
||||
<button type="button" className="batch-dates-btn" onClick={selectAllBatchImageDates}>全选</button>
|
||||
<button type="button" className="batch-dates-btn" onClick={clearAllBatchImageDates}>取消全选</button>
|
||||
</div>
|
||||
<ul className="batch-dates-list">
|
||||
{batchImageDates.map(dateStr => {
|
||||
const count = batchImageCountByDate.get(dateStr) ?? 0
|
||||
const checked = batchImageSelectedDates.has(dateStr)
|
||||
return (
|
||||
<li key={dateStr}>
|
||||
<label className="batch-date-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleBatchImageDate(dateStr)}
|
||||
/>
|
||||
<span className="batch-date-label">{formatBatchDateLabel(dateStr)}</span>
|
||||
<span className="batch-date-count">{count} 张图片</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="batch-info">
|
||||
<div className="info-item">
|
||||
<span className="label">已选:</span>
|
||||
<span className="value">{batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-warning">
|
||||
<AlertCircle size={16} />
|
||||
<span>批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-modal-footer">
|
||||
<button className="btn-secondary" onClick={() => setShowBatchDecryptConfirm(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-primary" onClick={confirmBatchDecrypt}>
|
||||
<ImageIcon size={16} />
|
||||
开始解密
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{contextMenu && createPortal(
|
||||
<>
|
||||
<div className="context-menu-overlay" onClick={() => setContextMenu(null)}
|
||||
|
||||
64
src/stores/batchImageDecryptStore.ts
Normal file
64
src/stores/batchImageDecryptStore.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export interface BatchImageDecryptState {
|
||||
isBatchDecrypting: boolean
|
||||
progress: { current: number; total: number }
|
||||
showToast: boolean
|
||||
showResultToast: boolean
|
||||
result: { success: number; fail: number }
|
||||
startTime: number
|
||||
sessionName: string
|
||||
|
||||
startDecrypt: (total: number, sessionName: string) => void
|
||||
updateProgress: (current: number, total: number) => void
|
||||
finishDecrypt: (success: number, fail: number) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
setShowResultToast: (show: boolean) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({
|
||||
isBatchDecrypting: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: '',
|
||||
|
||||
startDecrypt: (total, sessionName) => set({
|
||||
isBatchDecrypting: true,
|
||||
progress: { current: 0, total },
|
||||
showToast: true,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: Date.now(),
|
||||
sessionName
|
||||
}),
|
||||
|
||||
updateProgress: (current, total) => set({
|
||||
progress: { current, total }
|
||||
}),
|
||||
|
||||
finishDecrypt: (success, fail) => set({
|
||||
isBatchDecrypting: false,
|
||||
showToast: false,
|
||||
showResultToast: true,
|
||||
result: { success, fail },
|
||||
startTime: 0
|
||||
}),
|
||||
|
||||
setShowToast: (show) => set({ showToast: show }),
|
||||
setShowResultToast: (show) => set({ showResultToast: show }),
|
||||
|
||||
reset: () => set({
|
||||
isBatchDecrypting: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: ''
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -167,6 +167,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
.batch-inline-result-toast {
|
||||
.batch-progress-toast-title {
|
||||
svg {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
.batch-inline-result-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.batch-inline-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: #16a34a;
|
||||
svg { color: #16a34a; }
|
||||
}
|
||||
|
||||
&.fail {
|
||||
color: #dc2626;
|
||||
svg { color: #dc2626; }
|
||||
}
|
||||
|
||||
&.muted {
|
||||
color: var(--text-tertiary, #999);
|
||||
svg { color: var(--text-tertiary, #999); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量转写结果对话框
|
||||
.batch-result-modal {
|
||||
width: 420px;
|
||||
@@ -293,4 +337,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user