From 9348c5ce4aaf3178c1a8f3679206c561521732c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <969409112@qq.com> Date: Mon, 27 Jul 2026 22:06:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=8A=B6=E6=80=81=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95=E5=AF=BC=E5=87=BA=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + src/main/export-html-template.ts | 5 +- src/main/export-service.ts | 1 + src/main/message-parser.ts | 1 + src/main/services/chat-service.ts | 13 +- src/main/services/recall-archive-service.ts | 3 + src/renderer/src/App.tsx | 155 +++++++++++++- src/renderer/src/assets/main.css | 179 +++++++++++++++- src/renderer/src/components/ChatWindow.tsx | 6 +- .../src/components/chat/MessageList.tsx | 9 +- .../conversation/ConversationSection.tsx | 59 +++++- .../conversation/ConversationSidebar.tsx | 96 +++++++-- .../src/components/export/ExportWorkspace.tsx | 192 +++++++++++++++--- .../features/settings/SettingsWorkspace.tsx | 29 +-- .../settings/model/settingsNavigation.ts | 2 +- .../src/features/settings/model/types.ts | 1 + .../settings/pages/RecallProtectionPage.tsx | 56 +++++ src/shared/export.ts | 10 + src/shared/types.ts | 1 + 19 files changed, 727 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/features/settings/pages/RecallProtectionPage.tsx diff --git a/.gitignore b/.gitignore index 9527636..f69de56 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ docs/design/ docs/ui-redesign-plan.md docs/ui-redesign-spec.md AGENTS.md +findings.md +progress.md +task_plan.md diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index 6de66a0..ae2d5b5 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -1,6 +1,6 @@ import type { Message } from '../shared/types' -export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px}.message{display:flex;flex-direction:column;gap:6px;max-width:820px;margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}` +export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}` const safe = (value: unknown): string => String(value ?? '').replace( /[&<>"']/g, @@ -32,8 +32,9 @@ export function renderExportPage(name: string, messages: Message[]): string { m.exportShowAvatar === false ? '' : `
${m.exportAvatarUrl ? `` : avatar}
` + const isPat = m.contentData?.type === 'system' && m.contentData.pat const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '') - return `
${safe(m.datetime)}
${avatarMarkup}
${safe(m.name || (m.isSender ? '我' : '联系人'))}
${media}${audio}${quote}
${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}
` + return `
${safe(m.datetime)}
${isPat ? '' : avatarMarkup}
${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}
${media}${audio}${quote}
${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}
` }) .join('') return `${safe(name)} - 聊天记录
${safe(name)}${messages.length.toLocaleString()} 条消息
${body}
` diff --git a/src/main/export-service.ts b/src/main/export-service.ts index b9f3e08..ad34e8c 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -81,6 +81,7 @@ async function readAvatarAsset( } const kindOf = (message: Message): ExportMessageKind => { const type = message.contentData?.type + if (type === 'system' && message.contentData?.pat) return 'text' if ( type === 'image' || type === 'video' || diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index 98a7896..cdca514 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -56,6 +56,7 @@ type SystemContent = { type: 'system' content: string raw?: string + pat?: boolean recall?: { targetId?: string targetIds?: string[] diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 594d578..5215a87 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -229,12 +229,23 @@ function listSourceMessages( let contentData: ReturnType | undefined let displayType = MSG_TYPE_DICT[msgType] || msg.messageType + const isPatMessage = /]+>/g, '').trim(), pat: true } + contentData = patContent + content = patContent.content + displayType = '系统消息' + } const inferredMsgType = typeof content === 'string' && / contact.md5.toLowerCase())) const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5)) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3046bf6..6de10b5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -21,6 +21,7 @@ import { SummaryDateRange, SummaryMessageType } from './utils/group-report' import { Contact, Message } from '../../shared/types' import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage' import { ExportWorkspace } from './components/export/ExportWorkspace' +import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export' const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MAX_WIDTH = 380 @@ -39,7 +40,8 @@ interface SelfInfo { const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' const MESSAGE_MONITOR_DEBOUNCE_MS = 8000 -const VIEW_MESSAGE_LIMIT = 600 +const MESSAGE_PAGE_SIZE = 100 +const EXPORT_PREVIEW_LIMIT = 20 const getMessageIdentity = (message: Message): string => { if (message.localId) return `local:${message.localId}` if (message.id) return `id:${message.id}` @@ -133,6 +135,12 @@ const sortMessagesChronologically = (items: Message[]): Message[] => return getMessageIdentity(left).localeCompare(getMessageIdentity(right)) }) +const mergeMessagePages = (older: Message[], current: Message[]): Message[] => { + const merged = new Map() + for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message) + return sortMessagesChronologically(Array.from(merged.values())) +} + function App(): React.ReactElement { const [isAuthenticated, setIsAuthenticated] = useState(false) const [isDatabaseConnected, setIsDatabaseConnected] = useState(false) @@ -173,6 +181,14 @@ function App(): React.ReactElement { }) const [selfInfo, setSelfInfo] = useState(null) const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false) + const [exportTasks, setExportTasks] = useState(() => { + try { + const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]') + return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : [] + } catch { + return [] + } + }) const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading') const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null) const [startupProgress, setStartupProgress] = useState(null) @@ -217,6 +233,80 @@ function App(): React.ReactElement { }) const lastCapturedReportKeyRef = React.useRef('') + React.useEffect(() => { + const unsubscribe = window.api.onExportProgress((progress: ExportJobProgress) => { + setExportTasks((current) => + current.map((task) => { + if (task.jobId !== progress.jobId) return task + const status = + progress.phase === 'completed' + ? 'completed' + : progress.phase === 'cancelled' + ? 'cancelled' + : progress.phase === 'failed' + ? 'failed' + : 'running' + return { ...task, status, progress } + }) + ) + }) + return unsubscribe + }, []) + + React.useEffect(() => { + localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20))) + }, [exportTasks]) + + const handleStartExport = async (request: ExportRequest): Promise => { + const task: ExportTaskRecord = { + jobId: request.jobId, + contactId: request.userMd5, + contactName: request.name, + format: request.format, + status: 'running', + progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 }, + createdAt: Date.now() + } + setExportTasks((current) => [task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20)) + const result = await window.api.startExport(request) + setExportTasks((current) => + current.map((item) => + item.jobId === request.jobId + ? result.success + ? { + ...item, + status: 'completed', + progress: { + ...item.progress, + phase: 'completed', + processed: result.messageCount ?? item.progress.processed, + total: result.messageCount ?? item.progress.total, + percent: 100, + outputPath: result.outputPath + } + } + : { + ...item, + status: 'failed', + progress: { ...item.progress, phase: 'failed', error: result.error } + } + : item + ) + ) + return result + } + + const handleCancelExport = async (jobId: string): Promise => { + await window.api.cancelExport(jobId) + setExportTasks((current) => + current.map((task) => + task.jobId === jobId + ? { ...task, status: 'cancelled', progress: { ...task.progress, phase: 'cancelled' } } + : task + ) + ) + } + const loadGeneratedReports = React.useCallback(async (): Promise => { try { const result = await window.api.listGeneratedReports() @@ -715,7 +805,7 @@ function App(): React.ReactElement { setMessages( applyGroupMemberMeta( contact, - mergeSyntheticMessages(contact, cachedMsgs.slice(-VIEW_MESSAGE_LIMIT)) + mergeSyntheticMessages(contact, cachedMsgs.slice(-MESSAGE_PAGE_SIZE)) ) ) } else { @@ -724,7 +814,7 @@ function App(): React.ReactElement { await waitForPaint() try { const msgs = await window.api.getMessages(contact.md5, startTime, endTime, { - limit: VIEW_MESSAGE_LIMIT + limit: MESSAGE_PAGE_SIZE }) if (selectedContactMd5Ref.current !== contact.md5) return const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs)) @@ -748,6 +838,46 @@ function App(): React.ReactElement { } } + const handleLoadOlderMessages = async (): Promise => { + const contact = selectedContact + if (!contact || isMessagesLoading || messages.length === 0) return + const oldestTime = messages[0]?.createTime + if (!oldestTime) return + const { startTime } = getDateRangeParams(dateRange) + if (startTime && oldestTime <= startTime) return + + setIsMessagesLoading(true) + try { + const olderMessages = await window.api.getMessages( + contact.md5, + startTime, + oldestTime - 1, + { limit: MESSAGE_PAGE_SIZE } + ) + if (selectedContactMd5Ref.current !== contact.md5) return + setMessages((current) => + applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current))) + ) + } catch (error) { + console.warn('[Messages] older page load failed:', error) + } finally { + if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false) + } + } + + const loadExportPreview = async (contact: Contact): Promise => { + setSelectedContact(contact) + selectedContactMd5Ref.current = contact.md5 + try { + const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, { + limit: EXPORT_PREVIEW_LIMIT + }) + if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages) + } catch (error) { + console.warn('[Export] preview load failed:', error) + } + } + const handleDateRangeChange = (range: string): void => { setDateRange(range) if (selectedContact) { @@ -759,13 +889,13 @@ function App(): React.ReactElement { setMessages( applyGroupMemberMeta( selectedContact, - mergeSyntheticMessages(selectedContact, cachedMessages.slice(-VIEW_MESSAGE_LIMIT)) + mergeSyntheticMessages(selectedContact, cachedMessages.slice(-MESSAGE_PAGE_SIZE)) ) ) }) setIsMessagesLoading(true) window.api - .getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT }) + .getMessages(selectedContact.md5, startTime, endTime, { limit: MESSAGE_PAGE_SIZE }) .then((nextMessages) => { setMessages( applyGroupMemberMeta( @@ -803,7 +933,7 @@ function App(): React.ReactElement { contactMd5, range.startTime, range.endTime, - { limit: VIEW_MESSAGE_LIMIT } + { limit: MESSAGE_PAGE_SIZE } ) const nextMessages = applyGroupMemberMeta( selectedContact, @@ -828,7 +958,10 @@ function App(): React.ReactElement { } } - const unsubscribe = window.api.onWcdbChange(() => { + const unsubscribe = window.api.onWcdbChange(({ json }) => { + const eventText = String(json || '').toLowerCase() + const targetIds = [contactMd5, selectedContact.m_nsUsrName].filter(Boolean).map((value) => value.toLowerCase()) + if (!targetIds.some((targetId) => eventText.includes(targetId))) return if (refreshTimer) window.clearTimeout(refreshTimer) refreshTimer = window.setTimeout(() => { refreshTimer = null @@ -870,6 +1003,8 @@ function App(): React.ReactElement { const handlePageChange = (page: AppPage): void => { setActivePage(page) + if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact) + if (page === 'export' && selectedContact) void loadExportPreview(selectedContact) if (page === 'settings') setSettingsCategory('account-database') if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) { setReportSourceContact(selectedContact) @@ -1083,6 +1218,7 @@ function App(): React.ReactElement { onContentFilterChange={setContentFilter} onRefresh={() => selectedContact && handleSelectContact(selectedContact)} onRefreshData={loadContacts} + onLoadOlderMessages={() => void handleLoadOlderMessages()} onCreateGroupReport={handleOpenReportWorkspace} isAiLoading={reportGeneration.isGenerating} /> @@ -1211,8 +1347,11 @@ function App(): React.ReactElement { previewMessages={messages} selfInfo={selfInfo} dbReady={isDatabaseConnected} - onSelectContact={handleSelectContact} + onSelectContact={loadExportPreview} onOpenSettings={openSettings} + exportTasks={exportTasks} + onStartExport={handleStartExport} + onCancelExport={handleCancelExport} /> ) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index bfde128..25570c7 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -482,10 +482,22 @@ body { .conversation-list { flex: 1; min-height: 0; - overflow-y: auto; + overflow: auto; padding: 8px 8px 12px; } +.conversation-virtual-content { + position: relative; + width: 100%; +} + +.conversation-virtual-row { + position: absolute; + top: 0; + right: 0; + left: 0; +} + .conversation-section + .conversation-section { margin-top: 6px; } @@ -541,9 +553,25 @@ body { } .conversation-section-list { - display: flex; - flex-direction: column; - gap: 2px; + position: relative; + overflow: visible; +} + +.export-config-scroll > .export-section:not(.export-format-top):has(.export-format-grid) { + display: none; +} + +.conversation-section-virtual-content { + position: relative; + width: 100%; +} + +.conversation-section-virtual-row { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 58px; } .conversation-section-empty { @@ -4702,6 +4730,17 @@ body { overflow: hidden; background: #fafbfa; } + +.settings-page-panel { + display: none; + min-width: 0; + min-height: 0; + flex: 1 1 auto; +} + +.settings-page-panel.active { + display: flex; +} .settings-sidebar { width: 294px; flex: 0 0 294px; @@ -8209,6 +8248,25 @@ body { height: 16px; accent-color: var(--wxex-brand); } +.export-check-row.unsupported { + color: var(--wxex-text-muted); + cursor: not-allowed; +} +.export-check-row.unsupported input { + cursor: not-allowed; +} +.export-unsupported-hint { + display: inline-grid; + width: 16px; + height: 16px; + place-items: center; + border: 1px solid #c57923; + border-radius: 50%; + color: #a85e13; + font-size: 11px; + font-weight: 700; + line-height: 1; +} .export-name-mode-grid { display: flex; gap: 8px; @@ -8440,11 +8498,16 @@ body { display: flex; align-items: flex-start; gap: 8px; - margin-bottom: 15px; + width: min(100%, 620px); + margin: 0 auto 15px; } .export-preview-message.mine { flex-direction: row-reverse; } +.export-preview-message.system { + justify-content: center; + flex-direction: row; +} .export-preview-avatar { width: 30px; height: 30px; @@ -8464,6 +8527,23 @@ body { .export-preview-message.mine .export-preview-bubble { background: #95ec69; } +.export-preview-message.system .export-preview-avatar { + display: none; +} +.export-preview-message.system .export-preview-bubble { + max-width: 92%; + padding: 5px 10px; + border: 0; + border-radius: 5px; + background: #e9eeeb; + color: var(--wxex-text-muted); + font-size: 11px; + text-align: center; + box-shadow: none; +} +.export-preview-message.system .export-preview-bubble small { + display: none; +} .export-preview-bubble small { display: block; margin-bottom: 3px; @@ -8477,6 +8557,95 @@ body { padding: 18px; border-top: 1px solid var(--wxex-border); } + +.export-task-center-button { + align-self: flex-start; + margin: 0 0 12px; + border: 1px solid var(--wxex-border); + border-radius: 6px; + background: var(--wxex-bg-elevated); + color: var(--wxex-brand); + cursor: pointer; + padding: 7px 11px; + font: 600 12px/18px var(--wxex-font); +} + +.export-task-center { + margin: 0 0 18px; + padding: 12px; + border: 1px solid var(--wxex-border); + border-radius: 8px; + background: var(--wxex-bg-elevated); +} + +.export-task-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 12px; + padding: 9px 0; + border-top: 1px solid var(--wxex-border); + color: var(--wxex-text-secondary); + font-size: 12px; +} + +.export-task-row:first-of-type { + margin-top: 8px; +} + +.export-task-row span:first-child { + display: grid; + gap: 2px; + min-width: 0; +} + +.export-task-row strong, +.export-task-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.export-task-row strong { + color: var(--wxex-text-primary); +} + +.export-task-progress { + position: relative; + width: 110px; + height: 6px; + overflow: hidden; + border-radius: 999px; + background: var(--wxex-border); +} + +.export-task-progress i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--wxex-brand); +} + +.export-task-progress b { + position: absolute; + top: 10px; + right: 0; + color: var(--wxex-text-muted); + font-size: 10px; + font-weight: 500; +} + +.export-task-row button { + border: 1px solid var(--wxex-border); + border-radius: 5px; + background: transparent; + color: var(--wxex-text-secondary); + cursor: pointer; + padding: 4px 8px; +} +.export-preview-panel .export-preview-stats:not(.export-preview-real-stats) { + display: none; +} .export-preview-stats span, .export-complete-summary span { display: flex; diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index 587f5a3..e24ae04 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -15,6 +15,7 @@ interface ChatWindowProps { onContentFilterChange?: (keyword: string) => void onRefresh?: () => void onRefreshData?: () => void + onLoadOlderMessages?: () => void onCreateGroupReport?: () => void isAiLoading?: boolean } @@ -66,6 +67,7 @@ const ChatWindow: React.FC = ({ onContentFilterChange, onRefresh, onRefreshData, + onLoadOlderMessages, onCreateGroupReport, isAiLoading = false }) => { @@ -97,9 +99,10 @@ const ChatWindow: React.FC = ({ }, []) useEffect(() => { + if (!isAtLatest) return const frame = window.requestAnimationFrame(() => scrollToBottom()) return () => window.cancelAnimationFrame(frame) - }, [messages, scrollToBottom]) + }, [isAtLatest, messages, scrollToBottom]) const openImagePreview = (imageUrl: string): void => { setPreviewImage(imageUrl) @@ -211,6 +214,7 @@ const ChatWindow: React.FC = ({ listRef={messageListRef} bottomRef={messagesEndRef} onScroll={handleMessageListScroll} + onReachTop={onLoadOlderMessages} onImageClick={openImagePreview} /> bottomRef: React.RefObject onScroll: (event: React.UIEvent) => void + onReachTop?: () => void onImageClick: (imageUrl: string) => void } @@ -27,6 +28,7 @@ export function MessageList({ listRef, bottomRef, onScroll, + onReachTop, onImageClick }: MessageListProps): React.ReactElement { const groups = React.useMemo(() => buildMessageGroups(messages), [messages]) @@ -41,8 +43,13 @@ export function MessageList({ }) const virtualItems = virtualizer.getVirtualItems() + const handleScroll = (event: React.UIEvent): void => { + onScroll(event) + if (event.currentTarget.scrollTop < 120) onReachTop?.() + } + return ( -
+
{isLoadingMessages &&
正在加载聊天记录...
} {hiddenMessageCount > 0 && (
diff --git a/src/renderer/src/components/conversation/ConversationSection.tsx b/src/renderer/src/components/conversation/ConversationSection.tsx index 747d5cb..c975245 100644 --- a/src/renderer/src/components/conversation/ConversationSection.tsx +++ b/src/renderer/src/components/conversation/ConversationSection.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' import { Contact } from '../../../../shared/types' import { ConversationItem } from './ConversationItem' @@ -29,6 +30,31 @@ export function ConversationSection({ onToggle, onSelectContact }: ConversationSectionProps): React.ReactElement { + const listRef = useRef(null) + const [scrollMargin, setScrollMargin] = useState(0) + useLayoutEffect(() => { + const updateMargin = (): void => { + const list = listRef.current + const scrollElement = document.querySelector('.conversation-list') + if (!list || !scrollElement) return + setScrollMargin(list.getBoundingClientRect().top - scrollElement.getBoundingClientRect().top) + } + updateMargin() + window.addEventListener('resize', updateMargin) + return () => window.removeEventListener('resize', updateMargin) + }, [contacts.length, expanded]) + const virtualizer = useVirtualizer({ + count: expanded ? contacts.length : 0, + getScrollElement: () => document.querySelector('.conversation-list'), + scrollMargin, + estimateSize: () => 58, + overscan: 8 + }) + + useEffect(() => { + if (expanded) virtualizer.measure() + }, [contacts.length, expanded, virtualizer]) + return (
{expanded && ( -
+
{contacts.length > 0 ? ( - contacts.map((contact) => ( - - )) +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const contact = contacts[virtualItem.index] + return ( +
+ +
+ ) + })} +
) : (
{emptyText}
)} diff --git a/src/renderer/src/components/conversation/ConversationSidebar.tsx b/src/renderer/src/components/conversation/ConversationSidebar.tsx index 2e72e0e..b3dec43 100644 --- a/src/renderer/src/components/conversation/ConversationSidebar.tsx +++ b/src/renderer/src/components/conversation/ConversationSidebar.tsx @@ -1,7 +1,8 @@ -import React, { useState } from 'react' +import React, { useMemo, useRef, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' import { Contact } from '../../../../shared/types' import { AccountSummary } from '../account/AccountSummary' -import { ConversationSection } from './ConversationSection' +import { ConversationItem } from './ConversationItem' import { ConversationSidebarHeader } from './ConversationSidebarHeader' interface SelfInfo { @@ -25,6 +26,11 @@ export interface ConversationSidebarProps { onOpenSettings: () => void } +type SectionName = 'groups' | 'contacts' +type ConversationRow = + | { kind: 'header'; id: string; title: string; count: number; section: SectionName } + | { kind: 'contact'; id: string; contact: Contact } + export function ConversationSidebar({ contacts, selectedContact, @@ -38,10 +44,34 @@ export function ConversationSidebar({ onOpenSettings }: ConversationSidebarProps): React.ReactElement { const [searchTerm, setSearchTerm] = useState('') - const [expandedSection, setExpandedSection] = useState<'groups' | 'contacts'>('groups') + const [expandedSections, setExpandedSections] = useState>({ + groups: true, + contacts: false + }) + const listRef = useRef(null) const groups = contacts.filter((contact) => contact.type === 'group') const users = contacts.filter((contact) => contact.type === 'user') + const rows = useMemo( + () => [ + { kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' }, + ...(expandedSections.groups + ? groups.map((contact) => ({ kind: 'contact' as const, id: `group-${contact.md5}`, contact })) + : []), + { kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' }, + ...(expandedSections.contacts + ? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact })) + : []) + ], + [expandedSections.contacts, expandedSections.groups, groups, users] + ) + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => listRef.current, + estimateSize: (index) => (rows[index]?.kind === 'header' ? 38 : 58), + getItemKey: (index) => rows[index]?.id || index, + overscan: 10 + }) const handleSearchChange = (term: string): void => { setSearchTerm(term) @@ -57,25 +87,47 @@ export function ConversationSidebar({ onSearchChange={handleSearchChange} onDateRangeChange={onDateRangeChange} /> -
- setExpandedSection('groups')} - onSelectContact={onSelectContact} - /> - setExpandedSection('contacts')} - onSelectContact={onSelectContact} - /> +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const row = rows[virtualItem.index] + if (!row) return null + if (row.kind === 'header') { + const expanded = expandedSections[row.section] + return ( + + ) + } + return ( +
+ +
+ ) + })} +
diff --git a/src/renderer/src/components/export/ExportWorkspace.tsx b/src/renderer/src/components/export/ExportWorkspace.tsx index c8f1ced..3ef21c8 100644 --- a/src/renderer/src/components/export/ExportWorkspace.tsx +++ b/src/renderer/src/components/export/ExportWorkspace.tsx @@ -5,6 +5,7 @@ import type { ExportMessageKind, ExportNameMode } from '../../../../shared/export' +import type { ExportRequest, ExportResult, ExportTaskRecord } from '../../../../shared/export' type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom' type ExportFormat = 'html' | 'csv' | 'json' | 'markdown' @@ -34,6 +35,9 @@ interface ExportWorkspaceProps { dbReady: boolean onSelectContact: (contact: Contact) => void onOpenSettings: () => void + exportTasks: ExportTaskRecord[] + onStartExport: (request: ExportRequest) => Promise + onCancelExport: (jobId: string) => Promise } const messageKinds = [ @@ -53,6 +57,7 @@ const formatLabels: Record = { json: { label: 'JSON' }, markdown: { label: 'Markdown' } } +const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown'] function displayName(contact: Contact | null): string { return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话' @@ -73,7 +78,10 @@ export function ExportWorkspace({ selfInfo, dbReady, onSelectContact, - onOpenSettings + onOpenSettings, + exportTasks, + onStartExport, + onCancelExport }: ExportWorkspaceProps): React.ReactElement { const [contactFilter, setContactFilter] = useState('') const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all') @@ -88,12 +96,13 @@ export function ExportWorkspace({ const [preferOriginal, setPreferOriginal] = useState(true) const [fallbackThumbnail, setFallbackThumbnail] = useState(true) const [keepMissing, setKeepMissing] = useState(false) - const [format, setFormat] = useState('html') + const [format, setFormat] = useState('csv') const [zip, setZip] = useState(false) const [fileName, setFileName] = useState('') const [status, setStatus] = useState('idle') const [jobId, setJobId] = useState('') const [progress, setProgress] = useState(null) + const [taskCenterOpen, setTaskCenterOpen] = useState(false) const filteredContacts = useMemo(() => { const keyword = contactFilter.trim().toLowerCase() @@ -107,8 +116,17 @@ export function ExportWorkspace({ }, [contactFilter, contactType, contacts]) const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null + const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5) + 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 || '') + ).length + const previewBytes = preview.reduce( + (total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0), + 0 + ) const outputName = fileName.trim() || `${activeName}_聊天档案` const nameOptions: { value: ExportNameMode; label: string }[] = activeContact?.type === 'group' @@ -156,21 +174,31 @@ export function ExportWorkspace({ return map }, [activeContact, groupMembers, selfInfo]) + const previewName = (message: Message): string => + (message.senderId && nameMap[message.senderId]) || + message.name || + (message.isSender ? selfInfo?.nickname : undefined) || + (message.isSender ? '我' : '联系人') + const previewAvatar = (message: Message): string | undefined => + (message.senderId && avatarUrls[message.senderId]) || + message.img || + (message.isSender ? selfInfo?.avatar : undefined) + const previewItems = preview.map((message) => ({ + ...message, + name: previewName(message), + img: previewAvatar(message) + })) + React.useEffect(() => { setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark') - let cancelled = false - if (!activeContact || activeContact.type !== 'group') { - setGroupMembers([]) - return () => { - cancelled = true - } - } - void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => { - if (!cancelled) setGroupMembers((snapshot?.members || []) as GroupMemberName[]) - }) - return () => { - cancelled = true - } + setGroupMembers([]) + if (!activeContact || activeContact.type !== 'group') return + const timer = window.setTimeout(() => { + void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => { + setGroupMembers((snapshot?.members || []) as GroupMemberName[]) + }) + }, 300) + return () => window.clearTimeout(timer) }, [activeContact]) const toggleKind = (value: string): void => { @@ -188,6 +216,24 @@ export function ExportWorkspace({ setJobId(nextJobId) setProgress(null) setStatus('running') + let exportNameMap = nameMap + let exportAvatarUrls = avatarUrls + if (activeContact.type === 'group') { + const snapshot = await window.api.getGroupSnapshot(activeContact.md5) + const members = (snapshot?.members || []) as GroupMemberName[] + setGroupMembers(members) + exportNameMap = { ...nameMap } + exportAvatarUrls = { ...avatarUrls } + for (const member of members) { + exportNameMap[member.wxid] = + nameMode === 'groupNickname' + ? member.groupNickname || member.nickname || member.wxid + : nameMode === 'remark' + ? member.remark || member.wechatNickname || member.wxid + : member.wechatNickname || member.wxid + if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar + } + } const now = new Date() const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0 @@ -213,13 +259,25 @@ export function ExportWorkspace({ kinds: Array.from(selectedKinds) as ExportMessageKind[], includeMedia, includeAvatars, - avatarUrls, + avatarUrls: exportAvatarUrls, nameMode, - nameMap, + nameMap: exportNameMap, zip } - const result = await window.api.startExport(request) - if (!result.success && result.error !== '已取消') setStatus('idle') + const result = await onStartExport(request) + if (result.success) { + setProgress((current) => ({ + ...(current || { jobId: nextJobId, processed: result.messageCount || 0 }), + phase: 'completed', + processed: result.messageCount ?? current?.processed ?? 0, + total: result.messageCount ?? current?.total, + percent: 100, + outputPath: result.outputPath + })) + setStatus('completed') + } else if (result.error !== '已取消') { + setStatus('idle') + } } React.useEffect( @@ -233,6 +291,19 @@ export function ExportWorkspace({ [jobId] ) + React.useEffect(() => { + if (!currentTask) return + setJobId(currentTask.jobId) + setProgress(currentTask.progress) + setStatus( + currentTask.status === 'running' + ? 'running' + : currentTask.status === 'completed' + ? 'completed' + : 'idle' + ) + }, [currentTask]) + const targetPath = format === 'html' ? zip @@ -318,6 +389,21 @@ export function ExportWorkspace({
+ + {taskCenterOpen && ( +
+

导出任务

{exportTasks.length} 条记录
+ {exportTasks.length === 0 ?

暂无导出记录

: exportTasks.map((task) => ( +
+ {task.contactName}{task.format.toUpperCase()} · {task.progress.phase} + {task.progress.percent ?? 0}% + {task.status === 'running' && } +
+ ))} +
+ )}
{activeContact?.avatar ? ( @@ -335,6 +421,29 @@ export function ExportWorkspace({
+
+

导出格式

+
+ {formatOrder.map((value) => ( + + ))} +
+

CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。

+ {format === 'html' && ( +
+ + +
+ )} +
+

时间范围

@@ -396,13 +505,19 @@ export function ExportWorkspace({

消息内容

{messageKinds.map(([value, label]) => ( -
@@ -431,16 +546,17 @@ export function ExportWorkspace({ 包含图片、视频、语音及动态表情 setIncludeMedia(event.target.checked)} /> -
+
+

资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。

图片解密:已就绪 视频资源:可用 @@ -484,7 +601,7 @@ export function ExportWorkspace({

导出格式

- {(Object.keys(formatLabels) as ExportFormat[]).map((value) => ( + {formatOrder.map((value) => (