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)} - 聊天记录
${safe(name)}${messages.length.toLocaleString()} 条消息
${body}
` +} 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({ -
+ ))} +
+ + +
+ {filteredContacts.map((contact) => { + const name = displayName(contact) + return ( + + ) + })} +
+ + + + +
+
+
+ + {activeContact?.avatar ? ( + + ) : ( + activeName.slice(0, 1) + )} + + +

导出设置

+

+ {activeName} + {activeContact?.type === 'group' ? ' · 群聊' : ''} +

+
+
+ +
+
+

时间范围

+ {status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'} +
+
+ + + + +
+ {range === 'custom' && ( +
+ + +
+ )} +
+ +
+

消息内容

+
+ {messageKinds.map(([value, label]) => ( + + ))} +
+
+ +
+

消息显示名称

+
+ {nameOptions.map((option) => ( + + ))} +
+
+ +
+

资源处理

+ +
+ + + +
+
+ 图片解密:已就绪 + 视频资源:可用 + 语音资源:可用 + 表情资源:按需解析 +
+

媒体资源会延长导出时间,缺失资源不会中断任务。

+ +
+ +
+

导出格式

+
+ {(Object.keys(formatLabels) as ExportFormat[]).map((value) => ( + + ))} +
+ {format === 'html' && ( +
+ + +
+ )} +
+ +
+

保存设置

+ +
+ 保存位置 + {targetPath} + +
+
+
+
+ + + {status === 'running' + ? '正在后台导出' + : status === 'completed' + ? '导出完成' + : '准备就绪'} + + 路径:{targetPath} + + +
+
+ + + + ) +} diff --git a/src/shared/export.ts b/src/shared/export.ts new file mode 100644 index 0000000..a44f0d0 --- /dev/null +++ b/src/shared/export.ts @@ -0,0 +1,56 @@ +import type { Message } from './types' + +export type ExportFormat = 'html' | 'csv' | 'json' | 'markdown' +export type ExportMessageKind = + | 'text' + | 'image' + | 'video' + | 'voice' + | 'sticker' + | 'share' + | 'location' + | 'system' + +export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname' + +export interface ExportRequest { + jobId: string + userMd5: string + name: string + format: ExportFormat + outputName: string + startTime?: number + endTime?: number + kinds: ExportMessageKind[] + includeMedia: boolean + includeAvatars?: boolean + avatarUrls?: Record + nameMode?: ExportNameMode + nameMap?: Record + zip?: boolean +} + +export interface ExportJobProgress { + jobId: string + phase: 'reading' | 'writing' | 'completed' | 'cancelled' | 'failed' + processed: number + total?: number + percent?: number + outputPath?: string + error?: string +} + +export interface ExportResult { + success: boolean + outputPath?: string + messageCount?: number + error?: string +} +export type ExportRendererApi = { + startExport: (request: ExportRequest) => Promise + cancelExport: (jobId: string) => Promise<{ success: boolean }> + revealExport: (path: string) => Promise<{ success: boolean; error?: string }> + onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void +} + +export type ExportMessage = Message diff --git a/src/shared/types.ts b/src/shared/types.ts index d32c484..d94b3e2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -4,6 +4,8 @@ export interface Contact { md5: string type: 'user' | 'group' avatar?: string + wechatNickname?: string + remark?: string } export interface Message { @@ -26,6 +28,11 @@ export interface Message { recalled?: boolean recalledBy?: string recoveredFromRecallJournal?: boolean + exportMediaUrl?: string + exportMediaType?: 'image' | 'video' | 'sticker' + exportShowAvatar?: boolean + exportMediaError?: string + exportAvatarUrl?: string } type TextContent = { type: 'text'; content: string } @@ -79,6 +86,8 @@ type QuoteContent = { quotedContent?: string quotedSender?: string quotedType?: string + quotedImageMd5?: string + quotedImageDatName?: string } type SystemContent = { type: 'system'