fix: 完善聊天解析与导出体验

- 修复引用消息名称和图片布局
- 明确单会话图片测试日志范围
- 支持导出文件附件
- 完善图片批测、会话刷新和安全退出
This commit is contained in:
Wxw-Gu
2026-08-04 12:03:07 +08:00
parent d76727875d
commit c70e49bf16
41 changed files with 2320 additions and 221 deletions
+29 -76
View File
@@ -30,6 +30,7 @@ import {
mergeMessagePages,
sortMessagesChronologically
} from './utils/message-pages'
import { enrichQuotedMessages } from './utils/quoted-messages'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -70,67 +71,6 @@ const INITIAL_MESSAGE_COUNT = 20
const MESSAGE_PAGE_SIZE = 100
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
const EXPORT_PREVIEW_LIMIT = 20
const normalizeQuotedText = (value: string | undefined): string =>
String(value || '')
.replace(/\s+/g, ' ')
.trim()
const isInternalReferenceSender = (value: string | undefined): boolean => {
const sender = String(value || '').trim()
return (
!sender ||
sender.endsWith('@chatroom') ||
sender.startsWith('wxid_') ||
/^[a-z0-9_@.-]{12,}$/i.test(sender)
)
}
const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => {
const imageDatNameByMd5 = new Map<string, string>()
const messagesByContent = new Map<string, Message[]>()
for (const message of referenceMessages) {
if (
message.contentData?.type === 'image' &&
message.contentData.md5 &&
message.contentData.datName
) {
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
}
const content = normalizeQuotedText(message.content)
if (!content) continue
const candidates = messagesByContent.get(content) || []
candidates.push(message)
messagesByContent.set(content, candidates)
}
return messages.map((message) => {
if (message.contentData?.type !== 'quote') return message
const quote = message.contentData
let quotedImageDatName = quote.quotedImageDatName
if (!quotedImageDatName && quote.quotedImageMd5) {
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
}
let quotedSender = quote.quotedSender
if (isInternalReferenceSender(quotedSender)) {
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
const source = candidates
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
}
if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) {
return message
}
return {
...message,
contentData: { ...quote, quotedSender, quotedImageDatName }
}
})
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
@@ -140,6 +80,16 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
return true
}
const filterContactList = (list: Contact[], keyword: string): Contact[] => {
const lower = keyword.trim().toLowerCase()
if (!lower) return list
return list.filter((contact) =>
[contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname].some(
(value) => value?.toLowerCase().includes(lower)
)
)
}
type GroupSnapshot = {
roomId: string
memberCount: number
@@ -501,11 +451,12 @@ function App(): React.ReactElement {
const loadContacts = async (options?: {
waitForAvatars?: boolean
onProgress?: (message: string, percent?: number) => void
filterKeyword?: string
}): Promise<void> => {
options?.onProgress?.('正在加载联系人...', 35)
const list = await window.api.getContacts()
setContacts(list)
setFilteredContacts(list)
setFilteredContacts(filterContactList(list, options?.filterKeyword || ''))
const runId = ++contactAvatarHydrationRunRef.current
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
if (options?.waitForAvatars) {
@@ -886,11 +837,12 @@ function App(): React.ReactElement {
const applyGroupMemberMeta = React.useCallback(
(contact: Contact | null, baseMessages: Message[]): Message[] => {
if (!contact || contact.type !== 'group') return baseMessages
const enrichedMessages = enrichQuotedMessages(baseMessages, [
...messageHistoryRef.current,
...baseMessages
])
const memberMap = groupMemberMetaRef.current[contact.md5]
const enrichedMessages = enrichQuotedMessages(
baseMessages,
[...messageHistoryRef.current, ...baseMessages],
(senderId) => memberMap?.get(senderId)?.nickname
)
if (!memberMap || memberMap.size === 0) return enrichedMessages
return enrichedMessages.map((message) => {
@@ -1347,16 +1299,16 @@ function App(): React.ReactElement {
])
const handleSearchContacts = (keyword: string): void => {
if (!keyword) {
setFilteredContacts(contacts)
} else {
const lower = keyword.toLowerCase()
const filtered = contacts.filter(
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) ||
c.m_nsUsrName.toLowerCase().includes(lower)
)
setFilteredContacts(filtered)
setFilteredContacts(filterContactList(contacts, keyword))
}
const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
try {
await loadContacts({ waitForAvatars: false, filterKeyword })
setReportNotice('会话列表已刷新')
} catch (error) {
console.warn('[Contacts] refresh failed:', error)
setReportNotice('会话列表刷新失败,请稍后重试')
}
}
@@ -1588,6 +1540,7 @@ function App(): React.ReactElement {
dbReady={isDatabaseConnected}
dbConnecting={isDatabaseConnecting}
onOpenSettings={openSettings}
onRefresh={handleRefreshContacts}
/>
<div className="resizer" onMouseDown={startResizing} />
<ChatWindow
@@ -359,12 +359,13 @@ function QuoteBubble({
const quotedText = data.quotedContent || data.content || '[引用消息]'
const replyText = data.content || data.title || ''
const quotedSender = data.quotedSender || data.sender || ''
const hasQuotedImage = Boolean(data.quotedImageMd5 || data.quotedImageDatName)
return (
<div className="quote-message">
<div className="quoted-message">
<div className={`quoted-message${hasQuotedImage ? ' quoted-message-image' : ''}`}>
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
{data.quotedImageMd5 || data.quotedImageDatName ? (
{hasQuotedImage ? (
<ImageBubble
imageMd5={data.quotedImageMd5}
imageDatName={data.quotedImageDatName}
@@ -23,6 +23,7 @@ export interface ConversationSidebarProps {
dbReady: boolean
dbConnecting?: boolean
onOpenSettings: () => void
onRefresh: (filterKeyword: string) => Promise<void>
}
type SectionName = 'groups' | 'folded' | 'contacts'
@@ -39,9 +40,11 @@ export function ConversationSidebar({
selfInfo,
dbReady,
dbConnecting = false,
onOpenSettings
onOpenSettings,
onRefresh
}: ConversationSidebarProps): React.ReactElement {
const [searchTerm, setSearchTerm] = useState('')
const [isRefreshing, setIsRefreshing] = useState(false)
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
groups: true,
folded: false,
@@ -119,12 +122,24 @@ export function ConversationSidebar({
onSearch(term)
}
const handleRefresh = async (): Promise<void> => {
if (isRefreshing) return
setIsRefreshing(true)
try {
await onRefresh(searchTerm)
} finally {
setIsRefreshing(false)
}
}
return (
<aside className="conversation-sidebar" style={{ width }}>
<ConversationSidebarHeader
totalCount={contacts.length}
searchValue={searchTerm}
onSearchChange={handleSearchChange}
refreshing={isRefreshing}
onRefresh={() => void handleRefresh()}
/>
<div ref={listRef} className="conversation-list" aria-label="会话列表">
<div
@@ -5,18 +5,36 @@ interface ConversationSidebarHeaderProps {
totalCount: number
searchValue: string
onSearchChange: (value: string) => void
refreshing: boolean
onRefresh: () => void
}
export function ConversationSidebarHeader({
totalCount,
searchValue,
onSearchChange
onSearchChange,
refreshing,
onRefresh
}: ConversationSidebarHeaderProps): React.ReactElement {
return (
<div className="conversation-sidebar-header">
<div className="conversation-sidebar-title-row">
<h2></h2>
<span>{totalCount} </span>
<div className="conversation-sidebar-meta">
<span>{totalCount} </span>
<button
type="button"
className={`conversation-refresh-button ${refreshing ? 'is-refreshing' : ''}`}
aria-label={refreshing ? '正在刷新会话列表' : '刷新会话列表'}
title={refreshing ? '正在刷新…' : '刷新会话列表'}
disabled={refreshing}
onClick={onRefresh}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M16.1 6.2A7 7 0 1 0 17 12h-2a5 5 0 1 1-.7-3.4L12 11h6V5l-1.9 1.2Z" />
</svg>
</button>
</div>
</div>
<ConversationSearch value={searchValue} onChange={onSearchChange} />
</div>
@@ -66,8 +66,10 @@ export function ExportWorkspace({
const taskCount = exportTasks.filter((task) => task.status === 'running').length
const activeName = displayName(activeContact)
const preview = previewMessages.slice(-20)
const previewMediaCount = preview.filter((message) =>
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '')
const previewMediaCount = preview.filter(
(message) =>
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') ||
(message.contentData?.type === 'share' && message.contentData.typeVal === '6')
).length
const previewBytes = preview.reduce(
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
@@ -439,7 +441,7 @@ export function ExportWorkspace({
<section className="export-section">
<h3></h3>
<label className="export-media-master">
<span></span>
<span></span>
<input
type="checkbox"
checked={includeMedia}
@@ -486,6 +488,7 @@ export function ExportWorkspace({
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<p className="export-helper-text"></p>
<label className="export-media-master">
@@ -7,6 +7,7 @@ export const messageKinds = [
['video', '视频'],
['voice', '语音'],
['sticker', '表情包'],
['file', '文件'],
['share', '链接与分享'],
['location', '位置'],
['system', '系统消息']
@@ -1,6 +1,9 @@
import type { ImageDecryptionState } from './types'
import { useEffect, useMemo, useState } from 'react'
import type { Contact } from '../../../../../shared/types'
import type { ImageBatchTestItemStatus, ImageBatchTestState, ImageDecryptionState } from './types'
type StepState = 'pending' | 'ok' | 'fail' | 'skipped'
type ContactFilter = 'all' | 'group' | 'user'
function stepClass(step: StepState): string {
switch (step) {
@@ -28,22 +31,16 @@ function stepIcon(step: StepState): string {
}
}
function pickSteps(result: {
fileFound: boolean
decrypted: boolean
readable: boolean
success: boolean
}): { find: StepState; decrypt: StepState; read: StepState } {
function pickSteps(result: { fileFound: boolean; decrypted: boolean; readable: boolean }): {
find: StepState
decrypt: StepState
read: StepState
} {
// 三步严格联动:找到失败 → 解密/读取 skipped;解密失败 → 读取 skipped
// 解密成功但不可读 → 读取 fail。
if (!result.fileFound) {
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
}
if (!result.success && !result.decrypted && !result.readable) {
// 后端把 fileFound=false 的情况也用 success:false 表达;
// 此时第一步直接 fail,后两步 skip。
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
}
if (!result.decrypted) {
return { find: 'ok', decrypt: 'fail', read: 'skipped' }
}
@@ -53,50 +50,228 @@ function pickSteps(result: {
return { find: 'ok', decrypt: 'ok', read: 'ok' }
}
function contactName(contact: Contact): string {
return contact.remark || contact.m_nsNickName || contact.wechatNickname || contact.m_nsUsrName
}
function formatElapsed(elapsedMs: number): string {
if (elapsedMs < 1000) return `${elapsedMs} ms`
if (elapsedMs < 60_000) return `${(elapsedMs / 1000).toFixed(elapsedMs < 10_000 ? 1 : 0)}`
const minutes = Math.floor(elapsedMs / 60_000)
const seconds = Math.floor((elapsedMs % 60_000) / 1000)
return `${minutes}${seconds}`
}
function batchStatusText(status: ImageBatchTestItemStatus): string {
switch (status) {
case 'testing':
return '测试中'
case 'success':
return '成功'
case 'failed':
return '失败'
case 'no-image':
return '无图片'
case 'stopped':
return '未测试'
default:
return '等待中'
}
}
export function ImageTestSection({
state,
batchTest,
disabled,
canSave,
onSelect,
onTest,
onBatchTest,
onStopBatchTest,
onCopyLog,
onSave
}: {
state: ImageDecryptionState
batchTest: ImageBatchTestState
disabled: boolean
canSave: boolean
onSelect: (value: string) => void
onTest: () => void
onBatchTest: (contacts: Contact[]) => void
onStopBatchTest: () => void
onCopyLog: () => void
onSave: () => void
}): React.ReactElement {
const [contactFilter, setContactFilter] = useState<ContactFilter>('all')
const [searchValue, setSearchValue] = useState('')
const [liveClock, setLiveClock] = useState({ startedAt: 0, elapsedMs: 0 })
const result = state.testResult
const steps = result
? pickSteps({
fileFound: result.fileFound,
decrypted: result.decrypted,
readable: result.readable,
success: result.success
readable: result.readable
})
: null
const contactCounts = useMemo(
() => ({
all: state.contacts.length,
group: state.contacts.filter((contact) => contact.type === 'group').length,
user: state.contacts.filter((contact) => contact.type === 'user').length
}),
[state.contacts]
)
const filteredContacts = useMemo(() => {
const keyword = searchValue.trim().toLowerCase()
return state.contacts.filter((contact) => {
if (contactFilter !== 'all' && contact.type !== contactFilter) return false
if (!keyword) return true
return [
contactName(contact),
contact.m_nsNickName,
contact.m_nsUsrName,
contact.wechatNickname,
contact.remark
].some((value) => value?.toLowerCase().includes(keyword))
})
}, [contactFilter, searchValue, state.contacts])
const filteredGroups = filteredContacts.filter((contact) => contact.type === 'group')
const filteredUsers = filteredContacts.filter((contact) => contact.type === 'user')
const batchCounts = useMemo(() => {
const completed = batchTest.items.filter((item) =>
['success', 'failed', 'no-image'].includes(item.status)
).length
return {
completed,
success: batchTest.items.filter((item) => item.status === 'success').length,
failed: batchTest.items.filter((item) => item.status === 'failed').length,
noImage: batchTest.items.filter((item) => item.status === 'no-image').length,
stopped: batchTest.items.filter((item) => item.status === 'stopped').length
}
}, [batchTest.items])
const batchProgress = batchTest.items.length
? Math.round((batchCounts.completed / batchTest.items.length) * 100)
: 0
useEffect(() => {
if (!batchTest.running || !batchTest.startedAt) return
const timer = window.setInterval(() => {
setLiveClock({
startedAt: batchTest.startedAt!,
elapsedMs: Date.now() - batchTest.startedAt!
})
}, 250)
return () => window.clearInterval(timer)
}, [batchTest.running, batchTest.startedAt])
const displayedElapsed =
batchTest.running && liveClock.startedAt === batchTest.startedAt
? Math.max(batchTest.elapsedMs, liveClock.elapsedMs)
: batchTest.elapsedMs
const handleBatchTest = (): void => {
if (filteredContacts.length === 0 || batchTest.running) return
const confirmed = window.confirm(
`即将测试 ${filteredContacts.length} 个会话。每个会话最多检查最近 300 条消息中的一张图片,会话较多时可能耗时数分钟。是否继续?`
)
if (confirmed) onBatchTest(filteredContacts)
}
const batchButtonLabel =
contactFilter === 'all' && !searchValue.trim()
? `全部测试(${filteredContacts.length}`
: `测试筛选结果(${filteredContacts.length}`
return (
<section className="settings-card image-test-section">
<div>
<strong></strong>
<p></p>
<div className="image-test-heading">
<div>
<strong></strong>
<p></p>
</div>
<div className="image-test-copy-area">
<span
className="image-test-log-scope"
title="此日志只包含当前所选单个会话的图片解析结果,不包含批量测试结果"
>
<span className="image-test-log-scope-icon" aria-hidden="true">
!
</span>
</span>
<button
className="database-key-secondary image-test-copy"
disabled={!result?.diagnosticLog}
onClick={onCopyLog}
>
</button>
</div>
</div>
<label htmlFor="image-test-chat"></label>
<div className="image-test-filter-bar" aria-label="会话类型筛选">
{(
[
['all', '全部'],
['group', '群聊'],
['user', '联系人']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={contactFilter === value ? 'active' : ''}
aria-pressed={contactFilter === value}
disabled={disabled}
onClick={() => setContactFilter(value)}
>
{label} {contactCounts[value]}
</button>
))}
</div>
<label htmlFor="image-test-search"></label>
<input
id="image-test-search"
className="image-test-search"
type="search"
value={searchValue}
disabled={disabled}
placeholder="搜索群聊、联系人或 wxid"
onChange={(event) => setSearchValue(event.target.value)}
/>
<label htmlFor="image-test-chat"></label>
<select
id="image-test-chat"
value={state.selectedUserMd5}
disabled={disabled}
onChange={(event) => onSelect(event.target.value)}
>
<option value=""></option>
{state.contacts.map((contact) => (
<option key={contact.md5} value={contact.md5}>
{contact.m_nsNickName || contact.m_nsUsrName}
</option>
))}
<option value=""></option>
{filteredGroups.length > 0 && (
<optgroup label={`群聊(${filteredGroups.length}`}>
{filteredGroups.map((contact) => (
<option key={contact.md5} value={contact.md5}>
{contactName(contact)}
</option>
))}
</optgroup>
)}
{filteredUsers.length > 0 && (
<optgroup label={`联系人(${filteredUsers.length}`}>
{filteredUsers.map((contact) => (
<option key={contact.md5} value={contact.md5}>
{contactName(contact)}
</option>
))}
</optgroup>
)}
</select>
{filteredContacts.length === 0 && <p className="image-test-empty"></p>}
<div className="image-test-actions">
<button
className="database-key-primary"
@@ -108,7 +283,28 @@ export function ImageTestSection({
<button className="database-key-secondary" disabled={!canSave} onClick={onSave}>
</button>
{batchTest.running ? (
<button
className="database-key-secondary image-batch-stop"
disabled={batchTest.stopRequested}
onClick={onStopBatchTest}
>
{batchTest.stopRequested ? '正在停止…' : '停止测试'}
</button>
) : (
<button
className="database-key-secondary"
disabled={disabled || filteredContacts.length === 0}
onClick={handleBatchTest}
>
{batchButtonLabel}
</button>
)}
</div>
<p className="image-batch-warning">
</p>
{steps ? (
<ol className="image-step-list">
<li className={stepClass(steps.find)}>
@@ -130,6 +326,49 @@ export function ImageTestSection({
{result && !result.success && result.error ? (
<p className="image-inline-error">{result.error}</p>
) : null}
{batchTest.items.length > 0 && (
<div className="image-batch-results" aria-live="polite">
<div className="image-batch-summary">
<div>
<strong>{batchTest.running ? '正在批量测试' : '批量测试结果'}</strong>
<span>
{batchCounts.completed}/{batchTest.items.length} · {' '}
{formatElapsed(displayedElapsed)}
</span>
</div>
<span>{batchProgress}%</span>
</div>
<div className="image-batch-progress" aria-label={`批量测试进度 ${batchProgress}%`}>
<span style={{ width: `${batchProgress}%` }} />
</div>
<div className="image-batch-counts">
<span className="success"> {batchCounts.success}</span>
<span className="failed"> {batchCounts.failed}</span>
<span className="no-image"> {batchCounts.noImage}</span>
{batchCounts.stopped > 0 && <span> {batchCounts.stopped}</span>}
</div>
<ul className="image-batch-list">
{batchTest.items.map((item) => (
<li key={item.contact.md5} className={`image-batch-item ${item.status}`}>
<div className="image-batch-item-main">
<span className="image-batch-contact-name" title={contactName(item.contact)}>
{contactName(item.contact)}
</span>
<span className="image-batch-contact-type">
{item.contact.type === 'group' ? '群聊' : '联系人'}
</span>
<span className="image-batch-status">{batchStatusText(item.status)}</span>
<span className="image-batch-time">
{typeof item.elapsedMs === 'number' ? formatElapsed(item.elapsedMs) : '—'}
</span>
</div>
{item.error && <p title={item.error}>{item.error}</p>}
</li>
))}
</ul>
</div>
)}
</section>
)
}
@@ -45,6 +45,30 @@ export interface ImageDecryptionState {
dirty: boolean
}
export type ImageBatchTestItemStatus =
| 'pending'
| 'testing'
| 'success'
| 'failed'
| 'no-image'
| 'stopped'
export interface ImageBatchTestItem {
contact: Contact
status: ImageBatchTestItemStatus
elapsedMs?: number
result?: ImageDecryptionTestResult
error?: string
}
export interface ImageBatchTestState {
running: boolean
stopRequested: boolean
startedAt?: number
elapsedMs: number
items: ImageBatchTestItem[]
}
export type ImageDecryptionAction =
| {
type: 'LOADED'
@@ -77,12 +101,16 @@ export type ImageDecryptionAction =
export interface ImageDecryptionController {
state: ImageDecryptionState
batchTest: ImageBatchTestState
pageStatus: 'configured' | 'unconfigured' | 'partial'
busy: boolean
canSave: boolean
edit: (field: 'xorKey' | 'aesKey', value: string) => void
selectChat: (userMd5: string) => void
test: () => Promise<void>
testMany: (contacts: Contact[]) => Promise<void>
stopBatchTest: () => void
copyDiagnostics: () => Promise<void>
save: () => Promise<void>
autoDetect: () => Promise<void>
clear: () => Promise<void>
@@ -1,9 +1,28 @@
import { useCallback, useEffect, useMemo, useReducer } from 'react'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import type { Contact } from '../../../../../shared/types'
import type { SettingsSelfInfo } from '../model/types'
import { imageDecryptionReducer, initialImageDecryptionState } from './imageDecryptionReducer'
import type { ImageDecryptionController } from './types'
import type { ImageBatchTestItem, ImageBatchTestState, ImageDecryptionController } from './types'
import { normalizeAutoXorKey, sanitizeImageError } from './utils'
const EMPTY_BATCH_TEST: ImageBatchTestState = {
running: false,
stopRequested: false,
elapsedMs: 0,
items: []
}
const BATCH_CONCURRENCY = 3
function uniqueContacts(contacts: Contact[]): Contact[] {
const seen = new Set<string>()
return contacts.filter((contact) => {
if (!contact.md5 || seen.has(contact.md5)) return false
seen.add(contact.md5)
return true
})
}
export function useImageDecryptionController({
selfInfo,
onNotice
@@ -12,6 +31,9 @@ export function useImageDecryptionController({
onNotice: (message: string) => void
}): ImageDecryptionController {
const [state, dispatch] = useReducer(imageDecryptionReducer, initialImageDecryptionState)
const [batchTest, setBatchTest] = useState<ImageBatchTestState>(EMPTY_BATCH_TEST)
const batchRunRef = useRef(0)
const batchStopRef = useRef(false)
const refresh = useCallback(async (): Promise<void> => {
try {
@@ -55,6 +77,116 @@ export function useImageDecryptionController({
})
}, [state.aesKey, state.resourceRoot, state.selectedUserMd5, state.xorKey])
const testMany = useCallback(
async (contacts: Contact[]): Promise<void> => {
const targets = uniqueContacts(contacts)
if (targets.length === 0 || batchTest.running) return
const runId = ++batchRunRef.current
const startedAt = Date.now()
let nextIndex = 0
batchStopRef.current = false
setBatchTest({
running: true,
stopRequested: false,
startedAt,
elapsedMs: 0,
items: targets.map((contact) => ({ contact, status: 'pending' }))
})
const updateItem = (
contactMd5: string,
update: (item: ImageBatchTestItem) => ImageBatchTestItem
): void => {
if (batchRunRef.current !== runId) return
setBatchTest((current) => ({
...current,
elapsedMs: Date.now() - startedAt,
items: current.items.map((item) =>
item.contact.md5 === contactMd5 ? update(item) : item
)
}))
}
const worker = async (): Promise<void> => {
while (!batchStopRef.current) {
const targetIndex = nextIndex
nextIndex += 1
const contact = targets[targetIndex]
if (!contact) return
updateItem(contact.md5, (item) => ({ ...item, status: 'testing' }))
const itemStartedAt = Date.now()
try {
const rawResult = await window.api.testImageDecryption({
userMd5: contact.md5,
resourceRoot: state.resourceRoot,
xorKey: state.xorKey,
aesKey: state.aesKey
})
const result = rawResult.success
? rawResult
: { ...rawResult, error: sanitizeImageError(rawResult.error) }
const status = result.success
? 'success'
: result.code === 'NO_IMAGE_MESSAGE'
? 'no-image'
: 'failed'
updateItem(contact.md5, (item) => ({
...item,
status,
elapsedMs: Math.max(1, Date.now() - itemStartedAt),
result,
error: result.error
}))
} catch (error) {
updateItem(contact.md5, (item) => ({
...item,
status: 'failed',
elapsedMs: Math.max(1, Date.now() - itemStartedAt),
error: sanitizeImageError(error instanceof Error ? error.message : String(error))
}))
}
}
}
await Promise.all(
Array.from({ length: Math.min(BATCH_CONCURRENCY, targets.length) }, () => worker())
)
if (batchRunRef.current !== runId) return
const stopped = batchStopRef.current
setBatchTest((current) => ({
...current,
running: false,
stopRequested: false,
elapsedMs: Date.now() - startedAt,
items: stopped
? current.items.map((item) =>
item.status === 'pending' ? { ...item, status: 'stopped' } : item
)
: current.items
}))
onNotice(stopped ? '批量图片测试已停止' : `批量图片测试完成,共 ${targets.length} 个会话`)
},
[batchTest.running, onNotice, state.aesKey, state.resourceRoot, state.xorKey]
)
const stopBatchTest = useCallback((): void => {
if (!batchTest.running || batchStopRef.current) return
batchStopRef.current = true
setBatchTest((current) => ({ ...current, stopRequested: true }))
}, [batchTest.running])
const copyDiagnostics = useCallback(async (): Promise<void> => {
const diagnosticLog = state.testResult?.diagnosticLog
if (!diagnosticLog) {
onNotice('请先完成一次图片解析测试')
return
}
const result = await window.api.copyText(diagnosticLog)
onNotice(result.success ? '图片解析测试日志已复制' : result.error || '复制测试日志失败')
}, [onNotice, state.testResult?.diagnosticLog])
const save = useCallback(async (): Promise<void> => {
if (!state.testResult?.success && state.autoPhase !== 'success') return
if (state.autoPhase === 'success') dispatch({ type: 'AUTO_SAVE_START' })
@@ -125,7 +257,7 @@ export function useImageDecryptionController({
onNotice('图片密钥已清除,微信原始数据未受影响')
}, [onNotice])
const busy = ['checking', 'testing', 'clearing'].includes(state.phase)
const busy = ['checking', 'testing', 'clearing'].includes(state.phase) || batchTest.running
const canSave = Boolean(
((state.testResult?.success && state.dirty) || state.autoPhase === 'success') &&
state.status?.encryptionAvailable
@@ -139,12 +271,16 @@ export function useImageDecryptionController({
return {
state,
batchTest,
pageStatus,
busy,
canSave,
edit,
selectChat,
test,
testMany,
stopBatchTest,
copyDiagnostics,
save,
autoDetect,
clear,
@@ -17,6 +17,16 @@ export function sanitizeImageError(error?: string): string {
if (value.includes('unsupported') || value.includes('dat version')) {
return '仅支持 WeChat 4.0 图片协议,V3 及以下无法解析'
}
if (
value.includes('wxgf') ||
value.includes('ffmpeg') ||
value.includes('hevc') ||
value.includes('不完整') ||
value.includes('格式异常') ||
value.includes('无法识别')
) {
return error || '无法解析媒体文件'
}
if (value.includes('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配'
if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在'
if (value.includes('账号')) return '当前账号不匹配'
@@ -91,10 +91,14 @@ export function ImageDecryptionPage({
<h2 className="settings-section-heading"></h2>
<ImageTestSection
state={controller.state}
batchTest={controller.batchTest}
disabled={controller.busy}
canSave={controller.canSave}
onSelect={controller.selectChat}
onTest={() => void controller.test()}
onBatchTest={(contacts) => void controller.testMany(contacts)}
onStopBatchTest={controller.stopBatchTest}
onCopyLog={() => void controller.copyDiagnostics()}
onSave={() => void controller.save()}
/>
+6
View File
@@ -821,6 +821,12 @@
line-height: 1.4;
}
.quoted-message-image {
flex-direction: column;
align-items: flex-start;
gap: 7px;
}
.wechat-message-row.other .quoted-message {
background: #f0f0f0;
color: #666;
+51 -1
View File
@@ -21,7 +21,7 @@
.conversation-sidebar-title-row {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
@@ -42,6 +42,56 @@
}
}
.conversation-sidebar-meta {
display: flex;
align-items: center;
gap: 6px;
}
.conversation-refresh-button {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
-webkit-app-region: no-drag;
&:hover:not(:disabled) {
background: rgba(36, 122, 99, 0.1);
color: var(--wxex-brand);
}
&:focus-visible {
outline: 2px solid rgba(36, 122, 99, 0.35);
outline-offset: 1px;
}
&:disabled {
cursor: wait;
opacity: 0.72;
}
svg {
width: 16px;
height: 16px;
fill: currentColor;
}
&.is-refreshing svg {
animation: conversation-refresh-spin 0.8s linear infinite;
}
}
@keyframes conversation-refresh-spin {
to {
transform: rotate(360deg);
}
}
.conversation-search {
box-sizing: border-box;
display: flex;
+244 -2
View File
@@ -1223,7 +1223,8 @@
font-weight: 600;
}
.image-key-editor input,
.image-test-section select {
.image-test-section select,
.image-test-search {
box-sizing: border-box;
width: 100%;
height: 40px;
@@ -1240,7 +1241,8 @@
monospace;
}
.image-key-editor input:focus,
.image-test-section select:focus {
.image-test-section select:focus,
.image-test-search:focus {
border-color: #247a63;
box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.09);
}
@@ -1269,6 +1271,45 @@
font-size: 12px;
line-height: 1.6;
}
.image-test-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.image-test-copy-area {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.image-test-log-scope {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
}
.image-test-log-scope-icon {
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
border-radius: 50%;
background: color-mix(in srgb, var(--warning-color, #b7791f) 14%, transparent);
color: var(--warning-color, #9a6700);
font-size: 12px;
font-weight: 800;
}
.image-test-copy {
flex: 0 0 auto;
}
.image-test-section > label {
display: block;
margin: 18px 0 8px;
@@ -1276,11 +1317,64 @@
font-size: 12px;
font-weight: 600;
}
.image-test-filter-bar {
display: inline-flex;
gap: 3px;
margin-top: 18px;
padding: 3px;
border: 1px solid #dbe4e0;
border-radius: 9px;
background: #f1f5f3;
}
.image-test-filter-bar button {
min-width: 72px;
height: 30px;
border: 0;
border-radius: 6px;
background: transparent;
color: #66706b;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
}
.image-test-filter-bar button:hover:not(:disabled) {
color: #247a63;
}
.image-test-filter-bar button.active {
background: #fff;
color: #247a63;
box-shadow: 0 1px 4px rgba(35, 69, 57, 0.12);
font-weight: 700;
}
.image-test-filter-bar button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.image-test-search {
display: block;
font-family: inherit;
}
.image-test-empty {
margin: 8px 0 0;
color: #8b938f;
font-size: 12px;
}
.image-test-actions {
display: flex;
flex-wrap: wrap;
gap: 9px;
margin-top: 14px;
}
.image-batch-stop {
border-color: #e2b8b8;
color: #a84444;
}
.image-batch-warning {
margin: 10px 0 0;
color: #7a6b48;
font-size: 12px;
line-height: 1.6;
}
.image-test-result {
display: flex;
flex-wrap: wrap;
@@ -1362,6 +1456,154 @@
color: #a84444;
font-size: 12px;
}
.image-batch-results {
margin-top: 16px;
padding: 13px;
border: 1px solid #dfe8e4;
border-radius: 10px;
background: #f7faf9;
}
.image-batch-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
color: #35403b;
font-size: 12px;
}
.image-batch-summary > div {
display: grid;
gap: 4px;
}
.image-batch-summary strong {
font-size: 13px;
}
.image-batch-summary span {
color: #66706b;
}
.image-batch-progress {
height: 6px;
margin-top: 10px;
overflow: hidden;
border-radius: 999px;
background: #dde6e2;
}
.image-batch-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: #2e876b;
transition: width 180ms ease;
}
.image-batch-counts {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 10px;
}
.image-batch-counts span {
border-radius: 999px;
background: #e9eeec;
padding: 3px 8px;
color: #66706b;
font-size: 11px;
}
.image-batch-counts .success {
background: #e5f2ed;
color: #2e765d;
}
.image-batch-counts .failed {
background: #f9eaea;
color: #a84444;
}
.image-batch-counts .no-image {
background: #f5f0e4;
color: #806c3d;
}
.image-batch-list {
display: grid;
max-height: 360px;
margin: 12px 0 0;
padding: 0;
overflow: auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
list-style: none;
}
.image-batch-item {
min-width: 0;
border: 1px solid #e2e9e6;
border-left: 3px solid #c7cfcc;
border-radius: 8px;
background: #fff;
padding: 9px 10px;
}
.image-batch-item.testing {
border-left-color: #3c78b5;
}
.image-batch-item.success {
border-left-color: #2e876b;
}
.image-batch-item.failed {
border-left-color: #c45656;
}
.image-batch-item.no-image {
border-left-color: #b5964c;
}
.image-batch-item-main {
display: grid;
min-width: 0;
align-items: center;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 7px;
font-size: 11px;
}
.image-batch-contact-name {
overflow: hidden;
color: #35403b;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-batch-contact-type {
border-radius: 999px;
background: #edf2f0;
padding: 2px 6px;
color: #6b746f;
}
.image-batch-status {
color: #66706b;
font-weight: 600;
}
.image-batch-item.success .image-batch-status {
color: #2e765d;
}
.image-batch-item.failed .image-batch-status {
color: #a84444;
}
.image-batch-item.no-image .image-batch-status {
color: #806c3d;
}
.image-batch-time {
color: #8a928e;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.image-batch-item p {
margin: 7px 0 0;
overflow: hidden;
color: #8a5757;
font-size: 11px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 760px) {
.image-batch-list {
grid-template-columns: 1fr;
}
}
.image-auto-heading {
display: flex;
align-items: flex-start;
+79
View File
@@ -0,0 +1,79 @@
import type { Message } from '../../../shared/types'
const normalizeQuotedText = (value: string | undefined): string =>
String(value || '')
.replace(/\s+/g, ' ')
.trim()
export const isInternalReferenceSender = (value: string | undefined): boolean => {
const sender = String(value || '').trim()
return (
!sender ||
sender.endsWith('@chatroom') ||
sender.startsWith('wxid_') ||
/^[a-z0-9_@.-]{12,}$/i.test(sender)
)
}
export function enrichQuotedMessages(
messages: Message[],
referenceMessages: Message[],
resolveSenderName?: (senderId: string) => string | undefined
): Message[] {
const imageDatNameByMd5 = new Map<string, string>()
const messagesByContent = new Map<string, Message[]>()
const senderNames = new Map<string, string>()
for (const message of referenceMessages) {
if (
message.contentData?.type === 'image' &&
message.contentData.md5 &&
message.contentData.datName
) {
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
}
const senderId = String(message.senderId || '').trim()
const senderName = String(message.name || '').trim()
if (senderId && senderName && !isInternalReferenceSender(senderName)) {
senderNames.set(senderId, senderName)
}
const content = normalizeQuotedText(message.content)
if (!content) continue
const candidates = messagesByContent.get(content) || []
candidates.push(message)
messagesByContent.set(content, candidates)
}
return messages.map((message) => {
if (message.contentData?.type !== 'quote') return message
const quote = message.contentData
let quotedImageDatName = quote.quotedImageDatName
if (!quotedImageDatName && quote.quotedImageMd5) {
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
}
let quotedSender = quote.quotedSender
if (isInternalReferenceSender(quotedSender)) {
const senderId = String(quotedSender || '').trim()
const mappedName =
(senderId ? resolveSenderName?.(senderId) : undefined) || senderNames.get(senderId)
if (mappedName && !isInternalReferenceSender(mappedName)) {
quotedSender = mappedName
} else {
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
const source = candidates
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
}
}
if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) {
return message
}
return {
...message,
contentData: { ...quote, quotedSender, quotedImageDatName }
}
})
}