修复了一些已知问题

This commit is contained in:
ILoveBingLu
2026-02-07 23:50:57 +08:00
parent 948d5ec82c
commit e2aa9e09ac
19 changed files with 1491 additions and 342 deletions
+13 -8
View File
@@ -15,9 +15,14 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
// },
{
icon: <MessageSquareQuote size={20} />,
title: '优化',
desc: '修复了一些已知问题。'
}//,
title: '优化1',
desc: '优化图片加载逻辑。'
},
{
icon: <MessageSquareQuote size={20} />,
title: '优化2',
desc: '优化批量语音转文字功能。'
},
// {
// icon: <Sparkles size={20} />,
// title: 'AI摘要',
@@ -28,11 +33,11 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
// title: '体验升级',
// desc: '修复了一些已知的问题。'
// }//,
// {
// icon: <Mic size={20} />,
// title: '语音增强',
// desc: '语音转文字支持多模型选择,灵活平衡识别精度与速度,适配更多场景。'
// }
{
icon: <Mic size={20} />,
title: '新功能',
desc: '数据管理界面可查看所有解密后的图片。'
}
]
return (
+98
View File
@@ -1833,6 +1833,13 @@
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.transfer-desc {
font-size: 12px;
margin-bottom: 4px;
opacity: 0.9;
line-height: 1.4;
}
.transfer-memo {
font-size: 13px;
margin-bottom: 8px;
@@ -3682,6 +3689,87 @@ video::-webkit-media-controls-fullscreen-button {
line-height: 1.6;
}
// 有语音的日期列表 batch-info 等区块风格统一
.batch-dates-list-wrap {
margin-bottom: 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
.batch-dates-actions {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.batch-dates-btn {
padding: 0.35rem 0.75rem;
font-size: 12px;
color: var(--primary);
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--primary-light);
border-color: var(--primary);
}
}
}
.batch-dates-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 160px;
overflow-y: auto;
li {
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
}
.batch-date-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--bg-hover);
}
input[type="checkbox"] {
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
}
.batch-date-label {
flex: 1;
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
}
.batch-date-count {
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
}
}
}
}
.batch-info {
background: var(--bg-tertiary);
border-radius: 8px;
@@ -3770,6 +3858,16 @@ video::-webkit-media-controls-fullscreen-button {
opacity: 0.9;
}
}
&.batch-transcribe-btn {
background: var(--primary);
color: #fff;
&:hover {
background: var(--primary-hover);
opacity: 1;
}
}
}
}
}
+176 -28
View File
@@ -299,6 +299,9 @@ function ChatPage(_props: ChatPageProps) {
const [batchTranscribeProgress, setBatchTranscribeProgress] = useState({ current: 0, total: 0 })
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
const [batchVoiceCount, setBatchVoiceCount] = useState(0) // 保存查询到的语音消息数量
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null) // 当前会话所有语音消息(用于按日期筛选)
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([]) // 有语音的日期列表 YYYY-MM-DD,仅展示可选项
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 }) // 转写结果
@@ -689,30 +692,49 @@ function ChatPage(_props: ChatPageProps) {
return
}
// 保存语音消息数量
// 统计有语音的日期(仅这些日期可选)
const dateSet = new Set<string>()
voiceMessages.forEach(m => dateSet.add(new Date(m.createTime * 1000).toISOString().slice(0, 10)))
const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a)) // 最近的排上面
setBatchVoiceMessages(voiceMessages)
setBatchVoiceCount(voiceMessages.length)
// 显示确认对话框
setBatchVoiceDates(sortedDates)
setBatchSelectedDates(new Set(sortedDates)) // 默认全选
setShowBatchConfirm(true)
}, [sessions, currentSessionId, isBatchTranscribing])
// 确认批量转写
// 确认批量转写(仅转写选中日期内的语音)
const confirmBatchTranscribe = useCallback(async () => {
setShowBatchConfirm(false)
if (!currentSessionId) return
const session = sessions.find(s => s.username === currentSessionId)
if (!session) return
// 从数据库获取所有语音消息
const result = await window.electronAPI.chat.getAllVoiceMessages(currentSessionId)
if (!result.success || !result.messages) {
alert(`获取语音消息失败: ${result.error || '未知错误'}`)
const selected = batchSelectedDates
if (selected.size === 0) {
alert('请至少选择一个日期')
return
}
const voiceMessages = result.messages
const messages = batchVoiceMessages
if (!messages || messages.length === 0) {
setShowBatchConfirm(false)
return
}
const voiceMessages = messages.filter(m =>
selected.has(new Date(m.createTime * 1000).toISOString().slice(0, 10))
)
if (voiceMessages.length === 0) {
alert('所选日期下没有语音消息')
return
}
setShowBatchConfirm(false)
setBatchVoiceMessages(null)
setBatchVoiceDates([])
setBatchSelectedDates(new Set())
const session = sessions.find(s => s.username === currentSessionId)
if (!session) return
setIsBatchTranscribing(true)
setShowBatchProgress(true) // 显示进度对话框
@@ -814,7 +836,42 @@ function ChatPage(_props: ChatPageProps) {
// 显示结果对话框
setBatchResult({ success: successCount, fail: failCount })
setShowBatchResult(true)
}, [sessions, currentSessionId])
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages])
// 批量转写:按日期的消息数量
const batchCountByDate = useMemo(() => {
const map = new Map<string, number>()
if (!batchVoiceMessages) return map
batchVoiceMessages.forEach(m => {
const d = new Date(m.createTime * 1000).toISOString().slice(0, 10)
map.set(d, (map.get(d) || 0) + 1)
})
return map
}, [batchVoiceMessages])
// 批量转写:选中日期对应的语音条数
const batchSelectedMessageCount = useMemo(() => {
if (!batchVoiceMessages) return 0
return batchVoiceMessages.filter(m =>
batchSelectedDates.has(new Date(m.createTime * 1000).toISOString().slice(0, 10))
).length
}, [batchVoiceMessages, batchSelectedDates])
const toggleBatchDate = useCallback((date: string) => {
setBatchSelectedDates(prev => {
const next = new Set(prev)
if (next.has(date)) next.delete(date)
else next.add(date)
return next
})
}, [])
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), [])
const formatBatchDateLabel = useCallback((dateStr: string) => {
const [y, m, d] = dateStr.split('-').map(Number)
return `${y}${m}${d}`
}, [])
// 加载当前月份有消息的日期
useEffect(() => {
@@ -1876,15 +1933,42 @@ function ChatPage(_props: ChatPageProps) {
<h3></h3>
</div>
<div className="modal-body">
<p></p>
<p></p>
{batchVoiceDates.length > 0 && (
<div className="batch-dates-list-wrap">
<div className="batch-dates-actions">
<button type="button" className="batch-dates-btn" onClick={selectAllBatchDates}></button>
<button type="button" className="batch-dates-btn" onClick={clearAllBatchDates}></button>
</div>
<ul className="batch-dates-list">
{batchVoiceDates.map(dateStr => {
const count = batchCountByDate.get(dateStr) ?? 0
const checked = batchSelectedDates.has(dateStr)
return (
<li key={dateStr}>
<label className="batch-date-row">
<input
type="checkbox"
checked={checked}
onChange={() => toggleBatchDate(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">{batchVoiceCount} </span>
<span className="label">:</span>
<span className="value">{batchSelectedDates.size} {batchSelectedMessageCount} </span>
</div>
<div className="info-item">
<span className="label">:</span>
<span className="value"> {Math.ceil(batchVoiceCount * 2 / 60)} </span>
<span className="value"> {Math.ceil(batchSelectedMessageCount * 2 / 60)} </span>
</div>
</div>
<div className="batch-warning">
@@ -1896,7 +1980,7 @@ function ChatPage(_props: ChatPageProps) {
<button className="btn-secondary" onClick={() => setShowBatchConfirm(false)}>
</button>
<button className="btn-primary" onClick={confirmBatchTranscribe}>
<button className="btn-primary batch-transcribe-btn" onClick={confirmBatchTranscribe}>
<Mic size={16} />
</button>
@@ -1987,6 +2071,28 @@ function ChatPage(_props: ChatPageProps) {
)
}
// 全局语音播放管理器:同一时间只能播放一条语音
const globalVoiceManager = {
currentAudio: null as HTMLAudioElement | null,
currentStopCallback: null as (() => void) | null,
play(audio: HTMLAudioElement, onStop: () => void) {
// 停止当前正在播放的语音
if (this.currentAudio && this.currentAudio !== audio) {
this.currentAudio.pause()
this.currentAudio.currentTime = 0
this.currentStopCallback?.()
}
this.currentAudio = audio
this.currentStopCallback = onStop
},
stop(audio: HTMLAudioElement) {
if (this.currentAudio === audio) {
this.currentAudio = null
this.currentStopCallback = null
}
},
}
// 前端表情包缓存 (LRU 限制)
const emojiDataUrlCache = new LRUCache<string, string>(200)
// 前端图片缓存 (LRU 限制)
@@ -2058,6 +2164,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
const isSent = message.isSend === 1
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
const [senderName, setSenderName] = useState<string | undefined>(undefined)
const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined)
const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined)
const [emojiError, setEmojiError] = useState(false)
const [emojiLoading, setEmojiLoading] = useState(false)
const [imageError, setImageError] = useState(false)
@@ -2336,8 +2444,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
if (voicePlaying) {
voiceRef.current.pause()
setVoicePlaying(false)
globalVoiceManager.stop(voiceRef.current)
} else {
voiceRef.current.currentTime = 0
// 停止其他正在播放的语音,确保同一时间只播放一条
globalVoiceManager.play(voiceRef.current, () => {
voiceRef.current?.pause()
setVoicePlaying(false)
})
voiceRef.current.play()
setVoicePlaying(true)
}
@@ -2355,6 +2469,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
// 等待状态更新后播放
requestAnimationFrame(() => {
if (voiceRef.current) {
// 停止其他正在播放的语音
globalVoiceManager.play(voiceRef.current, () => {
voiceRef.current?.pause()
setVoicePlaying(false)
})
voiceRef.current.play()
setVoicePlaying(true)
}
@@ -2372,6 +2491,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
// 语音播放结束
const handleVoiceEnded = useCallback(() => {
setVoicePlaying(false)
if (voiceRef.current) globalVoiceManager.stop(voiceRef.current)
}, [])
// 语音转文字处理
@@ -2543,6 +2663,20 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
}
}, [isGroupChat, isSent, message.senderUsername])
// 解析转账消息的付款方和收款方显示名称
useEffect(() => {
if (!message.transferPayerUsername || !message.transferReceiverUsername) return
if (message.localType !== 49 && message.localType !== 8589934592049) return
window.electronAPI.chat.resolveTransferDisplayNames(
session.username,
message.transferPayerUsername,
message.transferReceiverUsername
).then((result: { payerName: string; receiverName: string }) => {
setTransferPayerName(result.payerName)
setTransferReceiverName(result.receiverName)
}).catch(() => {})
}, [message.transferPayerUsername, message.transferReceiverUsername, session.username])
// 自动下载表情包
useEffect(() => {
if (emojiLocalPath) return
@@ -3349,25 +3483,39 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
if (appMsgType === '2000') {
try {
const content = message.rawContent || message.parsedContent || ''
const xmlStr = content.includes('<msg>') ? content.substring(content.indexOf('<msg>')) : content
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/xml')
const transferDoc = parser.parseFromString(xmlStr, 'text/xml')
const feedesc = doc.querySelector('feedesc')?.textContent || ''
const payMemo = doc.querySelector('pay_memo')?.textContent || ''
const paysubtype = doc.querySelector('paysubtype')?.textContent || '1'
const feedesc = transferDoc.querySelector('feedesc')?.textContent || ''
const payMemo = transferDoc.querySelector('pay_memo')?.textContent || ''
const paysubtype = transferDoc.querySelector('paysubtype')?.textContent || '1'
// paysubtype: 1=待收款, 3=已收款
const isReceived = paysubtype === '3'
// 构建 "A 转账给 B" 描述
const transferDesc = transferPayerName && transferReceiverName
? `${transferPayerName} 转账给 ${transferReceiverName}`
: ''
return (
<div className={`transfer-message ${isReceived ? 'received' : ''}`}>
<div className="transfer-icon">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" />
<path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{isReceived ? (
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" />
<path d="M12 20l6 6 10-12" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" />
<path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<div className="transfer-info">
{transferDesc && <div className="transfer-desc">{transferDesc}</div>}
<div className="transfer-amount">{feedesc}</div>
{payMemo && <div className="transfer-memo">{payMemo}</div>}
<div className="transfer-label">{isReceived ? '已收款' : '微信转账'}</div>
+144
View File
@@ -318,6 +318,150 @@
}
}
// 媒体网格样式
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
max-height: 600px;
overflow-y: auto;
padding: 4px;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
&:hover {
background: var(--text-tertiary);
}
}
&.emoji-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
}
.media-item {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
background: var(--bg-primary);
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: var(--primary);
}
&.pending {
opacity: 0.6;
cursor: not-allowed;
&:hover {
transform: none;
box-shadow: none;
border-color: transparent;
}
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-tertiary);
svg {
opacity: 0.5;
}
span {
font-size: 12px;
}
}
.media-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
color: white;
font-size: 11px;
display: flex;
flex-direction: column;
gap: 2px;
opacity: 0;
transition: opacity 0.2s;
.media-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.media-size {
opacity: 0.8;
}
}
&:hover .media-info {
opacity: 1;
}
}
.emoji-item {
aspect-ratio: 1;
img {
padding: 12px;
object-fit: contain;
}
.media-info {
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
padding: 6px 8px;
.media-name {
font-size: 10px;
}
}
}
.more-hint {
grid-column: 1 / -1;
text-align: center;
padding: 20px;
font-size: 13px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
border-radius: 8px;
}
@keyframes slideDown {
from {
opacity: 0;
+328 -66
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw } from 'lucide-react'
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile } from 'lucide-react'
import './DataManagementPage.scss'
interface DatabaseFile {
@@ -13,8 +13,22 @@ interface DatabaseFile {
needsUpdate?: boolean
}
interface ImageFileInfo {
fileName: string
filePath: string
fileSize: number
isDecrypted: boolean
decryptedPath?: string
version: number
}
type TabType = 'database' | 'images' | 'emojis'
function DataManagementPage() {
const [activeTab, setActiveTab] = useState<TabType>('database')
const [databases, setDatabases] = useState<DatabaseFile[]>([])
const [images, setImages] = useState<ImageFileInfo[]>([])
const [emojis, setEmojis] = useState<ImageFileInfo[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isDecrypting, setIsDecrypting] = useState(false)
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
@@ -37,8 +51,65 @@ function DataManagementPage() {
}
}, [])
const loadImages = useCallback(async () => {
setIsLoading(true)
try {
console.log('[DataManagement] 开始加载图片...')
// 获取图片目录列表
const dirsResult = await window.electronAPI.dataManagement.getImageDirectories()
console.log('[DataManagement] 图片目录结果:', dirsResult)
if (!dirsResult.success || !dirsResult.directories || dirsResult.directories.length === 0) {
showMessage('未找到图片目录,请先解密数据库', false)
setIsLoading(false)
return
}
// 扫描第一个目录的图片
const firstDir = dirsResult.directories[0]
console.log('[DataManagement] 扫描目录:', firstDir.path)
const result = await window.electronAPI.dataManagement.scanImages(firstDir.path)
console.log('[DataManagement] 扫描结果:', result)
if (result.success && result.images) {
console.log('[DataManagement] 找到图片数量:', result.images.length)
// 分离图片和表情包
const imageList: ImageFileInfo[] = []
const emojiList: ImageFileInfo[] = []
result.images.forEach(img => {
console.log('[DataManagement] 处理图片:', img.fileName, '路径:', img.filePath)
// 根据路径判断是否是表情包
if (img.filePath.includes('CustomEmotions') || img.filePath.includes('emoji')) {
emojiList.push(img)
} else {
imageList.push(img)
}
})
console.log('[DataManagement] 图片分类完成 - 普通图片:', imageList.length, '表情包:', emojiList.length)
setImages(imageList)
setEmojis(emojiList)
} else {
showMessage(result.error || '扫描图片失败', false)
}
} catch (e) {
console.error('[DataManagement] 扫描图片异常:', e)
showMessage(`扫描图片失败: ${e}`, false)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
loadDatabases()
if (activeTab === 'database') {
loadDatabases()
} else if (activeTab === 'images' || activeTab === 'emojis') {
loadImages()
}
// 监听进度(手动更新/解密时显示进度弹窗)
const removeProgressListener = window.electronAPI.dataManagement.onProgress(async (data) => {
@@ -53,7 +124,11 @@ function DataManagementPage() {
setProgress(null)
// 更新完成后自动刷新数据库列表(显示最新的解密状态和更新状态)
if (data.type === 'complete') {
await loadDatabases()
if (activeTab === 'database') {
await loadDatabases()
} else if (activeTab === 'images' || activeTab === 'emojis') {
await loadImages()
}
}
}
})
@@ -66,7 +141,11 @@ function DataManagementPage() {
if (lastUpdateState && !hasUpdate) {
// 更新完成,延迟一点刷新,确保后端更新完成
setTimeout(async () => {
await loadDatabases()
if (activeTab === 'database') {
await loadDatabases()
} else if (activeTab === 'images' || activeTab === 'emojis') {
await loadImages()
}
}, 1000)
}
lastUpdateState = hasUpdate
@@ -76,27 +155,35 @@ function DataManagementPage() {
removeProgressListener()
removeUpdateListener()
}
}, [loadDatabases])
}, [activeTab, loadDatabases, loadImages])
// 当路由变化到数据管理页面时,重新加载数据
useEffect(() => {
if (location.pathname === '/data-management') {
loadDatabases()
if (activeTab === 'database') {
loadDatabases()
} else if (activeTab === 'images' || activeTab === 'emojis') {
loadImages()
}
}
}, [location.pathname, loadDatabases])
}, [location.pathname, activeTab, loadDatabases, loadImages])
// 窗口可见性变化时刷新数据
useEffect(() => {
const handleVisibilityChange = async () => {
if (!document.hidden && location.pathname === '/data-management') {
// 窗口从隐藏变为可见时,重新加载数据库列表
await loadDatabases()
// 窗口从隐藏变为可见时,重新加载数据
if (activeTab === 'database') {
await loadDatabases()
} else if (activeTab === 'images' || activeTab === 'emojis') {
await loadImages()
}
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [location.pathname, loadDatabases])
}, [location.pathname, activeTab, loadDatabases, loadImages])
const showMessage = (text: string, success: boolean) => {
@@ -181,10 +268,35 @@ function DataManagementPage() {
}
}
const handleRefresh = () => {
if (activeTab === 'database') {
loadDatabases()
} else if (activeTab === 'images' || activeTab === 'emojis') {
loadImages()
}
}
const handleImageClick = async (image: ImageFileInfo) => {
if (!image.isDecrypted) {
showMessage('图片未解密,请先解密数据库', false)
return
}
// 打开图片查看窗口
try {
await window.electronAPI.window.openImageViewerWindow(image.decryptedPath || image.filePath)
} catch (e) {
showMessage(`打开图片失败: ${e}`, false)
}
}
const pendingCount = databases.filter(db => !db.isDecrypted).length
const decryptedCount = databases.filter(db => db.isDecrypted).length
const needsUpdateCount = databases.filter(db => db.needsUpdate).length
const decryptedImagesCount = images.filter(img => img.isDecrypted).length
const decryptedEmojisCount = emojis.filter(emoji => emoji.isDecrypted).length
return (
<>
@@ -216,72 +328,222 @@ function DataManagementPage() {
<div className="page-header">
<h1></h1>
<div className="header-tabs">
<button
className={`tab-btn ${activeTab === 'database' ? 'active' : ''}`}
onClick={() => setActiveTab('database')}
>
<Database size={16} />
</button>
<button
className={`tab-btn ${activeTab === 'images' ? 'active' : ''}`}
onClick={() => setActiveTab('images')}
>
<ImageIcon size={16} />
({decryptedImagesCount}/{images.length})
</button>
<button
className={`tab-btn ${activeTab === 'emojis' ? 'active' : ''}`}
onClick={() => setActiveTab('emojis')}
>
<Smile size={16} />
({decryptedEmojisCount}/{emojis.length})
</button>
</div>
</div>
<div className="page-scroll">
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc">
{isLoading ? '正在扫描...' : `已找到 ${databases.length} 个数据库,${decryptedCount} 个已解密,${pendingCount} 个待解密`}
</p>
</div>
<div className="section-actions">
<button className="btn btn-secondary" onClick={loadDatabases} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
</button>
{needsUpdateCount > 0 && (
<button
className="btn btn-warning"
onClick={handleIncrementalUpdate}
disabled={isDecrypting}
>
<RefreshCcw size={16} />
({needsUpdateCount})
{activeTab === 'database' && (
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc">
{isLoading ? '正在扫描...' : `已找到 ${databases.length} 个数据库,${decryptedCount} 个已解密,${pendingCount} 个待解密`}
</p>
</div>
<div className="section-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
</button>
)}
<button
className="btn btn-primary"
onClick={handleDecryptAll}
disabled={isDecrypting || pendingCount === 0}
>
<Unlock size={16} />
{isDecrypting ? '解密中...' : '批量解密'}
</button>
{needsUpdateCount > 0 && (
<button
className="btn btn-warning"
onClick={handleIncrementalUpdate}
disabled={isDecrypting}
>
<RefreshCcw size={16} />
({needsUpdateCount})
</button>
)}
<button
className="btn btn-primary"
onClick={handleDecryptAll}
disabled={isDecrypting || pendingCount === 0}
>
<Unlock size={16} />
{isDecrypting ? '解密中...' : '批量解密'}
</button>
</div>
</div>
</div>
<div className="database-list">
{databases.map((db, index) => (
<div key={index} className={`database-item ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
<div className={`status-icon ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
{db.isDecrypted ? <Check size={16} /> : <Circle size={16} />}
</div>
<div className="db-info">
<div className="db-name">{db.fileName}</div>
<div className="db-meta">
<span>{db.wxid}</span>
<span></span>
<span>{formatFileSize(db.fileSize)}</span>
<div className="database-list">
{databases.map((db, index) => (
<div key={index} className={`database-item ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
<div className={`status-icon ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
{db.isDecrypted ? <Check size={16} /> : <Circle size={16} />}
</div>
<div className="db-info">
<div className="db-name">{db.fileName}</div>
<div className="db-meta">
<span>{db.wxid}</span>
<span></span>
<span>{formatFileSize(db.fileSize)}</span>
</div>
</div>
<div className={`db-status ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
{db.isDecrypted ? (db.needsUpdate ? '需更新' : '已解密') : '待解密'}
</div>
</div>
<div className={`db-status ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
{db.isDecrypted ? (db.needsUpdate ? '需更新' : '已解密') : '待解密'}
</div>
</div>
))}
))}
{!isLoading && databases.length === 0 && (
<div className="empty-state">
<Database size={48} strokeWidth={1} />
<p></p>
<p className="hint"></p>
{!isLoading && databases.length === 0 && (
<div className="empty-state">
<Database size={48} strokeWidth={1} />
<p></p>
<p className="hint"></p>
</div>
)}
</div>
</section>
)}
{activeTab === 'images' && (
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc">
{isLoading ? '正在扫描...' : `${images.length} 张图片,${decryptedImagesCount} 张已解密`}
</p>
</div>
)}
</div>
</section>
<div className="section-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
</button>
</div>
</div>
<div className="media-grid">
{images.slice(0, 100).map((image, index) => (
<div
key={index}
className={`media-item ${image.isDecrypted ? 'decrypted' : 'pending'}`}
onClick={() => handleImageClick(image)}
>
{image.isDecrypted && image.decryptedPath ? (
<img
src={image.decryptedPath.startsWith('data:') ? image.decryptedPath : `file:///${image.decryptedPath.replace(/\\/g, '/')}`}
alt={image.fileName}
onError={(e) => {
console.error('[DataManagement] 图片加载失败:', image.decryptedPath)
e.currentTarget.style.display = 'none'
}}
/>
) : (
<div className="media-placeholder">
<ImageIcon size={32} />
<span></span>
</div>
)}
<div className="media-info">
<span className="media-name">{image.fileName}</span>
<span className="media-size">{formatFileSize(image.fileSize)}</span>
</div>
</div>
))}
{!isLoading && images.length === 0 && (
<div className="empty-state">
<ImageIcon size={48} strokeWidth={1} />
<p></p>
<p className="hint"></p>
</div>
)}
{images.length > 100 && (
<div className="more-hint">
100 {images.length}
</div>
)}
</div>
</section>
)}
{activeTab === 'emojis' && (
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc">
{isLoading ? '正在扫描...' : `${emojis.length} 个表情包,${decryptedEmojisCount} 个已解密`}
</p>
</div>
<div className="section-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
</button>
</div>
</div>
<div className="media-grid emoji-grid">
{emojis.slice(0, 100).map((emoji, index) => (
<div
key={index}
className={`media-item emoji-item ${emoji.isDecrypted ? 'decrypted' : 'pending'}`}
onClick={() => handleImageClick(emoji)}
>
{emoji.isDecrypted && emoji.decryptedPath ? (
<img
src={emoji.decryptedPath.startsWith('data:') ? emoji.decryptedPath : `file:///${emoji.decryptedPath.replace(/\\/g, '/')}`}
alt={emoji.fileName}
onError={(e) => {
console.error('[DataManagement] 表情包加载失败:', emoji.decryptedPath)
e.currentTarget.style.display = 'none'
}}
/>
) : (
<div className="media-placeholder">
<Smile size={32} />
<span></span>
</div>
)}
<div className="media-info">
<span className="media-name">{emoji.fileName}</span>
</div>
</div>
))}
{!isLoading && emojis.length === 0 && (
<div className="empty-state">
<Smile size={48} strokeWidth={1} />
<p></p>
<p className="hint"></p>
</div>
)}
{emojis.length > 100 && (
<div className="more-hint">
100 {emojis.length}
</div>
)}
</div>
</section>
)}
</div>
</>
)
+79 -30
View File
@@ -1393,6 +1393,15 @@
.log-content {
margin-bottom: 24px;
&.log-content-selectable {
.log-content-text,
.log-content-text pre {
user-select: text;
-webkit-user-select: text;
cursor: text;
}
}
.log-content-header {
display: flex;
justify-content: space-between;
@@ -2267,48 +2276,88 @@
}
}
// 缓存统计样式
.cache-stats {
margin-bottom: 20px;
// 缓存管理 - 卡片布局
.cache-management {
.cache-loading {
margin: 0 0 1rem 0;
color: var(--text-secondary);
font-size: 14px;
}
.cache-info {
.cache-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
.cache-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-color);
gap: 0.75rem;
transition: border-color 0.2s, box-shadow 0.2s;
.cache-item {
&:hover {
border-color: var(--primary-light, rgba(var(--primary-rgb), 0.25));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.cache-card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
gap: 0.5rem;
}
.label {
color: var(--text-secondary);
.cache-card-icon {
color: var(--text-tertiary);
flex-shrink: 0;
}
.cache-card-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.cache-card-size {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.cache-card-btn {
margin-top: auto;
width: 100%;
justify-content: center;
padding: 0.4rem 0.75rem;
font-size: 12px;
}
&.cache-card-total {
.cache-card-size {
color: var(--primary);
}
.value {
color: var(--text-primary);
font-weight: 500;
font-variant-numeric: tabular-nums;
.cache-card-btn {
margin-top: auto;
}
}
&.total {
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid var(--border-color);
font-weight: 600;
.cache-card-desc {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.3;
}
.label {
color: var(--text-primary);
}
.value {
color: var(--primary);
}
&.cache-card-config {
.cache-card-desc {
min-height: 1.5em;
}
}
}
+82 -41
View File
@@ -10,7 +10,7 @@ import {
Eye, EyeOff, Key, FolderSearch, FolderOpen, Search,
RotateCcw, Trash2, Save, Plug, X, Check, Sun, Moon,
Palette, Database, ImageIcon, Download, HardDrive, Info, RefreshCw, Shield, Clock, CheckCircle, AlertCircle, FileText, Mic,
Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus
Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus, Smile
} from 'lucide-react'
import { useAuthStore } from '../stores/authStore'
import './SettingsPage.scss'
@@ -102,7 +102,7 @@ function SettingsPage() {
const [showXorKey, setShowXorKey] = useState(false)
const [showAesKey, setShowAesKey] = useState(false)
const [showClearDialog, setShowClearDialog] = useState<{
type: 'images' | 'all' | 'config'
type: 'images' | 'emojis' | 'databases' | 'all' | 'config'
title: string
message: string
} | null>(null)
@@ -401,6 +401,22 @@ function SettingsPage() {
})
}
const handleClearEmojis = () => {
setShowClearDialog({
type: 'emojis',
title: '清除表情包',
message: '此操作将删除所有解密后的表情包缓存文件,清除后无法恢复。确定要继续吗?'
})
}
const handleClearDatabases = () => {
setShowClearDialog({
type: 'databases',
title: '清除数据库',
message: '此操作将删除所有解密后的数据库缓存文件,清除后需要重新解密数据库才能使用聊天记录。确定要继续吗?'
})
}
const handleClearConfig = () => {
setShowClearDialog({
type: 'config',
@@ -418,6 +434,12 @@ function SettingsPage() {
case 'images':
result = await window.electronAPI.cache.clearImages()
break
case 'emojis':
result = await window.electronAPI.cache.clearEmojis()
break
case 'databases':
result = await window.electronAPI.cache.clearDatabases()
break
case 'all':
result = await window.electronAPI.cache.clearAll()
break
@@ -429,10 +451,8 @@ function SettingsPage() {
if (result.success) {
showMessage(`${showClearDialog.title}成功`, true)
if (showClearDialog.type === 'config') {
// 清除配置后重新加载
await loadConfig()
} else {
// 清除缓存后重新加载缓存大小
await loadCacheSize()
}
} else {
@@ -2219,45 +2239,66 @@ function SettingsPage() {
<div className="divider" style={{ margin: '2rem 0', borderBottom: '1px solid var(--border-color)', opacity: 0.1 }} />
{/* 缓存管理 */}
<section className="settings-section">
<section className="settings-section cache-management">
<h3 className="section-title"></h3>
<div className="cache-stats">
{isLoadingCacheSize ? (
<p>...</p>
) : cacheSize ? (
<div className="cache-info">
<div className="cache-item">
<span className="label">:</span>
<span className="value">{formatFileSize(cacheSize.images)}</span>
</div>
<div className="cache-item">
<span className="label">:</span>
<span className="value">{formatFileSize(cacheSize.emojis)}</span>
</div>
<div className="cache-item">
<span className="label">:</span>
<span className="value">{formatFileSize(cacheSize.databases)}</span>
</div>
<div className="cache-item total">
<span className="label">:</span>
<span className="value">{formatFileSize(cacheSize.total)}</span>
{isLoadingCacheSize ? (
<p className="cache-loading">...</p>
) : cacheSize ? (
<div className="cache-cards">
<div className="cache-card">
<div className="cache-card-header">
<ImageIcon size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-size">{formatFileSize(cacheSize.images)}</div>
<button type="button" className="btn btn-secondary cache-card-btn" onClick={handleClearImages}>
<Trash2 size={14} />
</button>
</div>
) : (
<p></p>
)}
</div>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleClearImages}>
<Trash2 size={16} />
</button>
<button className="btn btn-secondary" onClick={handleClearConfig}>
<Trash2 size={16} />
</button>
<button className="btn btn-danger" onClick={handleClearAllCache}>
<Trash2 size={16} />
</button>
</div>
<div className="cache-card">
<div className="cache-card-header">
<Smile size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-size">{formatFileSize(cacheSize.emojis)}</div>
<button type="button" className="btn btn-secondary cache-card-btn" onClick={handleClearEmojis}>
<Trash2 size={14} />
</button>
</div>
<div className="cache-card">
<div className="cache-card-header">
<Database size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-size">{formatFileSize(cacheSize.databases)}</div>
<button type="button" className="btn btn-secondary cache-card-btn" onClick={handleClearDatabases}>
<Trash2 size={14} />
</button>
</div>
<div className="cache-card cache-card-config">
<div className="cache-card-header">
<Key size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-desc"></div>
<button type="button" className="btn btn-secondary cache-card-btn" onClick={handleClearConfig}>
<Trash2 size={14} />
</button>
</div>
<div className="cache-card cache-card-total">
<div className="cache-card-header">
<Layers size={20} className="cache-card-icon" />
<span className="cache-card-label"></span>
</div>
<div className="cache-card-size">{formatFileSize(cacheSize.total)}</div>
<button type="button" className="btn btn-danger cache-card-btn" onClick={handleClearAllCache}>
<Trash2 size={14} />
</button>
</div>
</div>
) : (
<p></p>
)}
</section>
<div className="divider" style={{ margin: '2rem 0', borderBottom: '1px solid var(--border-color)', opacity: 0.1 }} />
@@ -2323,7 +2364,7 @@ function SettingsPage() {
</div>
{selectedLogFile && (
<div className="log-content" style={{ marginTop: '1rem' }}>
<div className="log-content log-content-selectable" style={{ marginTop: '1rem' }}>
<div className="log-content-text" style={{ maxHeight: '300px', overflowY: 'auto' }}>
<pre>{logContent}</pre>
</div>
+3
View File
@@ -207,6 +207,7 @@ export interface ElectronAPI {
}>
getContact: (username: string) => Promise<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }>
getMyUserInfo: () => Promise<{
success: boolean
@@ -439,6 +440,8 @@ export interface ElectronAPI {
}
cache: {
clearImages: () => Promise<{ success: boolean; error?: string }>
clearEmojis: () => Promise<{ success: boolean; error?: string }>
clearDatabases: () => Promise<{ success: boolean; error?: string }>
clearAll: () => Promise<{ success: boolean; error?: string }>
clearConfig: () => Promise<{ success: boolean; error?: string }>
getCacheSize: () => Promise<{
+3
View File
@@ -62,6 +62,9 @@ export interface Message {
fileExt?: string // 文件扩展名
fileMd5?: string // 文件 MD5
chatRecordList?: ChatRecordItem[] // 聊天记录列表 (Type 19)
// 转账消息
transferPayerUsername?: string // 转账付款方 wxid
transferReceiverUsername?: string // 转账收款方 wxid
}
export interface ChatRecordItem {