mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-26 22:40:24 +08:00
修复了一些已知问题
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Vendored
+3
@@ -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<{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user