diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts
new file mode 100644
index 0000000..6de66a0
--- /dev/null
+++ b/src/main/export-html-template.ts
@@ -0,0 +1,40 @@
+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}`
+const safe = (value: unknown): string =>
+ String(value ?? '').replace(
+ /[&<>"']/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c
+ )
+
+export function renderExportPage(name: string, messages: Message[]): string {
+ const body = messages
+ .map((m) => {
+ const avatar = m.img
+ ? `
`
+ : safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
+ const audio = m.voiceDataUrl
+ ? `
`
+ : ''
+ const quote =
+ m.contentData?.type === 'quote'
+ ? `${safe(m.contentData.quotedSender || '引用消息')}${safe(m.contentData.quotedContent || '[引用消息]')}
`
+ : ''
+ const media =
+ m.exportMediaUrl && m.exportMediaType === 'image'
+ ? `
`
+ : m.exportMediaUrl && m.exportMediaType === 'video'
+ ? ``
+ : m.exportMediaUrl && m.exportMediaType === 'sticker'
+ ? `
`
+ : ''
+ const avatarMarkup =
+ m.exportShowAvatar === false
+ ? ''
+ : `${m.exportAvatarUrl ? `
})
` : avatar}
`
+ 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}]` : ''))}
`
+ })
+ .join('')
+ return `${safe(name)} - 聊天记录![预览]()
`
+}
diff --git a/src/main/export-service.ts b/src/main/export-service.ts
new file mode 100644
index 0000000..b9f3e08
--- /dev/null
+++ b/src/main/export-service.ts
@@ -0,0 +1,314 @@
+import { app, BrowserWindow, shell } from 'electron'
+import { promises as fs } from 'fs'
+import { extname, join } from 'path'
+import { fileURLToPath } from 'url'
+import * as chat from './services/chat-service'
+import type {
+ ExportJobProgress,
+ ExportMessageKind,
+ ExportRequest,
+ ExportResult
+} from '../shared/export'
+import type { Message } from '../shared/types'
+import { VoiceService } from './voice-service'
+import { renderExportPage } from './export-html-template'
+import { ImageDecryptService } from './image-decrypt-service'
+import { ImageKeyConfigService } from './services/image-key-config-service'
+import { VideoAssetService } from './video-asset-service'
+import { StickerService } from './sticker-service'
+
+const jobs = new Set()
+const safeFilePart = (value: string): string =>
+ value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
+const exportStamp = (): string => {
+ const date = new Date()
+ const pad = (value: number): string => String(value).padStart(2, '0')
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
+}
+const imageKeys = new ImageKeyConfigService()
+function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
+ const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
+ if (!match) return null
+ return {
+ extension: match[1].split('/')[1] === 'jpeg' ? 'jpg' : match[1].split('/')[1],
+ buffer: Buffer.from(match[2], 'base64')
+ }
+}
+const normalizeAssetExtension = (value: string): string => {
+ const extension = value.toLowerCase().replace(/^\./, '')
+ return /^(png|jpg|jpeg|webp|gif)$/.test(extension)
+ ? extension === 'jpeg'
+ ? 'jpg'
+ : extension
+ : 'jpg'
+}
+const detectAssetExtension = (buffer: Buffer): string | null => {
+ if (buffer.subarray(0, 3).toString('ascii') === 'GIF') return 'gif'
+ if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
+ return 'png'
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg'
+ if (
+ buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
+ buffer.subarray(8, 12).toString('ascii') === 'WEBP'
+ )
+ return 'webp'
+ return null
+}
+async function readAvatarAsset(
+ source: string
+): Promise<{ extension: string; buffer: Buffer } | null> {
+ const decoded = decodeDataUrl(source)
+ if (decoded) return { ...decoded, extension: normalizeAssetExtension(decoded.extension) }
+
+ try {
+ if (/^https?:\/\//i.test(source)) {
+ const response = await fetch(source)
+ if (!response.ok) return null
+ const contentType = response.headers.get('content-type')?.split(';')[0].split('/')[1]
+ const extension = normalizeAssetExtension(contentType || extname(new URL(source).pathname))
+ const buffer = Buffer.from(await response.arrayBuffer())
+ return { extension: detectAssetExtension(buffer) || extension, buffer }
+ }
+ const path = source.startsWith('file://') ? fileURLToPath(source) : source
+ const buffer = await fs.readFile(path)
+ return {
+ extension: detectAssetExtension(buffer) || normalizeAssetExtension(extname(path)),
+ buffer
+ }
+ } catch {
+ return null
+ }
+}
+const kindOf = (message: Message): ExportMessageKind => {
+ const type = message.contentData?.type
+ if (
+ type === 'image' ||
+ type === 'video' ||
+ type === 'voice' ||
+ type === 'sticker' ||
+ type === 'share' ||
+ type === 'location' ||
+ type === 'system'
+ )
+ return type
+ if (message.type === '图片') return 'image'
+ if (message.type === '视频') return 'video'
+ if (message.type === '语音') return 'voice'
+ if (message.type === '表情包') return 'sticker'
+ return 'text'
+}
+const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
+
+function render(format: ExportRequest['format'], messages: Message[], name: string): string {
+ if (format === 'html') return renderExportPage(name, messages)
+ if (format === 'json')
+ return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
+ if (format === 'markdown')
+ return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}`
+ return [
+ '时间,发送者,类型,内容',
+ ...messages.map((m) =>
+ [m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',')
+ )
+ ].join('\n')
+}
+
+export async function runExport(request: ExportRequest, win: BrowserWindow): Promise {
+ jobs.add(request.jobId)
+ const send = (p: ExportJobProgress): void => {
+ if (!win.isDestroyed()) win.webContents.send('export:progress', p)
+ }
+ try {
+ send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
+ await new Promise((resolve) => setImmediate(resolve))
+ const messages = chat
+ .listMessages(request.userMd5, request.startTime, request.endTime)
+ .filter((m) => request.kinds.includes(kindOf(m)))
+ for (const message of messages) {
+ message.exportShowAvatar = request.includeAvatars !== false
+ const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
+ if (mappedName) message.name = mappedName
+ }
+ send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
+ if (!jobs.has(request.jobId)) {
+ send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
+ return { success: false, error: '已取消' }
+ }
+ send({
+ jobId: request.jobId,
+ phase: 'writing',
+ processed: 0,
+ total: messages.length,
+ percent: 15
+ })
+ const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
+ await fs.mkdir(root, { recursive: true })
+ const ext = request.format === 'markdown' ? 'md' : request.format
+ const outputFolder = `${safeFilePart(request.outputName)}_${exportStamp()}`
+ const outputDir = join(root, outputFolder)
+ const outputPath =
+ request.format === 'html'
+ ? join(outputDir, 'index.html')
+ : join(root, `${outputFolder}.${ext}`)
+ if (request.format === 'html') {
+ await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
+ await fs.mkdir(join(outputDir, 'media'), { recursive: true })
+ await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
+ const client = chat.getChatDb()?.getWcdb4Client()
+ const avatarUsernames = Array.from(
+ new Set(
+ messages
+ .map((message) => message.senderId)
+ .filter((value): value is string => Boolean(value))
+ )
+ )
+ const avatarMap =
+ request.includeAvatars === false
+ ? {}
+ : { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) }
+ const imageConfig = imageKeys.getConfig()
+ const imageService =
+ client && imageConfig.aesKey
+ ? new ImageDecryptService(imageConfig.xorKey || '0x40', imageConfig.aesKey, client)
+ : null
+ const videoService = client ? new VideoAssetService(client) : null
+ const stickerService = client ? new StickerService(client) : null
+ const exportedAvatars = new Map()
+ const voiceService =
+ request.includeMedia && chat.getChatDb()
+ ? new VoiceService(chat.getChatDb()!.getWcdb4Client())
+ : null
+ if (voiceService) {
+ for (const [index, message] of messages.entries()) {
+ if (
+ kindOf(message) !== 'voice' ||
+ !message.sessionId ||
+ !message.localId ||
+ !message.createTime
+ )
+ continue
+ const voice = await voiceService.resolveVoice(
+ message.sessionId,
+ message.localId,
+ message.createTime,
+ message.serverId
+ )
+ if (!voice.success || !voice.data) continue
+ const voiceName = `voice_${index + 1}_${message.localId}.wav`
+ const audioBuffer = Buffer.from(voice.data, 'base64')
+ await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
+ message.voiceDataUrl = `voices/${voiceName}`
+ message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
+ }
+ }
+ for (const [index, message] of messages.entries()) {
+ message.exportShowAvatar = request.includeAvatars !== false
+ const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
+ const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null
+ const avatarBuffer = resolvedAvatar?.buffer || null
+ const avatarExtension = resolvedAvatar?.extension || 'jpg'
+ if (avatarBuffer) {
+ const avatarKey = message.senderId || `message_${index + 1}`
+ let avatarName = exportedAvatars.get(avatarKey)
+ if (!avatarName) {
+ avatarName = `avatar_${exportedAvatars.size + 1}.${avatarExtension}`
+ await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
+ exportedAvatars.set(avatarKey, avatarName)
+ }
+ message.exportAvatarUrl = `avatars/${avatarName}`
+ }
+ if (!request.includeMedia || !message.contentData) {
+ send({
+ jobId: request.jobId,
+ phase: 'writing',
+ processed: index + 1,
+ total: messages.length,
+ percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
+ })
+ continue
+ }
+ if (message.contentData.type === 'image' && imageService) {
+ const file = imageService.findImageFile(
+ message.contentData.md5,
+ message.contentData.datName,
+ { allowThumbnail: true }
+ )
+ const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null
+ const decoded = decrypted ? decodeDataUrl(decrypted.data) : null
+ if (decoded) {
+ const name = `image_${index + 1}.${decoded.extension}`
+ await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
+ message.exportMediaUrl = `media/${name}`
+ message.exportMediaType = 'image'
+ }
+ } else if (message.contentData.type === 'video' && videoService) {
+ const hashes = [
+ message.contentData.md5,
+ message.contentData.newMd5,
+ message.contentData.rawMd5
+ ].filter((value): value is string => Boolean(value))
+ const resolved = videoService.resolve(hashes)
+ const token = resolved.url?.split('/').pop()
+ const source = token ? videoService.pathForToken(token) : undefined
+ if (source) {
+ const name = `video_${index + 1}.mp4`
+ await fs.copyFile(source, join(outputDir, 'media', name))
+ message.exportMediaUrl = `media/${name}`
+ message.exportMediaType = 'video'
+ }
+ } else if (message.contentData.type === 'sticker' && stickerService) {
+ const stickerSource = message.contentData.url || message.contentData.thumbUrl
+ const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
+ const decoded = result.data
+ ? decodeDataUrl(result.data)
+ : stickerSource
+ ? await readAvatarAsset(stickerSource)
+ : null
+ if (decoded) {
+ const name = `sticker_${index + 1}.${decoded.extension}`
+ await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
+ message.exportMediaUrl = `media/${name}`
+ message.exportMediaType = 'sticker'
+ }
+ }
+ send({
+ jobId: request.jobId,
+ phase: 'writing',
+ processed: index + 1,
+ total: messages.length,
+ percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
+ })
+ }
+ } else {
+ send({
+ jobId: request.jobId,
+ phase: 'writing',
+ processed: messages.length,
+ total: messages.length,
+ percent: 90
+ })
+ }
+ await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8')
+ send({
+ jobId: request.jobId,
+ phase: 'completed',
+ processed: messages.length,
+ total: messages.length,
+ percent: 100,
+ outputPath
+ })
+ return { success: true, outputPath, messageCount: messages.length }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
+ return { success: false, error: message }
+ } finally {
+ jobs.delete(request.jobId)
+ }
+}
+export function cancelExport(jobId: string): void {
+ jobs.delete(jobId)
+}
+export async function revealExport(path: string): Promise {
+ shell.showItemInFolder(path)
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index 74a44d3..ec020dc 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -75,6 +75,8 @@ import { appLogger } from './app-logger'
import type { AppLogEntry } from '../shared/app-log'
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
import { VideoAssetService } from './video-asset-service'
+import { cancelExport, revealExport, runExport } from './export-service'
+import type { ExportRequest } from '../shared/export'
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -105,7 +107,15 @@ protocol.registerSchemesAsPrivileged([
// WCDB's Windows runtime checks the host application name during wcdb_init.
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
-app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
+app.setName(
+ process.platform === 'win32'
+ ? 'WeFlow'
+ : process.env['WXE_USER_DATA']
+ ? 'WechatExplorer Dev'
+ : 'WechatExplorer'
+)
+const isolatedUserData = process.env['WXE_USER_DATA']
+if (isolatedUserData) app.setPath('userData', isolatedUserData)
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
null
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
@@ -419,10 +429,7 @@ app.whenReady().then(async () => {
// 优先级:chat 真实识别到的根 → self.accountRoot → settings.imageKeyRoot → settings.dbRoot
// 必须先看 chat.getCurrentAccountRoot(),否则 settings 缓存漂移会导致扫错目录。
const accountRoot =
- chat.getCurrentAccountRoot() ||
- self?.accountRoot ||
- settings.imageKeyRoot ||
- settings.dbRoot
+ chat.getCurrentAccountRoot() || self?.accountRoot || settings.imageKeyRoot || settings.dbRoot
const wxid = self?.wxid
const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
@@ -568,6 +575,24 @@ app.whenReady().then(async () => {
return exportGroupReport(request)
})
+ ipcMain.handle('export:start', async (event, request: ExportRequest) => {
+ const window = BrowserWindow.fromWebContents(event.sender)
+ if (!window) return { success: false, error: '窗口不可用' }
+ return runExport(request, window)
+ })
+ ipcMain.handle('export:cancel', (_, jobId: string) => {
+ cancelExport(jobId)
+ return { success: true }
+ })
+ ipcMain.handle('export:reveal', async (_, path: string) => {
+ try {
+ await revealExport(path)
+ return { success: true }
+ } catch (error) {
+ return { success: false, error: String(error) }
+ }
+ })
+
ipcMain.handle('report:listGenerated', async () => {
return listGeneratedReports()
})
diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts
index caea740..98a7896 100644
--- a/src/main/message-parser.ts
+++ b/src/main/message-parser.ts
@@ -49,6 +49,8 @@ type QuoteContent = {
quotedContent?: string
quotedSender?: string
quotedType?: string
+ quotedImageMd5?: string
+ quotedImageDatName?: string
}
type SystemContent = {
type: 'system'
@@ -391,6 +393,10 @@ function parseLocationMessage(content: string): ParsedContent {
function parseShareMessage(content: string): ParsedContent {
const appMsgType = extractAppMsgType(content)
+ if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
+ const sticker = parseStickerMessage(content)
+ if (sticker.type === 'sticker') return sticker
+ }
if (appMsgType === '57' || content.includes('')) {
const quote = parseQuoteMessage(content)
const title = extractXmlValue(content, 'title') || undefined
@@ -400,7 +406,9 @@ function parseShareMessage(content: string): ParsedContent {
content: title,
quotedContent: quote.content || '[引用消息]',
quotedSender: quote.sender,
- quotedType: quote.type
+ quotedType: quote.type,
+ quotedImageMd5: quote.imageMd5,
+ quotedImageDatName: quote.imageDatName
}
}
@@ -417,7 +425,13 @@ function parseShareMessage(content: string): ParsedContent {
return { type: 'share', title, des, url, appname, typeVal }
}
-function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
+function parseQuoteMessage(content: string): {
+ content?: string
+ sender?: string
+ type?: string
+ imageMd5?: string
+ imageDatName?: string
+} {
const referMsgStart = content.indexOf('')
const referMsgEnd = content.indexOf('')
if (referMsgStart === -1 || referMsgEnd === -1) return {}
@@ -433,8 +447,16 @@ function parseQuoteMessage(content: string): { content?: string; sender?: string
switch (referType) {
case '1':
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
- case '3':
- return { sender, content: '[图片]', type: referType }
+ case '3': {
+ const image = parseImageMessage(referContent)
+ return {
+ sender,
+ content: '[图片]',
+ type: referType,
+ imageMd5: image.type === 'image' ? image.md5 : undefined,
+ imageDatName: image.type === 'image' ? image.datName : undefined
+ }
+ }
case '34':
return { sender, content: '[语音]', type: referType }
case '43':
diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts
index 6d474b0..9eaa207 100644
--- a/src/main/services/chat-service.ts
+++ b/src/main/services/chat-service.ts
@@ -34,6 +34,8 @@ export interface FormattedContact {
md5: string
type: 'user' | 'group'
avatar?: string
+ wechatNickname?: string
+ remark?: string
}
export interface FormattedMessage {
@@ -49,6 +51,11 @@ export interface FormattedMessage {
contentData?: ReturnType
voiceDataUrl?: string
voiceDuration?: number
+ exportMediaUrl?: string
+ exportMediaType?: 'image' | 'video' | 'sticker'
+ exportShowAvatar?: boolean
+ exportMediaError?: string
+ exportAvatarUrl?: string
localId?: number
serverId?: string
createTime?: number
@@ -130,7 +137,9 @@ export function listContacts(filter?: string): FormattedContact[] {
m_nsNickName: user.nickname || '未知用户',
md5,
type: isGroup ? 'group' : 'user',
- avatar: typeof user.avatar === 'string' ? user.avatar : undefined
+ avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
+ wechatNickname: user.wechatNickname,
+ remark: user.remark
})
}
@@ -249,6 +258,8 @@ function listSourceMessages(
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
}
+ if (parsed.type === 'quote') displayType = '引用消息'
+ if (parsed.type === 'sticker') displayType = '表情包'
} catch {
// ignore parse errors
}
diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts
index e10504d..0b30477 100644
--- a/src/main/wcdb4-client.ts
+++ b/src/main/wcdb4-client.ts
@@ -11,6 +11,8 @@ export interface Wcdb4Session {
username: string
nickname: string
avatar?: string
+ wechatNickname?: string
+ remark?: string
raw: Record
}
@@ -1672,7 +1674,21 @@ export class Wcdb4Client {
'remark',
'name'
])
- return { username, nickname, raw: row }
+ const wechatNickname = this.pickString(row, [
+ 'wechatNickname',
+ 'wechat_nickname',
+ 'nickname',
+ 'nickName',
+ 'name'
+ ])
+ const remark = this.pickString(row, [
+ 'remark',
+ 'remarkName',
+ 'remark_name',
+ 'contactRemark',
+ 'contact_remark'
+ ])
+ return { username, nickname, wechatNickname, remark, raw: row }
}
private normalizeMessage(row: Record): Wcdb4Message {
diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts
index c9156cc..a546fcb 100644
--- a/src/main/wechat-db.ts
+++ b/src/main/wechat-db.ts
@@ -4,6 +4,8 @@ export interface UserContact {
m_nsUsrName: string
nickname: string
avatar?: string
+ wechatNickname?: string
+ remark?: string
}
export interface WechatMessage {
@@ -56,8 +58,7 @@ export class WechatDb {
initialChatTables?: { name: string; db_number: string }[]
) {
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
- const client =
- clientOverride || new Wcdb4Client(rawKey, accountRoot)
+ const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
if (!clientOverride) client.open()
this.wcdb4Client = client
for (const table of initialChatTables || client.getChatTables()) {
@@ -74,7 +75,9 @@ export class WechatDb {
.map((session) => ({
m_nsUsrName: session.username,
nickname: session.nickname || session.username,
- avatar: session.avatar
+ avatar: session.avatar,
+ wechatNickname: session.wechatNickname,
+ remark: session.remark
}))
.filter((contact) => {
if (!keyword) return true
@@ -124,9 +127,8 @@ export class WechatDb {
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
if (!chatroomId) return null
return (
- this.wcdb4Client
- .getGroupMembers(chatroomId)
- .find((member) => member.m_nsUsrName === wxid) || null
+ this.wcdb4Client.getGroupMembers(chatroomId).find((member) => member.m_nsUsrName === wxid) ||
+ null
)
}
diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts
index e304a26..ef4aa19 100644
--- a/src/preload/index.d.ts
+++ b/src/preload/index.d.ts
@@ -39,6 +39,7 @@ import type {
} from '../shared/image-insight'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log'
+import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
export type ParsedContent =
| { type: 'text'; content: string }
@@ -167,6 +168,10 @@ declare global {
cdnUrl?: string,
md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }>
+ startExport: (request: ExportRequest) => Promise
+ cancelExport: (jobId: string) => Promise<{ success: boolean }>
+ revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
+ onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
exportGroupReport: (request: GroupReportExportRequest) => Promise
listGeneratedReports: () => Promise
saveGeneratedReport: (
diff --git a/src/preload/index.ts b/src/preload/index.ts
index a6e9744..6ba21ef 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -17,6 +17,7 @@ import type {
} from '../shared/image-insight'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log'
+import type { ExportRequest, ExportJobProgress } from '../shared/export'
// 渲染器的自定义 API
const api = {
@@ -61,6 +62,15 @@ const api = {
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
+ startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
+ cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
+ revealExport: (path: string) => ipcRenderer.invoke('export:reveal', path),
+ onExportProgress: (callback: (progress: ExportJobProgress) => void) => {
+ const listener = (_event: Electron.IpcRendererEvent, progress: ExportJobProgress): void =>
+ callback(progress)
+ ipcRenderer.on('export:progress', listener)
+ return () => ipcRenderer.removeListener('export:progress', listener)
+ },
exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request),
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 247fefe..0540531 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -20,6 +20,7 @@ import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportG
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'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -1201,8 +1202,19 @@ function App(): React.ReactElement {
/>
)
case 'search':
- case 'export':
return renderPlaceholderPage(activePage)
+ case 'export':
+ return (
+
+ )
}
}
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 3efe764..93196ba 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -7808,3 +7808,743 @@ body {
color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font);
}
+
+/* Export workspace */
+.export-workspace {
+ display: grid;
+ grid-template-columns: 292px minmax(520px, 1fr) 360px;
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ background: var(--wxex-bg-main);
+}
+
+.export-contact-panel,
+.export-config-panel,
+.export-preview-panel {
+ min-width: 0;
+ min-height: 0;
+ background: var(--wxex-bg-elevated);
+}
+
+.export-contact-panel {
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid var(--wxex-border);
+ background: var(--wxex-bg-sidebar);
+}
+
+.export-panel-header {
+ padding: 20px 16px 12px;
+ border-bottom: 1px solid var(--wxex-border);
+}
+
+.export-panel-title-row,
+.export-section-heading,
+.export-preview-heading,
+.export-action-bar,
+.export-target-path,
+.export-media-master,
+.export-account-summary {
+ display: flex;
+ align-items: center;
+}
+
+.export-panel-title-row {
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 14px;
+}
+
+.export-panel-title-row h2,
+.export-config-header h1,
+.export-job-state h2 {
+ margin: 0;
+ color: var(--wxex-text-primary);
+ font-weight: 700;
+}
+
+.export-panel-title-row h2 {
+ font-size: 17px;
+}
+
+.export-count-badge {
+ padding: 3px 8px;
+ border-radius: 5px;
+ background: var(--wxex-bg-elevated);
+ color: var(--wxex-text-secondary);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.export-search-field {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 38px;
+ padding: 0 10px;
+ border: 1px solid var(--wxex-border);
+ border-radius: 7px;
+ background: var(--wxex-bg-elevated);
+ color: var(--wxex-text-muted);
+}
+
+.export-search-field input,
+.export-save-section input,
+.export-date-fields input {
+ min-width: 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--wxex-text-primary);
+ font: 13px/20px var(--wxex-font);
+}
+
+.export-search-field input {
+ width: 100%;
+}
+
+.export-filter-tabs {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 3px;
+ margin-top: 12px;
+ padding: 3px;
+ border-radius: 7px;
+ background: #e3e9e5;
+}
+
+.export-filter-tabs button,
+.export-range-toggle button {
+ border: 0;
+ border-radius: 5px;
+ background: transparent;
+ color: var(--wxex-text-secondary);
+ cursor: pointer;
+ font: 600 12px/30px var(--wxex-font);
+}
+
+.export-filter-tabs button.active,
+.export-range-toggle button.active {
+ background: var(--wxex-bg-elevated);
+ color: var(--wxex-brand);
+ box-shadow: 0 1px 2px rgba(32, 39, 36, 0.06);
+}
+
+.export-contact-list {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 8px 0;
+}
+
+.export-contact-item {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ gap: 10px;
+ padding: 11px 16px;
+ border: 0;
+ border-left: 3px solid transparent;
+ background: transparent;
+ color: var(--wxex-text-primary);
+ cursor: pointer;
+ text-align: left;
+}
+
+.export-contact-item:hover {
+ background: rgba(255, 255, 255, 0.58);
+}
+.export-contact-item.active {
+ border-left-color: var(--wxex-brand);
+ background: var(--wxex-brand-soft);
+}
+
+.export-contact-avatar,
+.export-account-avatar,
+.export-chat-avatar,
+.export-preview-avatar {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ overflow: hidden;
+ background: var(--wxex-brand-soft);
+ color: var(--wxex-brand);
+ font-weight: 700;
+}
+
+.export-contact-avatar {
+ width: 38px;
+ height: 38px;
+ border-radius: 8px;
+}
+.export-contact-avatar img,
+.export-account-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.export-contact-copy,
+.export-account-summary > span:last-child {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+}
+
+.export-contact-copy strong,
+.export-account-summary strong {
+ overflow: hidden;
+ color: var(--wxex-text-primary);
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.export-contact-copy small,
+.export-account-summary small {
+ color: var(--wxex-text-muted);
+ font-size: 11px;
+}
+.export-account-summary small.ready {
+ color: var(--wxex-success);
+}
+
+.export-account-summary {
+ gap: 9px;
+ padding: 14px 16px;
+ border: 0;
+ border-top: 1px solid var(--wxex-border);
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+}
+
+.export-account-avatar {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+}
+
+.export-config-panel {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--wxex-bg-main);
+}
+
+.export-config-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 24px 28px 28px;
+}
+
+.export-config-header {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 26px;
+}
+.export-chat-avatar {
+ width: 52px;
+ height: 52px;
+ border-radius: 10px;
+ font-size: 22px;
+}
+.export-chat-avatar img,
+.export-preview-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.export-config-header h1 {
+ font-size: 20px;
+ line-height: 28px;
+}
+.export-config-header p {
+ margin: 2px 0 0;
+ color: var(--wxex-text-secondary);
+ font-size: 13px;
+}
+
+.export-section {
+ margin-bottom: 25px;
+}
+.export-section h3 {
+ margin: 0 0 12px;
+ color: var(--wxex-text-primary);
+ font-size: 12px;
+ font-weight: 700;
+}
+.export-section-heading {
+ justify-content: space-between;
+ gap: 12px;
+}
+.export-section-heading span {
+ color: var(--wxex-brand);
+ font-size: 12px;
+}
+
+.export-range-toggle {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+.export-range-toggle button {
+ border: 1px solid var(--wxex-border);
+ background: var(--wxex-bg-elevated);
+ line-height: 36px;
+}
+.export-range-toggle button.active {
+ border-color: var(--wxex-brand);
+ background: var(--wxex-brand);
+ color: #fff;
+ box-shadow: none;
+}
+
+.export-date-fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ margin-top: 10px;
+ padding: 12px;
+ border-radius: 8px;
+ background: #f0f3f0;
+}
+.export-date-fields label,
+.export-save-section > label {
+ display: grid;
+ gap: 5px;
+ color: var(--wxex-text-secondary);
+ font-size: 11px;
+}
+.export-date-fields input,
+.export-save-section input {
+ width: 100%;
+ padding: 8px 9px;
+ border: 1px solid var(--wxex-border);
+ border-radius: 6px;
+ background: #fff;
+}
+
+.export-kind-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 4px 20px;
+ padding: 14px;
+ border-radius: 8px;
+ background: #f0f3f0;
+}
+.export-check-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 28px;
+ color: var(--wxex-text-primary);
+ font-size: 12px;
+ cursor: pointer;
+}
+.export-check-row input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--wxex-brand);
+}
+.export-name-mode-grid {
+ display: flex;
+ gap: 8px;
+ padding: 10px;
+ border-radius: 8px;
+ background: #f0f3f0;
+}
+.export-name-mode-option {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ flex: 1;
+ min-height: 32px;
+ padding: 0 8px;
+ border: 1px solid var(--wxex-border);
+ border-radius: 6px;
+ background: #fff;
+ color: var(--wxex-text-primary);
+ font-size: 12px;
+ cursor: pointer;
+}
+.export-name-mode-option input {
+ accent-color: var(--wxex-brand);
+}
+
+.export-media-master {
+ justify-content: space-between;
+ gap: 12px;
+ padding: 13px 14px;
+ border: 1px solid var(--wxex-border);
+ border-radius: 7px;
+ background: #fff;
+ color: var(--wxex-text-primary);
+ font-size: 12px;
+}
+.export-media-master input {
+ width: 17px;
+ height: 17px;
+ accent-color: var(--wxex-brand);
+}
+.export-media-options {
+ display: grid;
+ gap: 2px;
+ margin-top: 8px;
+ padding: 9px 13px;
+ border-radius: 8px;
+ background: #f0f3f0;
+}
+.export-media-options.disabled {
+ opacity: 0.52;
+}
+.export-resource-statuses {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 9px;
+}
+.export-resource-statuses span {
+ padding: 3px 7px;
+ border-radius: 4px;
+ background: var(--wxex-brand-soft);
+ color: var(--wxex-success);
+ font-size: 10px;
+}
+.export-helper-text {
+ margin: 9px 0 0;
+ color: var(--wxex-text-muted);
+ font-size: 11px;
+}
+
+.export-format-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+}
+.export-format-grid button {
+ min-height: 78px;
+ border: 1px solid var(--wxex-border);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--wxex-text-primary);
+ cursor: pointer;
+}
+.export-format-grid button.active {
+ border: 2px solid var(--wxex-brand);
+ background: #f2f8f5;
+}
+.export-format-grid strong {
+ display: block;
+ font-size: 13px;
+}
+.export-format-grid small {
+ display: block;
+ margin-top: 6px;
+ color: var(--wxex-success);
+ font-size: 10px;
+}
+.export-html-options {
+ display: grid;
+ gap: 8px;
+ margin-top: 10px;
+ padding: 11px 13px;
+ border: 1px solid var(--wxex-border);
+ border-radius: 8px;
+ background: #f0f3f0;
+ color: var(--wxex-text-primary);
+ font-size: 12px;
+}
+.export-html-options input {
+ accent-color: var(--wxex-brand);
+}
+
+.export-save-section {
+ display: grid;
+ gap: 12px;
+}
+.export-target-path {
+ gap: 10px;
+ padding: 9px 10px;
+ border: 1px solid var(--wxex-border);
+ border-radius: 7px;
+ background: #fff;
+ color: var(--wxex-text-secondary);
+ font-size: 11px;
+}
+.export-target-path strong {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--wxex-text-primary);
+ font-weight: 500;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.export-target-path button {
+ padding: 5px 8px;
+ border: 1px solid var(--wxex-border);
+ border-radius: 5px;
+ background: #fff;
+ color: var(--wxex-text-primary);
+ cursor: pointer;
+ font: 11px var(--wxex-font);
+}
+
+.export-action-bar {
+ flex: 0 0 58px;
+ gap: 8px;
+ padding: 0 22px;
+ border-top: 1px solid var(--wxex-border);
+ background: #fff;
+ color: var(--wxex-text-secondary);
+ font-size: 12px;
+}
+.export-ready-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--wxex-success);
+}
+.export-ready-dot.completed {
+ background: var(--wxex-brand);
+}
+.export-target-summary {
+ flex: 1;
+ min-width: 0;
+ margin-left: 14px;
+ overflow: hidden;
+ color: var(--wxex-text-muted);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.export-reset-button {
+ border: 0;
+ background: transparent;
+ color: var(--wxex-text-primary);
+ cursor: pointer;
+ font: 600 12px var(--wxex-font);
+}
+.export-primary-button {
+ min-width: 132px;
+ padding: 10px 16px;
+ border: 0;
+ border-radius: 7px;
+ background: var(--wxex-brand);
+ color: #fff;
+ cursor: pointer;
+ font: 700 12px var(--wxex-font);
+}
+.export-primary-button:disabled {
+ cursor: default;
+ opacity: 0.55;
+}
+
+.export-preview-panel {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border-left: 1px solid var(--wxex-border);
+ background: #fbfcfb;
+}
+.export-preview-heading {
+ justify-content: space-between;
+ flex: 0 0 52px;
+ padding: 0 18px;
+ border-bottom: 1px solid var(--wxex-border);
+ color: var(--wxex-text-primary);
+ font-size: 12px;
+}
+.export-preview-heading span {
+ color: var(--wxex-text-muted);
+ font-size: 11px;
+}
+.export-message-preview {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 16px 14px;
+}
+.export-preview-date {
+ width: fit-content;
+ margin: 0 auto 18px;
+ padding: 4px 9px;
+ border-radius: 10px;
+ background: #e9eeeb;
+ color: var(--wxex-text-secondary);
+ font-size: 10px;
+}
+.export-preview-message {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin-bottom: 15px;
+}
+.export-preview-message.mine {
+ flex-direction: row-reverse;
+}
+.export-preview-avatar {
+ width: 30px;
+ height: 30px;
+ border-radius: 7px;
+ font-size: 11px;
+}
+.export-preview-bubble {
+ max-width: 78%;
+ padding: 9px 10px;
+ border-radius: 8px;
+ background: #fff;
+ color: var(--wxex-text-primary);
+ font-size: 12px;
+ line-height: 18px;
+ box-shadow: 0 1px 2px rgba(32, 39, 36, 0.05);
+}
+.export-preview-message.mine .export-preview-bubble {
+ background: #95ec69;
+}
+.export-preview-bubble small {
+ display: block;
+ margin-bottom: 3px;
+ color: var(--wxex-text-muted);
+ font-size: 10px;
+}
+.export-preview-stats {
+ flex: 0 0 auto;
+ display: grid;
+ gap: 12px;
+ padding: 18px;
+ border-top: 1px solid var(--wxex-border);
+}
+.export-preview-stats span,
+.export-complete-summary span {
+ display: flex;
+ justify-content: space-between;
+ color: var(--wxex-text-secondary);
+ font-size: 12px;
+}
+.export-preview-stats strong,
+.export-complete-summary strong {
+ color: var(--wxex-text-primary);
+ font-weight: 600;
+}
+
+.export-job-state {
+ display: grid;
+ align-content: center;
+ gap: 12px;
+ height: 100%;
+ padding: 28px;
+ text-align: center;
+}
+.export-job-state h2 {
+ font-size: 18px;
+}
+.export-job-state p {
+ margin: 0;
+ color: var(--wxex-text-secondary);
+ font-size: 12px;
+ line-height: 18px;
+}
+.export-job-state ol {
+ display: grid;
+ gap: 10px;
+ margin: 8px 0;
+ padding: 0;
+ list-style: none;
+ text-align: left;
+}
+.export-job-state li {
+ color: var(--wxex-text-muted);
+ font-size: 12px;
+}
+.export-job-state li::before {
+ display: inline-grid;
+ width: 16px;
+ height: 16px;
+ margin-right: 8px;
+ place-items: center;
+ border: 1px solid var(--wxex-border);
+ border-radius: 50%;
+ content: '';
+ vertical-align: -3px;
+}
+.export-job-state li.done {
+ color: var(--wxex-success);
+}
+.export-job-state li.done::before {
+ border-color: var(--wxex-success);
+ background: var(--wxex-success);
+ content: '✓';
+ color: #fff;
+ font-size: 10px;
+}
+.export-job-state li.current {
+ color: var(--wxex-text-primary);
+ font-weight: 600;
+}
+.export-job-state li.current::before {
+ border-color: var(--wxex-brand);
+ background: var(--wxex-brand);
+ box-shadow: inset 0 0 0 4px #fff;
+}
+.export-progress-bar {
+ height: 6px;
+ overflow: hidden;
+ border-radius: 4px;
+ background: #e2e9e4;
+}
+.export-progress-bar span {
+ display: block;
+ height: 100%;
+ border-radius: 4px;
+ background: var(--wxex-brand);
+ transition: width 0.2s ease;
+}
+.export-job-state > strong {
+ color: var(--wxex-text-secondary);
+ font-size: 12px;
+ font-weight: 500;
+}
+.export-cancel-button,
+.export-open-folder-button {
+ padding: 10px 14px;
+ border: 1px solid var(--wxex-border);
+ border-radius: 7px;
+ background: #fff;
+ color: var(--wxex-text-primary);
+ cursor: pointer;
+ font: 600 12px var(--wxex-font);
+}
+.export-success-icon {
+ width: 58px;
+ height: 58px;
+ margin: 0 auto 4px;
+ border: 5px solid var(--wxex-brand-soft);
+ border-radius: 50%;
+ background: #fff;
+ color: var(--wxex-brand);
+ font-size: 30px;
+ line-height: 48px;
+}
+.export-complete-summary {
+ display: grid;
+ gap: 12px;
+ margin: 12px 0;
+ padding: 14px;
+ border-radius: 8px;
+ background: #eef3ef;
+ text-align: left;
+}
+
+@media (max-width: 1100px) {
+ .export-workspace {
+ grid-template-columns: 248px minmax(460px, 1fr);
+ }
+ .export-preview-panel {
+ display: none;
+ }
+}
diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx
index 15af505..587f5a3 100644
--- a/src/renderer/src/components/ChatWindow.tsx
+++ b/src/renderer/src/components/ChatWindow.tsx
@@ -4,7 +4,6 @@ import { ChatHeader } from './chat/ChatHeader'
import { ChatStatusBar } from './chat/ChatStatusBar'
import { DataTrustBar } from './chat/DataTrustBar'
import { EmptyConversationState } from './chat/EmptyConversationState'
-import { ExportRange } from './chat/ExportMenu'
import { MessageList } from './chat/MessageList'
interface ChatWindowProps {
@@ -171,65 +170,6 @@ const ChatWindow: React.FC = ({
}
}, [previewImage])
- const handleExport = (days: ExportRange): void => {
- if (!messages.length) return
-
- let filtered = messages
- if (days !== 'all') {
- const now = new Date()
- const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
-
- filtered = messages.filter((m) => {
- const parsed = new Date(m.datetime).getTime()
- if (isNaN(parsed)) return true
-
- if (days === 0) {
- // 今天
- return parsed >= startOfDay
- } else if (days === 1) {
- // 昨天
- const startOfYesterday = startOfDay - 86400000
- return parsed >= startOfYesterday && parsed < startOfDay
- } else if (days === 7) {
- // 过去 7 天
- const startOf7DaysAgo = startOfDay - 7 * 86400000
- return parsed >= startOf7DaysAgo
- } else if (days === 30) {
- // 过去 30 天
- const startOf30DaysAgo = startOfDay - 30 * 86400000
- return parsed >= startOf30DaysAgo
- }
- return true
- })
- }
-
- const headers = ['发送者', '类型', '时间', '内容']
- const csvContent = [
- headers.join(','),
- ...filtered.map((m) => {
- let prefix = ''
- if (isGroupChat) {
- prefix = m.name ? `${m.name}: ` : ''
- } else {
- const name = m.from === 'user' ? contact?.m_nsNickName || '未知' : '我'
- prefix = `${name}: `
- }
- const fullContent = `${prefix}${m.content}`
- const content = fullContent.replace(/"/g, '""').replace(/\n/g, ' ')
- return `"${m.from}","${m.type}","${m.datetime}","${content}"`
- })
- ].join('\n')
-
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
- const url = URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.href = url
- link.setAttribute('download', `${contact?.m_nsNickName || 'chat'}_export.csv`)
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- }
-
const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
@@ -255,11 +195,9 @@ const ChatWindow: React.FC = ({
filteredCount={filteredMessages.length}
contentFilter={contentFilter || ''}
isAiLoading={isAiLoading}
- canExport={messages.length > 0}
onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh}
onRefreshData={onRefreshData}
- onExport={handleExport}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
/>
diff --git a/src/renderer/src/components/RichMessageBubble.tsx b/src/renderer/src/components/RichMessageBubble.tsx
index 63c53d8..588903c 100644
--- a/src/renderer/src/components/RichMessageBubble.tsx
+++ b/src/renderer/src/components/RichMessageBubble.tsx
@@ -2,14 +2,21 @@ import { ParsedContent } from '../../../shared/types'
import { useEffect, useState } from 'react'
import type { JSX, MouseEvent } from 'react'
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
+import { ImageBubble } from './ImageBubble'
const stickerDataUrlCache = new Map()
interface RichMessageBubbleProps {
contentData: ParsedContent
+ sessionId?: string
+ onImageClick?: (imageUrl: string) => void
}
-export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
+export function RichMessageBubble({
+ contentData,
+ sessionId,
+ onImageClick
+}: RichMessageBubbleProps): JSX.Element {
switch (contentData.type) {
case 'location':
return
@@ -22,7 +29,7 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
case 'sticker':
return
case 'quote':
- return
+ return
case 'system':
return
case 'unknown':
@@ -201,7 +208,15 @@ function StickerBubble({
)
}
-function QuoteBubble({ data }: { data: Extract }): JSX.Element {
+function QuoteBubble({
+ data,
+ sessionId,
+ onImageClick
+}: {
+ data: Extract
+ sessionId?: string
+ onImageClick?: (imageUrl: string) => void
+}): JSX.Element {
const quotedText = data.quotedContent || data.content || '[引用消息]'
const replyText = data.content || data.title || ''
const quotedSender = data.quotedSender || data.sender || ''
@@ -210,7 +225,17 @@ function QuoteBubble({ data }: { data: Extract
{quotedSender && {quotedSender}}
- {renderWechatEmojiText(quotedText, 18)}
+ {data.quotedImageMd5 || data.quotedImageDatName ? (
+
+ ) : (
+ {renderWechatEmojiText(quotedText, 18)}
+ )}
{replyText &&
{renderWechatEmojiText(replyText)}
}
diff --git a/src/renderer/src/components/chat/ChatHeader.tsx b/src/renderer/src/components/chat/ChatHeader.tsx
index da8a8a9..1a56016 100644
--- a/src/renderer/src/components/chat/ChatHeader.tsx
+++ b/src/renderer/src/components/chat/ChatHeader.tsx
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { Contact } from '../../../../shared/types'
import { ConversationContentSearch } from './ConversationContentSearch'
-import { ExportMenu, ExportRange } from './ExportMenu'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
interface ChatHeaderProps {
@@ -12,11 +11,9 @@ interface ChatHeaderProps {
filteredCount: number
contentFilter: string
isAiLoading: boolean
- canExport: boolean
onContentFilterChange: (value: string) => void
onRefresh?: () => void
onRefreshData?: () => void
- onExport: (range: ExportRange) => void
onOpenAiSettings: () => void
}
@@ -28,11 +25,9 @@ export function ChatHeader({
filteredCount,
contentFilter,
isAiLoading,
- canExport,
onContentFilterChange,
onRefresh,
onRefreshData,
- onExport,
onOpenAiSettings
}: ChatHeaderProps): React.ReactElement {
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
@@ -96,7 +91,6 @@ export function ChatHeader({
-