feat: 导出功能

This commit is contained in:
Wxw-Gu
2026-07-27 17:43:36 +08:00
parent 9ae0c6cc47
commit e93e4554d7
18 changed files with 2007 additions and 92 deletions
+40
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c
)
export function renderExportPage(name: string, messages: Message[]): string {
const body = messages
.map((m) => {
const avatar = m.img
? `<img src="${safe(m.img)}" alt="">`
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
const audio = m.voiceDataUrl
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>`
: ''
const quote =
m.contentData?.type === 'quote'
? `<div class="quote-reference"><strong>${safe(m.contentData.quotedSender || '引用消息')}</strong><span>${safe(m.contentData.quotedContent || '[引用消息]')}</span></div>`
: ''
const media =
m.exportMediaUrl && m.exportMediaType === 'image'
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="图片">`
: m.exportMediaUrl && m.exportMediaType === 'video'
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
: m.exportMediaUrl && m.exportMediaType === 'sticker'
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
: ''
const avatarMarkup =
m.exportShowAvatar === false
? ''
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
return `<article class="message${m.isSender ? ' sent' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${avatarMarkup}<div class="bubble"><div class="sender">${safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
})
.join('')
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>`
}
+314
View File
@@ -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<string>()
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<ExportResult> {
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<void>((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<string, string>()
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<void> {
shell.showItemInFolder(path)
}
+30 -5
View File
@@ -75,6 +75,8 @@ import { appLogger } from './app-logger'
import type { AppLogEntry } from '../shared/app-log' import type { AppLogEntry } from '../shared/app-log'
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service' import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
import { VideoAssetService } from './video-asset-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. // 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 // 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. // WCDB's Windows runtime checks the host application name during wcdb_init.
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows. // 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 = let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
null null
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03' 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 真实识别到的根 → self.accountRoot → settings.imageKeyRoot → settings.dbRoot
// 必须先看 chat.getCurrentAccountRoot(),否则 settings 缓存漂移会导致扫错目录。 // 必须先看 chat.getCurrentAccountRoot(),否则 settings 缓存漂移会导致扫错目录。
const accountRoot = const accountRoot =
chat.getCurrentAccountRoot() || chat.getCurrentAccountRoot() || self?.accountRoot || settings.imageKeyRoot || settings.dbRoot
self?.accountRoot ||
settings.imageKeyRoot ||
settings.dbRoot
const wxid = self?.wxid const wxid = self?.wxid
const onStatus = (message: string): void => { const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message }) if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
@@ -568,6 +575,24 @@ app.whenReady().then(async () => {
return exportGroupReport(request) 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 () => { ipcMain.handle('report:listGenerated', async () => {
return listGeneratedReports() return listGeneratedReports()
}) })
+26 -4
View File
@@ -49,6 +49,8 @@ type QuoteContent = {
quotedContent?: string quotedContent?: string
quotedSender?: string quotedSender?: string
quotedType?: string quotedType?: string
quotedImageMd5?: string
quotedImageDatName?: string
} }
type SystemContent = { type SystemContent = {
type: 'system' type: 'system'
@@ -391,6 +393,10 @@ function parseLocationMessage(content: string): ParsedContent {
function parseShareMessage(content: string): ParsedContent { function parseShareMessage(content: string): ParsedContent {
const appMsgType = extractAppMsgType(content) 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('<refermsg>')) { if (appMsgType === '57' || content.includes('<refermsg>')) {
const quote = parseQuoteMessage(content) const quote = parseQuoteMessage(content)
const title = extractXmlValue(content, 'title') || undefined const title = extractXmlValue(content, 'title') || undefined
@@ -400,7 +406,9 @@ function parseShareMessage(content: string): ParsedContent {
content: title, content: title,
quotedContent: quote.content || '[引用消息]', quotedContent: quote.content || '[引用消息]',
quotedSender: quote.sender, 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 } 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('<refermsg>') const referMsgStart = content.indexOf('<refermsg>')
const referMsgEnd = content.indexOf('</refermsg>') const referMsgEnd = content.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) return {} if (referMsgStart === -1 || referMsgEnd === -1) return {}
@@ -433,8 +447,16 @@ function parseQuoteMessage(content: string): { content?: string; sender?: string
switch (referType) { switch (referType) {
case '1': case '1':
return { sender, content: sanitizeQuotedContent(referContent), type: referType } return { sender, content: sanitizeQuotedContent(referContent), type: referType }
case '3': case '3': {
return { sender, content: '[图片]', type: referType } 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': case '34':
return { sender, content: '[语音]', type: referType } return { sender, content: '[语音]', type: referType }
case '43': case '43':
+12 -1
View File
@@ -34,6 +34,8 @@ export interface FormattedContact {
md5: string md5: string
type: 'user' | 'group' type: 'user' | 'group'
avatar?: string avatar?: string
wechatNickname?: string
remark?: string
} }
export interface FormattedMessage { export interface FormattedMessage {
@@ -49,6 +51,11 @@ export interface FormattedMessage {
contentData?: ReturnType<typeof parseMessageContent> contentData?: ReturnType<typeof parseMessageContent>
voiceDataUrl?: string voiceDataUrl?: string
voiceDuration?: number voiceDuration?: number
exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker'
exportShowAvatar?: boolean
exportMediaError?: string
exportAvatarUrl?: string
localId?: number localId?: number
serverId?: string serverId?: string
createTime?: number createTime?: number
@@ -130,7 +137,9 @@ export function listContacts(filter?: string): FormattedContact[] {
m_nsNickName: user.nickname || '未知用户', m_nsNickName: user.nickname || '未知用户',
md5, md5,
type: isGroup ? 'group' : 'user', 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) { if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
} }
if (parsed.type === 'quote') displayType = '引用消息'
if (parsed.type === 'sticker') displayType = '表情包'
} catch { } catch {
// ignore parse errors // ignore parse errors
} }
+17 -1
View File
@@ -11,6 +11,8 @@ export interface Wcdb4Session {
username: string username: string
nickname: string nickname: string
avatar?: string avatar?: string
wechatNickname?: string
remark?: string
raw: Record<string, unknown> raw: Record<string, unknown>
} }
@@ -1672,7 +1674,21 @@ export class Wcdb4Client {
'remark', 'remark',
'name' '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<string, unknown>): Wcdb4Message { private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
+8 -6
View File
@@ -4,6 +4,8 @@ export interface UserContact {
m_nsUsrName: string m_nsUsrName: string
nickname: string nickname: string
avatar?: string avatar?: string
wechatNickname?: string
remark?: string
} }
export interface WechatMessage { export interface WechatMessage {
@@ -56,8 +58,7 @@ export class WechatDb {
initialChatTables?: { name: string; db_number: string }[] initialChatTables?: { name: string; db_number: string }[]
) { ) {
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`) console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
const client = const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
clientOverride || new Wcdb4Client(rawKey, accountRoot)
if (!clientOverride) client.open() if (!clientOverride) client.open()
this.wcdb4Client = client this.wcdb4Client = client
for (const table of initialChatTables || client.getChatTables()) { for (const table of initialChatTables || client.getChatTables()) {
@@ -74,7 +75,9 @@ export class WechatDb {
.map((session) => ({ .map((session) => ({
m_nsUsrName: session.username, m_nsUsrName: session.username,
nickname: session.nickname || session.username, nickname: session.nickname || session.username,
avatar: session.avatar avatar: session.avatar,
wechatNickname: session.wechatNickname,
remark: session.remark
})) }))
.filter((contact) => { .filter((contact) => {
if (!keyword) return true if (!keyword) return true
@@ -124,9 +127,8 @@ export class WechatDb {
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null { public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
if (!chatroomId) return null if (!chatroomId) return null
return ( return (
this.wcdb4Client this.wcdb4Client.getGroupMembers(chatroomId).find((member) => member.m_nsUsrName === wxid) ||
.getGroupMembers(chatroomId) null
.find((member) => member.m_nsUsrName === wxid) || null
) )
} }
+5
View File
@@ -39,6 +39,7 @@ import type {
} from '../shared/image-insight' } from '../shared/image-insight'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log' import type { AppLogEntry } from '../shared/app-log'
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
export type ParsedContent = export type ParsedContent =
| { type: 'text'; content: string } | { type: 'text'; content: string }
@@ -167,6 +168,10 @@ declare global {
cdnUrl?: string, cdnUrl?: string,
md5?: string md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }> ) => Promise<{ success: boolean; data?: string; error?: string }>
startExport: (request: ExportRequest) => Promise<ExportResult>
cancelExport: (jobId: string) => Promise<{ success: boolean }>
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult> exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
listGeneratedReports: () => Promise<ReportHistoryResult> listGeneratedReports: () => Promise<ReportHistoryResult>
saveGeneratedReport: ( saveGeneratedReport: (
+10
View File
@@ -17,6 +17,7 @@ import type {
} from '../shared/image-insight' } from '../shared/image-insight'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log' import type { AppLogEntry } from '../shared/app-log'
import type { ExportRequest, ExportJobProgress } from '../shared/export'
// 渲染器的自定义 API // 渲染器的自定义 API
const api = { const api = {
@@ -61,6 +62,15 @@ const api = {
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options), ) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes), getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), 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) => exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request), ipcRenderer.invoke('report:export', request),
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'), listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
+13 -1
View File
@@ -20,6 +20,7 @@ import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportG
import { SummaryDateRange, SummaryMessageType } from './utils/group-report' import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types' import { Contact, Message } from '../../shared/types'
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage' import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
import { ExportWorkspace } from './components/export/ExportWorkspace'
const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380 const SIDEBAR_MAX_WIDTH = 380
@@ -1201,8 +1202,19 @@ function App(): React.ReactElement {
/> />
) )
case 'search': case 'search':
case 'export':
return renderPlaceholderPage(activePage) return renderPlaceholderPage(activePage)
case 'export':
return (
<ExportWorkspace
contacts={contacts}
selectedContact={selectedContact}
previewMessages={messages}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onSelectContact={handleSelectContact}
onOpenSettings={openSettings}
/>
)
} }
} }
+740
View File
@@ -7808,3 +7808,743 @@ body {
color: var(--wxex-text-muted); color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font); 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;
}
}
@@ -4,7 +4,6 @@ import { ChatHeader } from './chat/ChatHeader'
import { ChatStatusBar } from './chat/ChatStatusBar' import { ChatStatusBar } from './chat/ChatStatusBar'
import { DataTrustBar } from './chat/DataTrustBar' import { DataTrustBar } from './chat/DataTrustBar'
import { EmptyConversationState } from './chat/EmptyConversationState' import { EmptyConversationState } from './chat/EmptyConversationState'
import { ExportRange } from './chat/ExportMenu'
import { MessageList } from './chat/MessageList' import { MessageList } from './chat/MessageList'
interface ChatWindowProps { interface ChatWindowProps {
@@ -171,65 +170,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
} }
}, [previewImage]) }, [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(() => { const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => { return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '') const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
@@ -255,11 +195,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
filteredCount={filteredMessages.length} filteredCount={filteredMessages.length}
contentFilter={contentFilter || ''} contentFilter={contentFilter || ''}
isAiLoading={isAiLoading} isAiLoading={isAiLoading}
canExport={messages.length > 0}
onContentFilterChange={onContentFilterChange || (() => undefined)} onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh} onRefresh={onRefresh}
onRefreshData={onRefreshData} onRefreshData={onRefreshData}
onExport={handleExport}
onOpenAiSettings={onCreateGroupReport || (() => undefined)} onOpenAiSettings={onCreateGroupReport || (() => undefined)}
/> />
<DataTrustBar messageCount={messages.length} /> <DataTrustBar messageCount={messages.length} />
@@ -2,14 +2,21 @@ import { ParsedContent } from '../../../shared/types'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { JSX, MouseEvent } from 'react' import type { JSX, MouseEvent } from 'react'
import { renderWechatEmojiText } from '../utils/wechatEmojiText' import { renderWechatEmojiText } from '../utils/wechatEmojiText'
import { ImageBubble } from './ImageBubble'
const stickerDataUrlCache = new Map<string, string>() const stickerDataUrlCache = new Map<string, string>()
interface RichMessageBubbleProps { interface RichMessageBubbleProps {
contentData: ParsedContent 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) { switch (contentData.type) {
case 'location': case 'location':
return <LocationBubble data={contentData} /> return <LocationBubble data={contentData} />
@@ -22,7 +29,7 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
case 'sticker': case 'sticker':
return <StickerBubble data={contentData} /> return <StickerBubble data={contentData} />
case 'quote': case 'quote':
return <QuoteBubble data={contentData} /> return <QuoteBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
case 'system': case 'system':
return <SystemBubble data={contentData} /> return <SystemBubble data={contentData} />
case 'unknown': case 'unknown':
@@ -201,7 +208,15 @@ function StickerBubble({
) )
} }
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element { function QuoteBubble({
data,
sessionId,
onImageClick
}: {
data: Extract<ParsedContent, { type: 'quote' }>
sessionId?: string
onImageClick?: (imageUrl: string) => void
}): JSX.Element {
const quotedText = data.quotedContent || data.content || '[引用消息]' const quotedText = data.quotedContent || data.content || '[引用消息]'
const replyText = data.content || data.title || '' const replyText = data.content || data.title || ''
const quotedSender = data.quotedSender || data.sender || '' const quotedSender = data.quotedSender || data.sender || ''
@@ -210,7 +225,17 @@ function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }>
<div className="quote-message"> <div className="quote-message">
<div className="quoted-message"> <div className="quoted-message">
{quotedSender && <span className="quoted-sender">{quotedSender}</span>} {quotedSender && <span className="quoted-sender">{quotedSender}</span>}
{data.quotedImageMd5 || data.quotedImageDatName ? (
<ImageBubble
imageMd5={data.quotedImageMd5}
imageDatName={data.quotedImageDatName}
sessionId={sessionId}
isThumb
onImageClick={onImageClick}
/>
) : (
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span> <span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
)}
</div> </div>
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>} {replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
</div> </div>
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Contact } from '../../../../shared/types' import { Contact } from '../../../../shared/types'
import { ConversationContentSearch } from './ConversationContentSearch' import { ConversationContentSearch } from './ConversationContentSearch'
import { ExportMenu, ExportRange } from './ExportMenu'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons' import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
interface ChatHeaderProps { interface ChatHeaderProps {
@@ -12,11 +11,9 @@ interface ChatHeaderProps {
filteredCount: number filteredCount: number
contentFilter: string contentFilter: string
isAiLoading: boolean isAiLoading: boolean
canExport: boolean
onContentFilterChange: (value: string) => void onContentFilterChange: (value: string) => void
onRefresh?: () => void onRefresh?: () => void
onRefreshData?: () => void onRefreshData?: () => void
onExport: (range: ExportRange) => void
onOpenAiSettings: () => void onOpenAiSettings: () => void
} }
@@ -28,11 +25,9 @@ export function ChatHeader({
filteredCount, filteredCount,
contentFilter, contentFilter,
isAiLoading, isAiLoading,
canExport,
onContentFilterChange, onContentFilterChange,
onRefresh, onRefresh,
onRefreshData, onRefreshData,
onExport,
onOpenAiSettings onOpenAiSettings
}: ChatHeaderProps): React.ReactElement { }: ChatHeaderProps): React.ReactElement {
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter)) const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
@@ -96,7 +91,6 @@ export function ChatHeader({
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录"> <button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
<RefreshIcon /> <RefreshIcon />
</button> </button>
<ExportMenu disabled={!canExport} onExport={onExport} />
<div className="chat-menu" ref={moreRef}> <div className="chat-menu" ref={moreRef}>
<button <button
type="button" type="button"
@@ -16,7 +16,7 @@ interface MessageBubbleProps {
onImageClick: (imageUrl: string) => void onImageClick: (imageUrl: string) => void
} }
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'] const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '引用消息', '通话', '表情包', '系统消息']
export function MessageBubble({ export function MessageBubble({
message, message,
@@ -60,7 +60,11 @@ export function MessageBubble({
duration={message.contentData.duration} duration={message.contentData.duration}
/> />
) : isRichMedia && message.contentData ? ( ) : isRichMedia && message.contentData ? (
<RichMessageBubble contentData={message.contentData} /> <RichMessageBubble
contentData={message.contentData}
sessionId={message.sessionId}
onImageClick={onImageClick}
/>
) : ( ) : (
<div className="message-text">{renderWechatEmojiText(message.content)}</div> <div className="message-text">{renderWechatEmojiText(message.content)}</div>
)} )}
@@ -0,0 +1,692 @@
import React, { useMemo, useState } from 'react'
import type { Contact, Message } from '../../../../shared/types'
import type {
ExportJobProgress,
ExportMessageKind,
ExportNameMode
} from '../../../../shared/export'
type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
type ExportStatus = 'idle' | 'running' | 'completed'
interface GroupMemberName {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface ExportWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
previewMessages: Message[]
selfInfo: SelfInfo | null
dbReady: boolean
onSelectContact: (contact: Contact) => void
onOpenSettings: () => void
}
const messageKinds = [
['text', '文字'],
['image', '图片'],
['video', '视频'],
['voice', '语音'],
['sticker', '表情包'],
['share', '链接与分享'],
['location', '位置'],
['system', '系统消息']
] as const
const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
html: { label: 'HTML', hint: '推荐' },
csv: { label: 'CSV' },
json: { label: 'JSON' },
markdown: { label: 'Markdown' }
}
function displayName(contact: Contact | null): string {
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
}
function formatPreviewTime(message: Message): string {
if (!message.createTime) return message.datetime || ''
return new Date(message.createTime * 1000).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
export function ExportWorkspace({
contacts,
selectedContact,
previewMessages,
selfInfo,
dbReady,
onSelectContact,
onOpenSettings
}: ExportWorkspaceProps): React.ReactElement {
const [contactFilter, setContactFilter] = useState('')
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
const [range, setRange] = useState<ExportRange>('today')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
const [includeMedia, setIncludeMedia] = useState(true)
const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(false)
const [format, setFormat] = useState<ExportFormat>('html')
const [zip, setZip] = useState(false)
const [fileName, setFileName] = useState('')
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
const filteredContacts = useMemo(() => {
const keyword = contactFilter.trim().toLowerCase()
return contacts.filter((contact) => {
if (contactType !== 'all' && contact.type !== contactType) return false
if (!keyword) return true
return [contact.m_nsNickName, contact.m_nsUsrName].some((value) =>
value.toLowerCase().includes(keyword)
)
})
}, [contactFilter, contactType, contacts])
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
const activeName = displayName(activeContact)
const preview = previewMessages.slice(-20)
const outputName = fileName.trim() || `${activeName}_聊天档案`
const nameOptions: { value: ExportNameMode; label: string }[] =
activeContact?.type === 'group'
? [
{ value: 'groupNickname', label: '群昵称' },
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
: [
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
const nameMap = useMemo(() => {
const map: Record<string, string> = {}
if (activeContact?.type === 'group') {
for (const member of groupMembers) {
const value =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
map[member.wxid] = value
}
} else if (activeContact) {
map[activeContact.m_nsUsrName] =
nameMode === 'remark'
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
: activeContact.wechatNickname || activeContact.m_nsUsrName
}
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
return map
}, [activeContact, groupMembers, nameMode, selfInfo])
const avatarUrls = useMemo(() => {
const map: Record<string, string> = {}
if (activeContact?.m_nsUsrName && activeContact.avatar) {
map[activeContact.m_nsUsrName] = activeContact.avatar
}
for (const member of groupMembers) {
if (member.avatar) map[member.wxid] = member.avatar
}
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
return map
}, [activeContact, groupMembers, selfInfo])
React.useEffect(() => {
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
let cancelled = false
if (!activeContact || activeContact.type !== 'group') {
setGroupMembers([])
return () => {
cancelled = true
}
}
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
if (!cancelled) setGroupMembers((snapshot?.members || []) as GroupMemberName[])
})
return () => {
cancelled = true
}
}, [activeContact])
const toggleKind = (value: string): void => {
setSelectedKinds((current) => {
const next = new Set(current)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
})
}
const handleStart = async (): Promise<void> => {
if (!activeContact || status === 'running') return
const nextJobId = `export-${Date.now()}`
setJobId(nextJobId)
setProgress(null)
setStatus('running')
const now = new Date()
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
const startOfRange = days
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
: null
const request = {
jobId: nextJobId,
userMd5: activeContact.md5,
name: activeName,
format,
outputName,
startTime: startOfRange
? Math.floor(startOfRange.getTime() / 1000)
: range === 'custom' && startDate
? Math.floor(new Date(startDate).getTime() / 1000)
: undefined,
endTime: startOfRange
? Math.floor(endOfToday.getTime() / 1000)
: range === 'custom' && endDate
? Math.floor(new Date(endDate).getTime() / 1000)
: undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia,
includeAvatars,
avatarUrls,
nameMode,
nameMap,
zip
}
const result = await window.api.startExport(request)
if (!result.success && result.error !== '已取消') setStatus('idle')
}
React.useEffect(
() =>
window.api.onExportProgress((next) => {
if (next.jobId !== jobId) return
setProgress(next)
if (next.phase === 'completed') setStatus('completed')
if (next.phase === 'cancelled' || next.phase === 'failed') setStatus('idle')
}),
[jobId]
)
const targetPath =
format === 'html'
? zip
? `文稿/WechatExplorer/导出/${outputName}.zip`
: `文稿/WechatExplorer/导出/${outputName}/`
: `文稿/WechatExplorer/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
return (
<div className="export-workspace">
<aside className="export-contact-panel">
<div className="export-panel-header">
<div className="export-panel-title-row">
<h2></h2>
<span className="export-count-badge"> {contacts.length.toLocaleString()} </span>
</div>
<label className="export-search-field">
<span aria-hidden></span>
<input
value={contactFilter}
onChange={(event) => setContactFilter(event.target.value)}
placeholder="搜索群聊、联系人或 wxid"
aria-label="搜索聊天"
/>
</label>
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型">
{(
[
['all', '全部'],
['group', '群聊'],
['user', '联系人']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={contactType === value ? 'active' : ''}
onClick={() => setContactType(value)}
>
{label}
</button>
))}
</div>
</div>
<div className="export-contact-list">
{filteredContacts.map((contact) => {
const name = displayName(contact)
return (
<button
key={contact.md5}
type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
>
<span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
</span>
<span className="export-contact-copy">
<strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
</button>
)
})}
</div>
<button type="button" className="export-account-summary" onClick={onOpenSettings}>
<span className="export-account-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt="" />
) : (
(selfInfo?.nickname || '我').slice(0, 1)
)}
</span>
<span>
<strong>{selfInfo?.nickname || '当前账号'}</strong>
<small className={dbReady ? 'ready' : ''}>
{dbReady ? '数据库已连接' : '数据库未连接'}
</small>
</span>
</button>
</aside>
<main className="export-config-panel">
<div className="export-config-scroll">
<header className="export-config-header">
<span className="export-chat-avatar">
{activeContact?.avatar ? (
<img src={activeContact.avatar} alt="" />
) : (
activeName.slice(0, 1)
)}
</span>
<span>
<h1></h1>
<p>
{activeName}
{activeContact?.type === 'group' ? ' · 群聊' : ''}
</p>
</span>
</header>
<section className="export-section">
<div className="export-section-heading">
<h3></h3>
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
</div>
<div className="export-range-toggle">
<button
type="button"
className={range === 'today' ? 'active' : ''}
onClick={() => setRange('today')}
>
</button>
<button
type="button"
className={range === 'threeDays' ? 'active' : ''}
onClick={() => setRange('threeDays')}
>
3
</button>
<button
type="button"
className={range === 'sevenDays' ? 'active' : ''}
onClick={() => setRange('sevenDays')}
>
7
</button>
<button
type="button"
className={range === 'custom' ? 'active' : ''}
onClick={() => setRange('custom')}
>
</button>
</div>
{range === 'custom' && (
<div className="export-date-fields">
<label>
<input
type="datetime-local"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<label>
<input
type="datetime-local"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
</label>
</div>
)}
</section>
<section className="export-section">
<h3></h3>
<div className="export-kind-grid">
{messageKinds.map(([value, label]) => (
<label key={value} className="export-check-row">
<input
type="checkbox"
checked={selectedKinds.has(value)}
onChange={() => toggleKind(value)}
/>
<span>{label}</span>
</label>
))}
</div>
</section>
<section className="export-section">
<h3></h3>
<div className="export-name-mode-grid" role="radiogroup" aria-label="消息显示名称">
{nameOptions.map((option) => (
<label key={option.value} className="export-name-mode-option">
<input
type="radio"
name="export-name-mode"
checked={nameMode === option.value}
onChange={() => setNameMode(option.value)}
/>
<span>{option.label}</span>
</label>
))}
</div>
</section>
<section className="export-section">
<h3></h3>
<label className="export-media-master">
<span></span>
<input
type="checkbox"
checked={includeMedia}
onChange={(event) => setIncludeMedia(event.target.checked)}
/>
</label>
<div className={`export-media-options ${includeMedia ? '' : 'disabled'}`}>
<label className="export-check-row">
<input
type="checkbox"
checked={preferOriginal}
disabled={!includeMedia}
onChange={(event) => setPreferOriginal(event.target.checked)}
/>
<span></span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={fallbackThumbnail}
disabled={!includeMedia}
onChange={(event) => setFallbackThumbnail(event.target.checked)}
/>
<span>使</span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={keepMissing}
disabled={!includeMedia}
onChange={(event) => setKeepMissing(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="export-resource-statuses">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<p className="export-helper-text"></p>
<label className="export-media-master">
<span></span>
<input
type="checkbox"
checked={includeAvatars}
onChange={(event) => setIncludeAvatars(event.target.checked)}
/>
</label>
</section>
<section className="export-section">
<h3></h3>
<div className="export-format-grid">
{(Object.keys(formatLabels) as ExportFormat[]).map((value) => (
<button
key={value}
type="button"
className={format === value ? 'active' : ''}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
</button>
))}
</div>
{format === 'html' && (
<div className="export-html-options">
<label>
<input
type="radio"
name="html-package"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label>
<label>
<input
type="radio"
name="html-package"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label>
</div>
)}
</section>
<section className="export-section export-save-section">
<h3></h3>
<label>
<input
value={fileName}
onChange={(event) => setFileName(event.target.value)}
placeholder={`${activeName}_聊天档案`}
/>
</label>
<div className="export-target-path">
<span></span>
<strong>{targetPath}</strong>
<button type="button"></button>
</div>
</section>
</div>
<footer className="export-action-bar">
<span className={`export-ready-dot ${status === 'completed' ? 'completed' : ''}`} />
<span>
{status === 'running'
? '正在后台导出'
: status === 'completed'
? '导出完成'
: '准备就绪'}
</span>
<span className="export-target-summary">{targetPath}</span>
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
</button>
<button
type="button"
className="export-primary-button"
disabled={!activeContact || status === 'running'}
onClick={handleStart}
>
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
</button>
</footer>
</main>
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
{status === 'idle' && (
<>
<div className="export-preview-heading">
<strong></strong>
<span> 20 </span>
</div>
<div className="export-message-preview">
<div className="export-preview-date"></div>
{(preview.length
? preview
: [
{
id: 'empty',
from: 'user',
content: '导出预览将在这里显示',
type: '文字',
datetime: '',
isSender: false
}
]
).map((message) => (
<div
key={message.id}
className={`export-preview-message ${message.isSender ? 'mine' : ''}`}
>
<span className="export-preview-avatar">
{message.img || (message.isSender && selfInfo?.avatar) ? (
<img src={message.isSender ? selfInfo?.avatar : message.img} alt="" />
) : (
(message.isSender ? '我' : message.name || '友').slice(0, 1)
)}
</span>
<span className="export-preview-bubble">
<small>
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
{formatPreviewTime(message)}
</small>
{message.content || `[${message.type}]`}
</span>
</div>
))}
</div>
<div className="export-preview-stats">
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
</>
)}
{status === 'running' && (
<div className="export-job-state">
<h2></h2>
<p></p>
<ol>
<li className="done"></li>
<li className="current">
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
</li>
<li></li>
<li></li>
<li></li>
</ol>
<div className="export-progress-bar" aria-label="导出进度">
<span style={{ width: `${progress?.percent ?? 0}%` }} />
</div>
<strong>
{progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong>
<button
type="button"
className="export-cancel-button"
onClick={() => {
void window.api.cancelExport(jobId)
setStatus('idle')
}}
>
</button>
</div>
)}
{status === 'completed' && (
<div className="export-job-state completed">
<div className="export-success-icon"></div>
<h2></h2>
<p></p>
<div className="export-complete-summary">
<span>
<strong>{progress?.processed.toLocaleString() || '已完成'}</strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
<button
type="button"
className="export-primary-button"
onClick={() =>
progress?.outputPath && void window.api.revealExport(progress.outputPath)
}
>
</button>
<button
type="button"
className="export-open-folder-button"
onClick={() =>
progress?.outputPath && void window.api.revealExport(progress.outputPath)
}
>
</button>
</div>
)}
</aside>
</div>
)
}
+56
View File
@@ -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<string, string>
nameMode?: ExportNameMode
nameMap?: Record<string, string>
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<ExportResult>
cancelExport: (jobId: string) => Promise<{ success: boolean }>
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
}
export type ExportMessage = Message
+9
View File
@@ -4,6 +4,8 @@ export interface Contact {
md5: string md5: string
type: 'user' | 'group' type: 'user' | 'group'
avatar?: string avatar?: string
wechatNickname?: string
remark?: string
} }
export interface Message { export interface Message {
@@ -26,6 +28,11 @@ export interface Message {
recalled?: boolean recalled?: boolean
recalledBy?: string recalledBy?: string
recoveredFromRecallJournal?: boolean recoveredFromRecallJournal?: boolean
exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker'
exportShowAvatar?: boolean
exportMediaError?: string
exportAvatarUrl?: string
} }
type TextContent = { type: 'text'; content: string } type TextContent = { type: 'text'; content: string }
@@ -79,6 +86,8 @@ type QuoteContent = {
quotedContent?: string quotedContent?: string
quotedSender?: string quotedSender?: string
quotedType?: string quotedType?: string
quotedImageMd5?: string
quotedImageDatName?: string
} }
type SystemContent = { type SystemContent = {
type: 'system' type: 'system'