mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-22 05:56:58 +08:00
fix: 完善聊天解析与导出体验
- 修复引用消息名称和图片布局 - 明确单会话图片测试日志范围 - 支持导出文件附件 - 完善图片批测、会话刷新和安全退出
This commit is contained in:
+29
-76
@@ -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>
|
||||
|
||||
+139
-3
@@ -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()}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user