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)} - 聊天记录![预览]()
`
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({
+
+
时间范围
@@ -396,13 +505,19 @@ export function ExportWorkspace({
消息内容
{messageKinds.map(([value, label]) => (
-
@@ -431,16 +546,17 @@ export function ExportWorkspace({
包含图片、视频、语音及动态表情
setIncludeMedia(event.target.checked)}
/>
-
+
setPreferOriginal(event.target.checked)}
/>
优先导出原图
@@ -449,7 +565,7 @@ export function ExportWorkspace({
setFallbackThumbnail(event.target.checked)}
/>
原图缺失时使用缩略图
@@ -458,12 +574,13 @@ export function ExportWorkspace({
setKeepMissing(event.target.checked)}
/>
媒体缺失时保留占位说明
+
资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。
图片解密:已就绪
视频资源:可用
@@ -484,7 +601,7 @@ export function ExportWorkspace({
导出格式
- {(Object.keys(formatLabels) as ExportFormat[]).map((value) => (
+ {formatOrder.map((value) => (