mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 支持多聊天合并导出
This commit is contained in:
@@ -39,8 +39,16 @@ body {
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 8px 24px #29483b12;
|
||||
}
|
||||
.archive-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.title { font-size: 18px; font-weight: 750; }
|
||||
.meta { color: var(--muted); margin-left: 12px; font-size: 13px; }
|
||||
.archive-heading .meta { margin-left: 0; }
|
||||
.controls { display: flex; gap: 8px; align-items: center; justify-content: flex-end; }
|
||||
.controls input, .filter-button {
|
||||
border: 1px solid var(--border);
|
||||
@@ -63,13 +71,66 @@ body {
|
||||
.count { margin-left: auto; color: var(--muted); font-size: 13px; }
|
||||
.archive-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
grid-template-columns: 168px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.archive-layout.single-conversation { grid-template-columns: 168px minmax(0, 1fr); }
|
||||
.archive-navigation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.conversation-filter {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 1 320px;
|
||||
min-width: 210px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.conversation-filter[hidden] { display: none; }
|
||||
.conversation-filter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 2px solid var(--accent);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
.conversation-select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
padding: 0 42px 0 14px;
|
||||
border: 1px solid #b8cec5;
|
||||
border-radius: 10px;
|
||||
outline: 0;
|
||||
background: #f7fbf9;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px #29483b12;
|
||||
transition: border-color .15s ease, background .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.conversation-select:hover { border-color: #70a392; background: #fff; }
|
||||
.conversation-select:focus {
|
||||
border-color: var(--accent);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px #176b571c;
|
||||
}
|
||||
.timeline {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #f7faf8;
|
||||
border: 1px solid var(--border);
|
||||
@@ -133,6 +194,14 @@ body {
|
||||
}
|
||||
.message.system .sender { display: none; }
|
||||
.time { color: var(--muted); font-size: 11px; margin: 0 12px; }
|
||||
.conversation-source {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.row { display: flex; gap: 12px; align-items: flex-end; max-width: 100%; }
|
||||
.sent .row { flex-direction: row-reverse; }
|
||||
.avatar {
|
||||
@@ -249,9 +318,19 @@ body {
|
||||
.controls input[type=search] { width: 100%; }
|
||||
.filters { grid-column: 1; }
|
||||
.count { width: 100%; margin-left: 0; }
|
||||
.archive-layout { grid-template-columns: 1fr; margin-top: 10px; }
|
||||
.timeline { display: flex; gap: 6px; overflow: auto; padding: 8px; }
|
||||
.timeline-year { display: none; }
|
||||
.archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; }
|
||||
.archive-layout.single-conversation { grid-template-columns: 1fr; }
|
||||
.archive-navigation { align-self: start; gap: 8px; }
|
||||
.timeline { align-self: start; display: flex; gap: 6px; overflow: auto; padding: 8px; }
|
||||
.archive-heading { align-items: flex-start; }
|
||||
.conversation-filter { flex-basis: 100%; width: 100%; }
|
||||
.timeline-year {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin: 0 2px 0 0;
|
||||
padding: 7px 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timeline-month {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
@@ -281,8 +360,24 @@ const renderExportScript = (name: string): string => `
|
||||
const PAGE_SIZE = ${EXPORT_PAGE_SIZE}
|
||||
const WINDOW_STEP = Math.floor(PAGE_SIZE / 2)
|
||||
const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] }
|
||||
const allMessages = Array.isArray(archive.messages) ? archive.messages : []
|
||||
const legacyConversation = archive.sourceId
|
||||
? [{ id: archive.sourceId, name: archive.name || ${JSON.stringify(name)}, type: 'user', messageCount: 0 }]
|
||||
: []
|
||||
const conversations = Array.isArray(archive.conversations) && archive.conversations.length
|
||||
? archive.conversations
|
||||
: legacyConversation
|
||||
const allMessages = (Array.isArray(archive.messages) ? archive.messages : []).map((message) =>
|
||||
message.exportConversationId || conversations.length !== 1
|
||||
? message
|
||||
: Object.assign({}, message, {
|
||||
exportConversationId: conversations[0].id,
|
||||
exportConversationName: conversations[0].name
|
||||
})
|
||||
)
|
||||
const list = document.querySelector('#messages')
|
||||
const layout = document.querySelector('.archive-layout')
|
||||
const conversationFilter = document.querySelector('#conversation-filter')
|
||||
const conversationSelect = document.querySelector('#conversation-select')
|
||||
const timeline = document.querySelector('#timeline')
|
||||
const query = document.querySelector('#query')
|
||||
const count = document.querySelector('#count')
|
||||
@@ -293,6 +388,7 @@ const renderExportScript = (name: string): string => `
|
||||
const preview = document.querySelector('#lightbox-image')
|
||||
const closeButton = document.querySelector('#lightbox-close')
|
||||
let activeKind = 'all'
|
||||
let activeConversation = 'all'
|
||||
let filtered = []
|
||||
let windowStart = 0
|
||||
let windowEnd = 0
|
||||
@@ -345,6 +441,7 @@ const renderExportScript = (name: string): string => `
|
||||
return 'text'
|
||||
}
|
||||
const searchText = (message) => [
|
||||
message.exportConversationName,
|
||||
message.name,
|
||||
message.senderId,
|
||||
message.content,
|
||||
@@ -389,14 +486,42 @@ const renderExportScript = (name: string): string => `
|
||||
: avatarFallback) + '</div>'
|
||||
const text = message.content || (data.type === 'quote' ? data.title : '')
|
||||
const content = esc(text || (!media && !audio && !quote ? '[' + (message.type || '消息') + ']' : ''))
|
||||
const source = conversations.length > 1 && activeConversation === 'all'
|
||||
? '<span class="conversation-source">' + esc(message.exportConversationName || '聊天') + '</span>'
|
||||
: ''
|
||||
return '<article class="message' + (message.isSender ? ' sent' : '') + (isSystem ? ' system' : '') +
|
||||
'" data-index="' + archiveIndex + '" data-month="' + esc(monthKey(message)) + '">' +
|
||||
'<div class="time">' + esc(fullTime(message)) + '</div><div class="row">' +
|
||||
'<div class="time">' + esc(fullTime(message)) + source + '</div><div class="row">' +
|
||||
(isSystem ? '' : avatar) + '<div class="bubble"><div class="sender">' +
|
||||
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote +
|
||||
'<div class="content">' + content + '</div>' + mediaStatus + '</div></div></article>'
|
||||
}
|
||||
|
||||
const renderConversations = () => {
|
||||
if (conversations.length <= 1) {
|
||||
conversationFilter.hidden = true
|
||||
title.hidden = false
|
||||
layout.classList.add('single-conversation')
|
||||
return
|
||||
}
|
||||
const counts = new Map()
|
||||
for (const message of allMessages) {
|
||||
const id = message.exportConversationId || ''
|
||||
counts.set(id, (counts.get(id) || 0) + 1)
|
||||
}
|
||||
const option = (id, label, total) =>
|
||||
'<option value="' + esc(id) + '">' + esc(label) + '(' + total + ')</option>'
|
||||
title.hidden = true
|
||||
conversationFilter.hidden = false
|
||||
conversationSelect.innerHTML = option('all', '全部聊天', allMessages.length) +
|
||||
conversations.map((conversation) => option(
|
||||
conversation.id,
|
||||
conversation.name,
|
||||
counts.get(conversation.id) || 0
|
||||
)).join('')
|
||||
conversationSelect.value = activeConversation
|
||||
}
|
||||
|
||||
const renderTimeline = () => {
|
||||
if (filtered.length === 0) {
|
||||
timeline.innerHTML = '<div class="timeline-empty">没有可跳转的月份</div>'
|
||||
@@ -431,7 +556,11 @@ const renderExportScript = (name: string): string => `
|
||||
}
|
||||
const updateCount = () => {
|
||||
const shown = Math.max(0, windowEnd - windowStart)
|
||||
count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / 全部 ' + allMessages.length
|
||||
const scopeTotal = activeConversation === 'all'
|
||||
? allMessages.length
|
||||
: allMessages.filter((message) => message.exportConversationId === activeConversation).length
|
||||
const scopeLabel = activeConversation === 'all' ? '全部' : '当前聊天'
|
||||
count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / ' + scopeLabel + ' ' + scopeTotal
|
||||
}
|
||||
const setScrollTop = (value) => {
|
||||
scrollLoadSuppressed = true
|
||||
@@ -472,6 +601,7 @@ const renderExportScript = (name: string): string => `
|
||||
const applyFilters = () => {
|
||||
const term = query.value.trim().toLowerCase()
|
||||
filtered = allMessages.filter((message) =>
|
||||
(activeConversation === 'all' || message.exportConversationId === activeConversation) &&
|
||||
(activeKind === 'all' || kindOf(message) === activeKind) &&
|
||||
(!term || searchText(message).includes(term))
|
||||
)
|
||||
@@ -540,6 +670,10 @@ const renderExportScript = (name: string): string => `
|
||||
filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button))
|
||||
applyFilters()
|
||||
})
|
||||
conversationSelect.addEventListener('change', () => {
|
||||
activeConversation = conversationSelect.value
|
||||
applyFilters()
|
||||
})
|
||||
timeline.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-month]')
|
||||
if (button) jumpToMonth(button.dataset.month)
|
||||
@@ -577,10 +711,12 @@ const renderExportScript = (name: string): string => `
|
||||
})
|
||||
|
||||
title.textContent = archive.name || ${JSON.stringify(name)}
|
||||
meta.textContent = allMessages.length.toLocaleString() + ' 条消息 · 更新于 ' +
|
||||
(archive.exportedAt
|
||||
? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false })
|
||||
: '未知时间')
|
||||
const updatedAt = archive.exportedAt
|
||||
? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false })
|
||||
: '未知时间'
|
||||
meta.textContent = (conversations.length > 1 ? '' : allMessages.length.toLocaleString() + ' 条消息 · ') +
|
||||
'更新于 ' + updatedAt
|
||||
renderConversations()
|
||||
applyFilters()
|
||||
})()
|
||||
`
|
||||
@@ -597,8 +733,11 @@ export function renderExportPage(name: string): string {
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="toolbar">
|
||||
<div>
|
||||
<div class="archive-heading">
|
||||
<span class="title" id="archive-title">${safe(name)}</span>
|
||||
<label class="conversation-filter" id="conversation-filter" hidden>
|
||||
<select class="conversation-select" id="conversation-select" aria-label="筛选聊天"></select>
|
||||
</label>
|
||||
<span class="meta" id="archive-meta">正在读取消息…</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
@@ -616,7 +755,9 @@ export function renderExportPage(name: string): string {
|
||||
</div>
|
||||
</header>
|
||||
<section class="archive-layout">
|
||||
<nav class="timeline" id="timeline" aria-label="聊天时间轴"></nav>
|
||||
<aside class="archive-navigation">
|
||||
<nav class="timeline" id="timeline" aria-label="聊天时间轴"></nav>
|
||||
</aside>
|
||||
<section class="scroll" id="messages">
|
||||
<div class="empty">正在加载聊天档案…</div>
|
||||
</section>
|
||||
|
||||
+277
-44
@@ -1,14 +1,16 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
import { promises as fs } from 'fs'
|
||||
import { createWriteStream, promises as fs } from 'fs'
|
||||
import { extname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { ZipArchive, type Archiver } from 'archiver'
|
||||
import * as chat from './services/chat-service'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportRequest,
|
||||
ExportResult
|
||||
ExportResult,
|
||||
ExportTarget
|
||||
} from '../shared/export'
|
||||
import type { Message } from '../shared/types'
|
||||
import { VoiceService } from './voice-service'
|
||||
@@ -22,8 +24,18 @@ import { FileAssetService } from './file-asset-service'
|
||||
import { mergeCachedSelfInfo } from './services/bootstrap-cache'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const activeArchives = new Map<string, Archiver>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
||||
try {
|
||||
await fs.chmod(destination, 0o644)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await fs.copyFile(source, destination)
|
||||
await fs.chmod(destination, 0o644)
|
||||
}
|
||||
const exportStamp = (): string => {
|
||||
const date = new Date()
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
@@ -31,7 +43,15 @@ const exportStamp = (): string => {
|
||||
}
|
||||
const imageKeys = new ImageKeyConfigService()
|
||||
|
||||
export interface HtmlExportArchive {
|
||||
export interface HtmlExportConversation {
|
||||
id: string
|
||||
name: string
|
||||
type: 'user' | 'group'
|
||||
avatarUrl?: string
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
interface HtmlExportArchiveV1 {
|
||||
version: 1
|
||||
sourceId: string
|
||||
name: string
|
||||
@@ -39,20 +59,29 @@ export interface HtmlExportArchive {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
export interface HtmlExportArchive {
|
||||
version: 2
|
||||
name: string
|
||||
exportedAt: string
|
||||
conversations: HtmlExportConversation[]
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = '
|
||||
const hashPart = (value: string, length = 16): string =>
|
||||
createHash('sha1').update(value).digest('hex').slice(0, length)
|
||||
|
||||
export const exportMessageKey = (message: Message, sourceId = ''): string => {
|
||||
const conversationId = message.exportConversationId || sourceId
|
||||
const sessionId = message.sessionId || sourceId
|
||||
if (message.localId && message.createTime) {
|
||||
return `${sessionId}:local:${message.localId}:${message.createTime}`
|
||||
return `${conversationId}:${sessionId}:local:${message.localId}:${message.createTime}`
|
||||
}
|
||||
if (message.serverId) return `${sessionId}:server:${message.serverId}`
|
||||
if (message.serverId) return `${conversationId}:${sessionId}:server:${message.serverId}`
|
||||
if (message.id && message.createTime && !/^0\.\d+$/.test(message.id)) {
|
||||
return `${sessionId}:id:${message.id}:${message.createTime}`
|
||||
return `${conversationId}:${sessionId}:id:${message.id}:${message.createTime}`
|
||||
}
|
||||
return `${sessionId}:fallback:${hashPart(
|
||||
return `${conversationId}:${sessionId}:fallback:${hashPart(
|
||||
JSON.stringify([
|
||||
message.createTime || 0,
|
||||
message.senderId || '',
|
||||
@@ -89,7 +118,8 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
|
||||
export function mergeHtmlArchiveMessages(
|
||||
previous: Message[],
|
||||
current: Message[],
|
||||
sourceId = ''
|
||||
sourceId = '',
|
||||
conversationOrder: string[] = []
|
||||
): Message[] {
|
||||
const merged = new Map<string, Message>()
|
||||
for (const message of previous) merged.set(exportMessageKey(message, sourceId), message)
|
||||
@@ -101,6 +131,10 @@ export function mergeHtmlArchiveMessages(
|
||||
return Array.from(merged.values()).sort((left, right) => {
|
||||
const byTime = Number(left.createTime || 0) - Number(right.createTime || 0)
|
||||
if (byTime !== 0) return byTime
|
||||
const byConversation =
|
||||
conversationOrder.indexOf(left.exportConversationId || sourceId) -
|
||||
conversationOrder.indexOf(right.exportConversationId || sourceId)
|
||||
if (byConversation !== 0) return byConversation
|
||||
return Number(left.localId || 0) - Number(right.localId || 0)
|
||||
})
|
||||
}
|
||||
@@ -127,7 +161,7 @@ export function normalizeHtmlArchiveSelfNames(
|
||||
|
||||
export async function readHtmlArchive(
|
||||
outputDir: string,
|
||||
sourceId: string,
|
||||
targets: ExportTarget[],
|
||||
name: string
|
||||
): Promise<HtmlExportArchive> {
|
||||
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||
@@ -136,7 +170,18 @@ export async function readHtmlArchive(
|
||||
source = await fs.readFile(dataPath, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { version: 1, sourceId, name, exportedAt: new Date(0).toISOString(), messages: [] }
|
||||
return {
|
||||
version: 2,
|
||||
name,
|
||||
exportedAt: new Date(0).toISOString(),
|
||||
conversations: targets.map((target) => ({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
messageCount: 0
|
||||
})),
|
||||
messages: []
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -146,21 +191,100 @@ export async function readHtmlArchive(
|
||||
.slice(assignment + 1)
|
||||
.trim()
|
||||
.replace(/;\s*$/, '')
|
||||
let archive: HtmlExportArchive
|
||||
let parsed: HtmlExportArchive | HtmlExportArchiveV1
|
||||
try {
|
||||
archive = JSON.parse(json) as HtmlExportArchive
|
||||
parsed = JSON.parse(json) as HtmlExportArchive | HtmlExportArchiveV1
|
||||
} catch {
|
||||
throw new Error('现有 HTML 档案数据已损坏,请从 messages.js.bak 恢复或更换导出名称')
|
||||
}
|
||||
if (archive.sourceId && archive.sourceId !== sourceId) {
|
||||
throw new Error('同名导出目录已属于另一个会话,请修改文件名称后重试')
|
||||
const expectedIds = targets.map((target) => target.userMd5).sort()
|
||||
if (parsed.version === 1) {
|
||||
if (targets.length !== 1 || parsed.sourceId !== targets[0].userMd5) {
|
||||
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
name: parsed.name || name,
|
||||
exportedAt: parsed.exportedAt || new Date(0).toISOString(),
|
||||
conversations: [
|
||||
{
|
||||
id: targets[0].userMd5,
|
||||
name: targets[0].name,
|
||||
type: targets[0].type,
|
||||
messageCount: Array.isArray(parsed.messages) ? parsed.messages.length : 0
|
||||
}
|
||||
],
|
||||
messages: (Array.isArray(parsed.messages) ? parsed.messages : []).map((message) => ({
|
||||
...message,
|
||||
exportConversationId: targets[0].userMd5,
|
||||
exportConversationName: targets[0].name
|
||||
}))
|
||||
}
|
||||
}
|
||||
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
|
||||
.map((conversation) => conversation.id)
|
||||
.sort()
|
||||
if (actualIds.join('|') !== expectedIds.join('|')) {
|
||||
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
sourceId,
|
||||
name: archive.name || name,
|
||||
exportedAt: archive.exportedAt || new Date(0).toISOString(),
|
||||
messages: Array.isArray(archive.messages) ? archive.messages : []
|
||||
version: 2,
|
||||
name: parsed.name || name,
|
||||
exportedAt: parsed.exportedAt || new Date(0).toISOString(),
|
||||
conversations: parsed.conversations,
|
||||
messages: Array.isArray(parsed.messages) ? parsed.messages : []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeZipArchive(
|
||||
outputDir: string,
|
||||
zipPath: string,
|
||||
folderName: string,
|
||||
jobId: string
|
||||
): Promise<void> {
|
||||
const temporaryPath = `${zipPath}.tmp-${process.pid}-${Date.now()}`
|
||||
await fs.rm(temporaryPath, { force: true })
|
||||
const output = createWriteStream(temporaryPath)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
output.once('open', () => resolve())
|
||||
output.once('error', reject)
|
||||
})
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } })
|
||||
activeArchives.set(jobId, archive)
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
output.on('close', () => finish())
|
||||
output.on('error', finish)
|
||||
archive.on('warning', (error) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') finish(error)
|
||||
})
|
||||
archive.on('error', finish)
|
||||
archive.pipe(output)
|
||||
archive.directory(outputDir, safeFilePart(folderName))
|
||||
void archive.finalize().catch(finish)
|
||||
})
|
||||
if (!jobs.has(jobId)) throw new Error('已取消')
|
||||
try {
|
||||
await fs.rename(temporaryPath, zipPath)
|
||||
} catch (error) {
|
||||
if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error
|
||||
await fs.rm(zipPath, { force: true })
|
||||
await fs.rename(temporaryPath, zipPath)
|
||||
}
|
||||
} finally {
|
||||
activeArchives.delete(jobId)
|
||||
if (!output.closed) {
|
||||
output.destroy()
|
||||
await new Promise<void>((resolve) => output.once('close', () => resolve()))
|
||||
}
|
||||
await fs.rm(temporaryPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,11 +422,57 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||
}
|
||||
try {
|
||||
const targets = request.targets || []
|
||||
if (targets.length < 1 || targets.length > 5) {
|
||||
throw new Error('一次导出必须选择 1 到 5 个聊天')
|
||||
}
|
||||
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
|
||||
throw new Error('导出聊天不能重复')
|
||||
}
|
||||
if (targets.length > 1 && request.format !== 'html') {
|
||||
throw new Error('多聊天合并仅支持 HTML 格式')
|
||||
}
|
||||
const archiveName =
|
||||
targets.length > 1 ? `${targets[0].name} 等 ${targets.length} 个聊天` : targets[0].name
|
||||
const targetById = new Map(targets.map((target) => [target.userMd5, target]))
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
const messages = (
|
||||
await chat.listMessagesAsync(request.userMd5, request.startTime, request.endTime)
|
||||
).filter((message) => request.kinds.includes(kindOf(message)))
|
||||
const messageEntries: { message: Message; targetOrder: number; messageOrder: number }[] = []
|
||||
for (const [targetOrder, target] of targets.entries()) {
|
||||
if (!jobs.has(request.jobId)) {
|
||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
const targetMessages = (
|
||||
await chat.listMessagesAsync(target.userMd5, request.startTime, request.endTime)
|
||||
).filter((message) => request.kinds.includes(kindOf(message)))
|
||||
for (const [messageOrder, message] of targetMessages.entries()) {
|
||||
messageEntries.push({
|
||||
message: {
|
||||
...message,
|
||||
exportConversationId: target.userMd5,
|
||||
exportConversationName: target.name
|
||||
},
|
||||
targetOrder,
|
||||
messageOrder
|
||||
})
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'reading',
|
||||
processed: targetOrder + 1,
|
||||
total: targets.length,
|
||||
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
|
||||
})
|
||||
}
|
||||
const messages = messageEntries
|
||||
.sort((left, right) => {
|
||||
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
|
||||
if (byTime !== 0) return byTime
|
||||
if (left.targetOrder !== right.targetOrder) return left.targetOrder - right.targetOrder
|
||||
return left.messageOrder - right.messageOrder
|
||||
})
|
||||
.map((entry) => entry.message)
|
||||
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
||||
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
||||
const isUsableSelfName = (value: string | undefined): value is string => {
|
||||
@@ -313,13 +483,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
return true
|
||||
}
|
||||
for (const message of messages) {
|
||||
const target = targetById.get(message.exportConversationId || '')
|
||||
message.exportMediaUrl = undefined
|
||||
message.exportMediaType = undefined
|
||||
message.exportMediaName = undefined
|
||||
message.exportMediaError = undefined
|
||||
message.voiceDataUrl = undefined
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||
const mappedName = message.senderId ? target?.nameMap?.[message.senderId] : undefined
|
||||
if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) {
|
||||
message.name = mappedName
|
||||
} else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) {
|
||||
@@ -357,7 +528,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
? join(outputDir, 'index.html')
|
||||
: join(root, `${outputFolder}.${ext}`)
|
||||
if (request.format === 'html') {
|
||||
const previousArchive = await readHtmlArchive(outputDir, request.userMd5, request.name)
|
||||
const previousArchive = await readHtmlArchive(outputDir, targets, archiveName)
|
||||
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
|
||||
@@ -369,10 +540,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
const requestedAvatarUrls = Object.assign(
|
||||
{},
|
||||
...targets.map((target) => target.avatarUrls || {})
|
||||
) as Record<string, string>
|
||||
const avatarMap =
|
||||
request.includeAvatars === false
|
||||
? {}
|
||||
: { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) }
|
||||
: { ...(await chat.getContactAvatars(avatarUsernames)), ...requestedAvatarUrls }
|
||||
const imageConfig = imageKeys.getConfig()
|
||||
const imageService =
|
||||
client && imageConfig.aesKey
|
||||
@@ -382,13 +557,27 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
const stickerService = client ? new StickerService(client) : null
|
||||
const fileService = client ? new FileAssetService(client) : null
|
||||
const exportedAvatars = new Map<string, string>()
|
||||
const conversationAvatarUrls = new Map<string, string>()
|
||||
if (request.includeAvatars !== false) {
|
||||
for (const target of targets) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
if (!target.avatarUrl) continue
|
||||
const resolved = await readAvatarAsset(target.avatarUrl)
|
||||
if (!resolved) continue
|
||||
const avatarName = `conversation_${hashPart(target.userMd5)}.${resolved.extension}`
|
||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
|
||||
conversationAvatarUrls.set(target.userMd5, `avatars/${avatarName}`)
|
||||
}
|
||||
}
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||
: null
|
||||
if (voiceService) {
|
||||
for (const message of messages) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
if (kindOf(message) !== 'voice') continue
|
||||
const conversationId = message.exportConversationId || targets[0].userMd5
|
||||
if (!message.sessionId || message.localId == null || !message.createTime) {
|
||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||
continue
|
||||
@@ -410,7 +599,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
keepMediaError(request, message, reason)
|
||||
continue
|
||||
}
|
||||
const voiceName = `voice_${hashPart(exportMessageKey(message, request.userMd5))}.wav`
|
||||
const voiceName = `voice_${hashPart(exportMessageKey(message, conversationId))}.wav`
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
message.voiceDataUrl = `voices/${voiceName}`
|
||||
@@ -431,14 +620,26 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
}
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (!jobs.has(request.jobId)) {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'cancelled',
|
||||
processed: index,
|
||||
total: messages.length,
|
||||
percent: 15 + Math.round((index / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
const conversationId = message.exportConversationId || targets[0].userMd5
|
||||
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 || avatar || `message_${exportMessageKey(message, request.userMd5)}`
|
||||
const avatarKey = `${conversationId}:${
|
||||
message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}`
|
||||
}`
|
||||
let avatarName = exportedAvatars.get(avatarKey)
|
||||
if (!avatarName) {
|
||||
avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}`
|
||||
@@ -472,7 +673,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
allowThumbnail: attempt.allowThumbnail,
|
||||
preferThumbnail: attempt.preferThumbnail,
|
||||
sessionId: message.sessionId,
|
||||
sessionMd5: request.userMd5,
|
||||
sessionMd5: conversationId,
|
||||
createTime: message.createTime
|
||||
}
|
||||
)
|
||||
@@ -497,7 +698,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
}
|
||||
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
||||
if (decoded) {
|
||||
const name = `image_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}`
|
||||
const name = `image_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}`
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'image'
|
||||
@@ -534,8 +735,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
} else if (extname(source).toLowerCase() !== '.mp4') {
|
||||
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
|
||||
} else {
|
||||
const name = `video_${hashPart(exportMessageKey(message, request.userMd5))}.mp4`
|
||||
await fs.copyFile(source, join(outputDir, 'media', name))
|
||||
const name = `video_${hashPart(exportMessageKey(message, conversationId))}.mp4`
|
||||
await copyWritableExportFile(source, join(outputDir, 'media', name))
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'video'
|
||||
}
|
||||
@@ -549,7 +750,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
? await readAvatarAsset(stickerSource)
|
||||
: null
|
||||
if (decoded) {
|
||||
const name = `sticker_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}`
|
||||
const name = `sticker_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}`
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'sticker'
|
||||
@@ -564,8 +765,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
if (!resolved.success || !resolved.filePath || !resolved.fileName) {
|
||||
keepMediaError(request, message, resolved.error || '本地文件附件缺失')
|
||||
} else {
|
||||
const name = `file_${hashPart(exportMessageKey(message, request.userMd5))}_${safeFilePart(resolved.fileName)}`
|
||||
await fs.copyFile(resolved.filePath, join(outputDir, 'media', name))
|
||||
const name = `file_${hashPart(exportMessageKey(message, conversationId))}_${safeFilePart(resolved.fileName)}`
|
||||
await copyWritableExportFile(resolved.filePath, join(outputDir, 'media', name))
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'file'
|
||||
message.exportMediaName = message.contentData.title || resolved.fileName
|
||||
@@ -583,26 +784,53 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
const mergedMessages = mergeHtmlArchiveMessages(
|
||||
previousArchive.messages,
|
||||
messages,
|
||||
request.userMd5
|
||||
'',
|
||||
targets.map((target) => target.userMd5)
|
||||
)
|
||||
const normalizedMessages = normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
|
||||
const archive: HtmlExportArchive = {
|
||||
version: 1,
|
||||
sourceId: request.userMd5,
|
||||
name: request.name,
|
||||
version: 2,
|
||||
name: archiveName,
|
||||
exportedAt: new Date().toISOString(),
|
||||
messages: normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
|
||||
conversations: targets.map((target) => ({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
avatarUrl:
|
||||
conversationAvatarUrls.get(target.userMd5) ||
|
||||
previousArchive.conversations.find((item) => item.id === target.userMd5)?.avatarUrl,
|
||||
messageCount: normalizedMessages.filter(
|
||||
(message) => message.exportConversationId === target.userMd5
|
||||
).length
|
||||
})),
|
||||
messages: normalizedMessages
|
||||
}
|
||||
await fs.writeFile(outputPath, renderExportPage(request.name), 'utf8')
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8')
|
||||
await writeHtmlArchive(outputDir, archive)
|
||||
let completedPath = outputPath
|
||||
if (request.zip) {
|
||||
if (!jobs.has(request.jobId)) return { success: false, error: '已取消' }
|
||||
const zipPath = join(root, `${outputFolder}.zip`)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'compressing',
|
||||
processed: archive.messages.length,
|
||||
total: archive.messages.length,
|
||||
percent: 95
|
||||
})
|
||||
await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId)
|
||||
completedPath = zipPath
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
processed: archive.messages.length,
|
||||
total: archive.messages.length,
|
||||
percent: 100,
|
||||
outputPath
|
||||
outputPath: completedPath
|
||||
})
|
||||
return { success: true, outputPath, messageCount: archive.messages.length }
|
||||
return { success: true, outputPath: completedPath, messageCount: archive.messages.length }
|
||||
} else {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
@@ -612,7 +840,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
percent: 90
|
||||
})
|
||||
}
|
||||
await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8')
|
||||
await fs.writeFile(outputPath, render(request.format, messages, archiveName), 'utf8')
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
@@ -624,6 +852,10 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
return { success: true, outputPath, messageCount: messages.length }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!jobs.has(request.jobId) || message === '已取消') {
|
||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, error: '已取消' })
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
@@ -632,6 +864,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
}
|
||||
export function cancelExport(jobId: string): void {
|
||||
jobs.delete(jobId)
|
||||
activeArchives.get(jobId)?.abort()
|
||||
}
|
||||
export async function revealExport(path: string): Promise<void> {
|
||||
shell.showItemInFolder(path)
|
||||
|
||||
+37
-12
@@ -212,7 +212,32 @@ function App(): React.ReactElement {
|
||||
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]')
|
||||
return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : []
|
||||
if (!Array.isArray(stored)) return []
|
||||
return stored.slice(0, 20).map(
|
||||
(
|
||||
value: Partial<ExportTaskRecord> & {
|
||||
contactId?: string
|
||||
contactName?: string
|
||||
}
|
||||
) => {
|
||||
const targetIds = Array.isArray(value.targetIds)
|
||||
? value.targetIds
|
||||
: value.contactId
|
||||
? [value.contactId]
|
||||
: []
|
||||
const targetNames = Array.isArray(value.targetNames)
|
||||
? value.targetNames
|
||||
: value.contactName
|
||||
? [value.contactName]
|
||||
: []
|
||||
return {
|
||||
...value,
|
||||
targetIds,
|
||||
targetNames,
|
||||
targetLabel: value.targetLabel || targetNames.join('、') || '聊天导出'
|
||||
} as ExportTaskRecord
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -341,10 +366,14 @@ function App(): React.ReactElement {
|
||||
const handleStartExport = async (
|
||||
request: ExportRequest
|
||||
): Promise<import('../../shared/export').ExportResult> => {
|
||||
const targetNames = request.targets.map((target) => target.name)
|
||||
const targetLabel =
|
||||
targetNames.length > 1 ? `${targetNames[0]} 等 ${targetNames.length} 个聊天` : targetNames[0]
|
||||
const task: ExportTaskRecord = {
|
||||
jobId: request.jobId,
|
||||
contactId: request.userMd5,
|
||||
contactName: request.name,
|
||||
targetIds: request.targets.map((target) => target.userMd5),
|
||||
targetNames,
|
||||
targetLabel,
|
||||
format: request.format,
|
||||
status: 'running',
|
||||
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
|
||||
@@ -1220,16 +1249,14 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const loadExportPreview = async (contact: Contact): Promise<void> => {
|
||||
setSelectedContact(contact)
|
||||
selectedContactMd5Ref.current = contact.md5
|
||||
const loadExportPreviewMessages = async (contact: Contact): Promise<Message[]> => {
|
||||
try {
|
||||
const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||
return await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||
limit: EXPORT_PREVIEW_LIMIT
|
||||
})
|
||||
if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages)
|
||||
} catch (error) {
|
||||
console.warn('[Export] preview load failed:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1322,7 +1349,6 @@ function App(): React.ReactElement {
|
||||
const handlePageChange = (page: AppPage): void => {
|
||||
setActivePage(page)
|
||||
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
|
||||
if (page === 'export' && selectedContact) void loadExportPreview(selectedContact)
|
||||
if (page === 'settings') setSettingsCategory('account-database')
|
||||
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
||||
setReportSourceContact(selectedContact)
|
||||
@@ -1702,11 +1728,10 @@ function App(): React.ReactElement {
|
||||
return (
|
||||
<ExportWorkspace
|
||||
contacts={contacts}
|
||||
selectedContact={selectedContact}
|
||||
previewMessages={messages}
|
||||
initialContact={selectedContact}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
onSelectContact={loadExportPreview}
|
||||
loadPreviewMessages={loadExportPreviewMessages}
|
||||
onOpenSettings={openSettings}
|
||||
exportTasks={exportTasks}
|
||||
onStartExport={handleStartExport}
|
||||
|
||||
@@ -6,6 +6,9 @@ interface ExportContactPanelProps {
|
||||
contacts: Contact[]
|
||||
filteredContacts: Contact[]
|
||||
activeContact: Contact | null
|
||||
selectedContactIds: string[]
|
||||
selectionMode: boolean
|
||||
selectionLimit: number
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
contactFilter: string
|
||||
@@ -13,6 +16,7 @@ interface ExportContactPanelProps {
|
||||
onContactFilterChange: (value: string) => void
|
||||
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
|
||||
onSelectContact: (contact: Contact) => void
|
||||
onCompleteSelection: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
@@ -20,6 +24,9 @@ export function ExportContactPanel({
|
||||
contacts,
|
||||
filteredContacts,
|
||||
activeContact,
|
||||
selectedContactIds,
|
||||
selectionMode,
|
||||
selectionLimit,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
contactFilter,
|
||||
@@ -27,6 +34,7 @@ export function ExportContactPanel({
|
||||
onContactFilterChange,
|
||||
onContactTypeChange,
|
||||
onSelectContact,
|
||||
onCompleteSelection,
|
||||
onOpenSettings
|
||||
}: ExportContactPanelProps): React.ReactElement {
|
||||
return (
|
||||
@@ -65,15 +73,30 @@ export function ExportContactPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectionMode && (
|
||||
<div className="export-multi-select-bar">
|
||||
<span>
|
||||
已选 {selectedContactIds.length} / {selectionLimit} 个
|
||||
</span>
|
||||
<button type="button" onClick={onCompleteSelection}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="export-contact-list">
|
||||
{filteredContacts.map((contact) => {
|
||||
const name = displayName(contact)
|
||||
const selected = selectedContactIds.includes(contact.md5)
|
||||
const atLimit = selectionMode && !selected && selectedContactIds.length >= selectionLimit
|
||||
return (
|
||||
<button
|
||||
key={contact.md5}
|
||||
type="button"
|
||||
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
|
||||
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
disabled={atLimit}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span className="export-contact-avatar">
|
||||
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
||||
@@ -82,6 +105,11 @@ export function ExportContactPanel({
|
||||
<strong>{name}</strong>
|
||||
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||
</span>
|
||||
{selectionMode && (
|
||||
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
|
||||
{selected ? '✓' : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ExportPreviewPanelProps {
|
||||
previewBytes: number
|
||||
selfInfo: SelfInfo | null
|
||||
progress: ExportJobProgress | null
|
||||
selectedCount: number
|
||||
jobId: string
|
||||
onCancel: (jobId: string) => void
|
||||
onReveal: (path: string) => void
|
||||
@@ -22,6 +23,7 @@ export function ExportPreviewPanel({
|
||||
previewBytes,
|
||||
selfInfo,
|
||||
progress,
|
||||
selectedCount,
|
||||
jobId,
|
||||
onCancel,
|
||||
onReveal
|
||||
@@ -32,7 +34,9 @@ export function ExportPreviewPanel({
|
||||
<>
|
||||
<div className="export-preview-heading">
|
||||
<strong>导出预览</strong>
|
||||
<span>仅预览最近 20 条</span>
|
||||
<span>
|
||||
{selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="export-message-preview">
|
||||
<div className="export-preview-date">最近消息</div>
|
||||
@@ -50,7 +54,7 @@ export function ExportPreviewPanel({
|
||||
]
|
||||
).map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
key={`${message.exportConversationId || 'single'}:${message.id}`}
|
||||
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
|
||||
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
|
||||
}`}
|
||||
@@ -64,6 +68,9 @@ export function ExportPreviewPanel({
|
||||
</span>
|
||||
<span className="export-preview-bubble">
|
||||
<small>
|
||||
{selectedCount > 1 && message.exportConversationName
|
||||
? `${message.exportConversationName} · `
|
||||
: ''}
|
||||
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
|
||||
{formatPreviewTime(message)}
|
||||
</small>
|
||||
@@ -108,7 +115,11 @@ export function ExportPreviewPanel({
|
||||
<ol>
|
||||
<li className="done">准备导出</li>
|
||||
<li className="current">
|
||||
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
|
||||
{progress?.phase === 'compressing'
|
||||
? '压缩 ZIP'
|
||||
: progress?.phase === 'writing'
|
||||
? '生成档案'
|
||||
: '分批读取聊天记录'}
|
||||
</li>
|
||||
<li>解析消息内容</li>
|
||||
<li>处理媒体资源</li>
|
||||
@@ -118,9 +129,11 @@ export function ExportPreviewPanel({
|
||||
<span style={{ width: `${progress?.percent ?? 0}%` }} />
|
||||
</div>
|
||||
<strong>
|
||||
{progress?.phase === 'writing'
|
||||
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
|
||||
: `正在读取消息... ${progress?.percent ?? 0}%`}
|
||||
{progress?.phase === 'compressing'
|
||||
? `正在压缩资源包... ${progress.percent ?? 0}%`
|
||||
: progress?.phase === 'writing'
|
||||
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
|
||||
: `正在读取消息... ${progress?.percent ?? 0}%`}
|
||||
</strong>
|
||||
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
|
||||
取消导出
|
||||
|
||||
@@ -9,6 +9,25 @@ interface ExportTaskCenterProps {
|
||||
onCancel: (jobId: string) => void
|
||||
}
|
||||
|
||||
const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
|
||||
reading: '读取消息',
|
||||
writing: '导出资源',
|
||||
compressing: '压缩归档',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
failed: '导出失败'
|
||||
}
|
||||
|
||||
const taskDetail = (task: ExportTaskRecord): string | null => {
|
||||
if (task.status === 'completed') {
|
||||
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
|
||||
}
|
||||
if (task.status === 'failed') {
|
||||
return `失败原因:${task.progress.error || '未知错误'}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function ExportTaskCenter({
|
||||
open,
|
||||
taskCount,
|
||||
@@ -30,25 +49,36 @@ export function ExportTaskCenter({
|
||||
{tasks.length === 0 ? (
|
||||
<p>暂无导出记录</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<div className="export-task-row" key={task.jobId}>
|
||||
<span>
|
||||
<strong>{task.contactName}</strong>
|
||||
<small>
|
||||
{task.format.toUpperCase()} · {task.progress.phase}
|
||||
</small>
|
||||
</span>
|
||||
<span className="export-task-progress">
|
||||
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
|
||||
<b>{task.progress.percent ?? 0}%</b>
|
||||
</span>
|
||||
{task.status === 'running' && (
|
||||
<button type="button" onClick={() => onCancel(task.jobId)}>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
tasks.map((task) => {
|
||||
const detail = taskDetail(task)
|
||||
return (
|
||||
<div className="export-task-row" key={task.jobId}>
|
||||
<span>
|
||||
<strong>{task.targetLabel}</strong>
|
||||
<small>
|
||||
{task.format.toUpperCase()} · {phaseLabels[task.progress.phase]}
|
||||
</small>
|
||||
{detail && (
|
||||
<small
|
||||
className={`export-task-detail ${task.status}`}
|
||||
title={task.status === 'failed' ? detail : undefined}
|
||||
>
|
||||
{detail}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
<span className="export-task-progress">
|
||||
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
|
||||
<b>{task.progress.percent ?? 0}%</b>
|
||||
</span>
|
||||
{task.status === 'running' && (
|
||||
<button type="button" onClick={() => onCancel(task.jobId)}>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -3,12 +3,15 @@ import type { Message } from '../../../../shared/types'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportNameMode
|
||||
ExportNameMode,
|
||||
ExportRequest,
|
||||
ExportTarget
|
||||
} from '../../../../shared/export'
|
||||
import { ExportContactPanel } from './ExportContactPanel'
|
||||
import { ExportPreviewPanel } from './ExportPreviewPanel'
|
||||
import { ExportTaskCenter } from './ExportTaskCenter'
|
||||
import type {
|
||||
Contact,
|
||||
ExportFormat,
|
||||
ExportRange,
|
||||
ExportStatus,
|
||||
@@ -19,24 +22,33 @@ import { displayName, formatLabels, formatOrder, messageKinds } from './exportUt
|
||||
|
||||
export function ExportWorkspace({
|
||||
contacts,
|
||||
selectedContact,
|
||||
previewMessages,
|
||||
initialContact,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
onSelectContact,
|
||||
loadPreviewMessages,
|
||||
onOpenSettings,
|
||||
exportTasks,
|
||||
onStartExport,
|
||||
onCancelExport
|
||||
}: ExportWorkspaceProps): React.ReactElement {
|
||||
const initialSelection = initialContact || contacts[0] || null
|
||||
const initialContactRef = React.useRef<Contact | null>(initialSelection)
|
||||
const previewLoadingRef = React.useRef(new Set<string>())
|
||||
const [contactFilter, setContactFilter] = useState('')
|
||||
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
||||
const [selectionMode, setSelectionMode] = useState(false)
|
||||
const [selectedContacts, setSelectedContacts] = useState<Contact[]>(() =>
|
||||
initialSelection ? [initialSelection] : []
|
||||
)
|
||||
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
|
||||
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
|
||||
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 [nameMode, setNameMode] = useState<ExportNameMode>(
|
||||
initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
|
||||
)
|
||||
const [includeMedia, setIncludeMedia] = useState(true)
|
||||
const [includeAvatars, setIncludeAvatars] = useState(true)
|
||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||
@@ -49,6 +61,27 @@ export function ExportWorkspace({
|
||||
const [jobId, setJobId] = useState('')
|
||||
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
|
||||
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
|
||||
const selectionLimit = 5
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedContacts.length > 0) return
|
||||
const candidate = initialContact || contacts[0]
|
||||
if (!candidate) return
|
||||
initialContactRef.current = candidate
|
||||
setSelectedContacts([candidate])
|
||||
setActiveContactId(candidate.md5)
|
||||
}, [contacts, initialContact, selectedContacts.length])
|
||||
|
||||
React.useEffect(() => {
|
||||
for (const contact of selectedContacts) {
|
||||
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
|
||||
previewLoadingRef.current.add(contact.md5)
|
||||
void loadPreviewMessages(contact).then((items) => {
|
||||
previewLoadingRef.current.delete(contact.md5)
|
||||
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
|
||||
})
|
||||
}
|
||||
}, [loadPreviewMessages, previewByContact, selectedContacts])
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
const keyword = contactFilter.trim().toLowerCase()
|
||||
@@ -61,11 +94,35 @@ export function ExportWorkspace({
|
||||
})
|
||||
}, [contactFilter, contactType, contacts])
|
||||
|
||||
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
|
||||
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5)
|
||||
const activeContact =
|
||||
selectedContacts.find((contact) => contact.md5 === activeContactId) ||
|
||||
selectedContacts[0] ||
|
||||
null
|
||||
const selectedTargetKey = selectedContacts
|
||||
.map((contact) => contact.md5)
|
||||
.sort()
|
||||
.join('|')
|
||||
const currentTask = exportTasks.find(
|
||||
(task) => [...task.targetIds].sort().join('|') === selectedTargetKey
|
||||
)
|
||||
const taskCount = exportTasks.filter((task) => task.status === 'running').length
|
||||
const activeName = displayName(activeContact)
|
||||
const preview = previewMessages.slice(-20)
|
||||
const selectedNames = selectedContacts.map(displayName)
|
||||
const selectedLabel =
|
||||
selectedNames.length > 1
|
||||
? `${selectedNames.join('、')} · 共 ${selectedNames.length} 个聊天`
|
||||
: selectedNames[0] || '未选择聊天'
|
||||
const preview = selectedContacts
|
||||
.flatMap((contact) =>
|
||||
(previewByContact[contact.md5] || []).map((message) => ({
|
||||
...message,
|
||||
exportConversationId: contact.md5,
|
||||
exportConversationName: displayName(contact),
|
||||
exportConversationAvatarUrl: contact.avatar
|
||||
}))
|
||||
)
|
||||
.sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0))
|
||||
.slice(-20)
|
||||
const previewMediaCount = preview.filter(
|
||||
(message) =>
|
||||
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') ||
|
||||
@@ -75,79 +132,59 @@ export function ExportWorkspace({
|
||||
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
||||
0
|
||||
)
|
||||
const outputName = fileName.trim() || `${activeName}_聊天档案`
|
||||
const nameOptions: { value: ExportNameMode; label: string }[] =
|
||||
activeContact?.type === 'group'
|
||||
? [
|
||||
{ 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])
|
||||
const defaultOutputName =
|
||||
selectedContacts.length > 1
|
||||
? `${selectedNames[0]}等${selectedContacts.length}个聊天_合并档案`
|
||||
: `${activeName}_聊天档案`
|
||||
const outputName = fileName.trim() || defaultOutputName
|
||||
const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.some(
|
||||
(contact) => contact.type === 'group'
|
||||
)
|
||||
? [
|
||||
{ value: 'groupNickname', label: '群昵称' },
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
: [
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
|
||||
const previewName = (message: Message): string =>
|
||||
(message.senderId && nameMap[message.senderId]) ||
|
||||
message.name ||
|
||||
(message.isSender ? selfInfo?.nickname : undefined) ||
|
||||
(message.isSender ? '我' : '联系人')
|
||||
const previewAvatar = (message: Message): string | undefined =>
|
||||
(message.senderId && avatarUrls[message.senderId]) ||
|
||||
message.img ||
|
||||
(message.isSender ? selfInfo?.avatar : undefined)
|
||||
message.img || (message.isSender ? selfInfo?.avatar : undefined)
|
||||
const previewItems = preview.map((message) => ({
|
||||
...message,
|
||||
name: previewName(message),
|
||||
img: previewAvatar(message)
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||
setGroupMembers([])
|
||||
if (!activeContact || activeContact.type !== 'group') return
|
||||
const timer = window.setTimeout(() => {
|
||||
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
|
||||
setGroupMembers((snapshot?.members || []) as GroupMemberName[])
|
||||
})
|
||||
}, 300)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [activeContact])
|
||||
const handleSelectContact = (contact: Contact): void => {
|
||||
if (!selectionMode) {
|
||||
setSelectedContacts([contact])
|
||||
setActiveContactId(contact.md5)
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
const selected = selectedContacts.some((item) => item.md5 === contact.md5)
|
||||
if (selected) {
|
||||
if (selectedContacts.length === 1) return
|
||||
const next = selectedContacts.filter((item) => item.md5 !== contact.md5)
|
||||
setSelectedContacts(next)
|
||||
if (activeContactId === contact.md5) setActiveContactId(next[0].md5)
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
if (selectedContacts.length >= selectionLimit) return
|
||||
const next = [...selectedContacts, contact]
|
||||
setSelectedContacts(next)
|
||||
setActiveContactId(contact.md5)
|
||||
setFormat('html')
|
||||
setStatus('idle')
|
||||
}
|
||||
|
||||
const toggleKind = (value: string): void => {
|
||||
setSelectedKinds((current) => {
|
||||
@@ -159,42 +196,59 @@ export function ExportWorkspace({
|
||||
}
|
||||
|
||||
const handleStart = async (): Promise<void> => {
|
||||
if (!activeContact || status === 'running') return
|
||||
if (!activeContact || selectedContacts.length === 0 || status === 'running') return
|
||||
// Runs only from the export button event; a fresh id is required for each job.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const nextJobId = `export-${Date.now()}`
|
||||
setJobId(nextJobId)
|
||||
setProgress(null)
|
||||
setStatus('running')
|
||||
let exportNameMap = nameMap
|
||||
let exportAvatarUrls = avatarUrls
|
||||
if (activeContact.type === 'group') {
|
||||
const snapshot = await window.api.getGroupSnapshot(activeContact.md5)
|
||||
const members = (snapshot?.members || []) as GroupMemberName[]
|
||||
setGroupMembers(members)
|
||||
exportNameMap = { ...nameMap }
|
||||
exportAvatarUrls = { ...avatarUrls }
|
||||
for (const member of members) {
|
||||
exportNameMap[member.wxid] =
|
||||
nameMode === 'groupNickname'
|
||||
? member.groupNickname || member.nickname || member.wxid
|
||||
: nameMode === 'remark'
|
||||
? member.remark || member.wechatNickname || member.wxid
|
||||
: member.wechatNickname || member.wxid
|
||||
if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar
|
||||
}
|
||||
}
|
||||
const targets: ExportTarget[] = await Promise.all(
|
||||
selectedContacts.map(async (contact) => {
|
||||
const nameMap: Record<string, string> = {}
|
||||
const avatarUrls: Record<string, string> = {}
|
||||
if (contact.type === 'group') {
|
||||
const snapshot = await window.api.getGroupSnapshot(contact.md5)
|
||||
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
|
||||
nameMap[member.wxid] =
|
||||
nameMode === 'groupNickname'
|
||||
? member.groupNickname || member.nickname || member.wxid
|
||||
: nameMode === 'remark'
|
||||
? member.remark || member.wechatNickname || member.wxid
|
||||
: member.wechatNickname || member.wxid
|
||||
if (member.avatar) avatarUrls[member.wxid] = member.avatar
|
||||
}
|
||||
} else {
|
||||
nameMap[contact.m_nsUsrName] =
|
||||
nameMode === 'remark'
|
||||
? contact.remark || contact.m_nsNickName || contact.m_nsUsrName
|
||||
: contact.wechatNickname || contact.m_nsUsrName
|
||||
if (contact.avatar) avatarUrls[contact.m_nsUsrName] = contact.avatar
|
||||
}
|
||||
if (selfInfo?.wxid) {
|
||||
nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
|
||||
}
|
||||
return {
|
||||
userMd5: contact.md5,
|
||||
name: displayName(contact),
|
||||
type: contact.type,
|
||||
avatarUrl: contact.avatar,
|
||||
nameMode,
|
||||
nameMap,
|
||||
avatarUrls
|
||||
}
|
||||
})
|
||||
)
|
||||
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 = {
|
||||
const request: ExportRequest = {
|
||||
jobId: nextJobId,
|
||||
userMd5: activeContact.md5,
|
||||
name: activeName,
|
||||
format,
|
||||
targets,
|
||||
format: selectedContacts.length > 1 ? 'html' : format,
|
||||
outputName,
|
||||
startTime: startOfRange
|
||||
? Math.floor(startOfRange.getTime() / 1000)
|
||||
@@ -212,9 +266,6 @@ export function ExportWorkspace({
|
||||
fallbackThumbnail,
|
||||
keepMissing,
|
||||
includeAvatars,
|
||||
avatarUrls: exportAvatarUrls,
|
||||
nameMode,
|
||||
nameMap: exportNameMap,
|
||||
zip
|
||||
}
|
||||
const result = await onStartExport(request)
|
||||
@@ -257,6 +308,29 @@ export function ExportWorkspace({
|
||||
)
|
||||
}, [currentTask])
|
||||
|
||||
const resetDefaults = (): void => {
|
||||
const contact = initialContactRef.current || contacts[0] || null
|
||||
setSelectedContacts(contact ? [contact] : [])
|
||||
setActiveContactId(contact?.md5 || '')
|
||||
setSelectionMode(false)
|
||||
setRange('today')
|
||||
setStartDate('')
|
||||
setEndDate('')
|
||||
setSelectedKinds(new Set(['text']))
|
||||
setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||
setIncludeMedia(true)
|
||||
setIncludeAvatars(true)
|
||||
setPreferOriginal(true)
|
||||
setFallbackThumbnail(true)
|
||||
setKeepMissing(true)
|
||||
setFormat('csv')
|
||||
setZip(false)
|
||||
setFileName('')
|
||||
setStatus('idle')
|
||||
setJobId('')
|
||||
setProgress(null)
|
||||
}
|
||||
|
||||
const targetPath =
|
||||
format === 'html'
|
||||
? zip
|
||||
@@ -270,13 +344,17 @@ export function ExportWorkspace({
|
||||
contacts={contacts}
|
||||
filteredContacts={filteredContacts}
|
||||
activeContact={activeContact}
|
||||
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
|
||||
selectionMode={selectionMode}
|
||||
selectionLimit={selectionLimit}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
contactFilter={contactFilter}
|
||||
contactType={contactType}
|
||||
onContactFilterChange={setContactFilter}
|
||||
onContactTypeChange={setContactType}
|
||||
onSelectContact={onSelectContact}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCompleteSelection={() => setSelectionMode(false)}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
|
||||
@@ -290,20 +368,28 @@ export function ExportWorkspace({
|
||||
onCancel={(taskJobId) => void onCancelExport(taskJobId)}
|
||||
/>
|
||||
<header className="export-config-header">
|
||||
<span className="export-chat-avatar">
|
||||
{activeContact?.avatar ? (
|
||||
<img src={activeContact.avatar} alt="" />
|
||||
) : (
|
||||
activeName.slice(0, 1)
|
||||
)}
|
||||
<span className="export-chat-avatar-stack" aria-hidden>
|
||||
{selectedContacts.slice(0, 3).map((contact) => (
|
||||
<span className="export-chat-avatar" key={contact.md5}>
|
||||
{contact.avatar ? (
|
||||
<img src={contact.avatar} alt="" />
|
||||
) : (
|
||||
displayName(contact).slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
<span className="export-config-title">
|
||||
<h1>导出设置</h1>
|
||||
<p>
|
||||
{activeName}
|
||||
{activeContact?.type === 'group' ? ' · 群聊' : ''}
|
||||
</p>
|
||||
<p>{selectedLabel}</p>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="export-add-chat-button"
|
||||
onClick={() => setSelectionMode((current) => !current)}
|
||||
>
|
||||
{selectionMode ? '完成选择' : '+ 添加聊天'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="export-section export-format-top">
|
||||
@@ -314,6 +400,7 @@ export function ExportWorkspace({
|
||||
key={value}
|
||||
type="button"
|
||||
className={format === value ? 'active' : ''}
|
||||
disabled={selectedContacts.length > 1 && value !== 'html'}
|
||||
onClick={() => setFormat(value)}
|
||||
>
|
||||
<strong>{formatLabels[value].label}</strong>
|
||||
@@ -322,7 +409,9 @@ export function ExportWorkspace({
|
||||
))}
|
||||
</div>
|
||||
<p className="export-helper-text">
|
||||
CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。
|
||||
{selectedContacts.length > 1
|
||||
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
|
||||
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
|
||||
</p>
|
||||
{format === 'html' && (
|
||||
<>
|
||||
@@ -506,45 +595,6 @@ export function ExportWorkspace({
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>导出格式</h3>
|
||||
<div className="export-format-grid">
|
||||
{formatOrder.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>
|
||||
@@ -552,7 +602,7 @@ export function ExportWorkspace({
|
||||
<input
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
placeholder={`${activeName}_聊天档案`}
|
||||
placeholder={defaultOutputName}
|
||||
/>
|
||||
</label>
|
||||
<div className="export-target-path">
|
||||
@@ -577,7 +627,7 @@ export function ExportWorkspace({
|
||||
: '准备就绪'}
|
||||
</span>
|
||||
<span className="export-target-summary">路径:{targetPath}</span>
|
||||
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
|
||||
<button type="button" className="export-reset-button" onClick={resetDefaults}>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
@@ -598,6 +648,7 @@ export function ExportWorkspace({
|
||||
previewBytes={previewBytes}
|
||||
selfInfo={selfInfo}
|
||||
progress={progress}
|
||||
selectedCount={selectedContacts.length}
|
||||
jobId={jobId}
|
||||
onCancel={(exportJobId) => {
|
||||
void window.api.cancelExport(exportJobId)
|
||||
|
||||
@@ -30,15 +30,21 @@ export interface SelfInfo {
|
||||
|
||||
export interface ExportWorkspaceProps {
|
||||
contacts: Contact[]
|
||||
selectedContact: Contact | null
|
||||
previewMessages: Message[]
|
||||
initialContact: Contact | null
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
onSelectContact: (contact: Contact) => void
|
||||
loadPreviewMessages: (contact: Contact) => Promise<Message[]>
|
||||
onOpenSettings: () => void
|
||||
exportTasks: ExportTaskRecord[]
|
||||
onStartExport: (request: ExportRequest) => Promise<ExportResult>
|
||||
onCancelExport: (jobId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type { Contact, ExportJobProgress, ExportMessageKind, Message, ExportNameMode, ExportTaskRecord }
|
||||
export type {
|
||||
Contact,
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
Message,
|
||||
ExportNameMode,
|
||||
ExportTaskRecord
|
||||
}
|
||||
|
||||
@@ -116,6 +116,25 @@
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.export-multi-select-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 16px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
font-size: 12px;
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: 600 12px/18px var(--wxex-font);
|
||||
}
|
||||
}
|
||||
|
||||
.export-contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -137,6 +156,28 @@
|
||||
border-left-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand-soft);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
}
|
||||
|
||||
.export-contact-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--wxex-border-strong);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
|
||||
&.checked {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.export-contact-avatar,
|
||||
@@ -246,6 +287,46 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.export-config-title {
|
||||
min-width: 0;
|
||||
}
|
||||
.export-config-title p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.export-chat-avatar-stack {
|
||||
position: relative;
|
||||
width: 70px;
|
||||
height: 58px;
|
||||
flex: 0 0 70px;
|
||||
|
||||
.export-chat-avatar {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
border: 2px solid var(--wxex-bg-main);
|
||||
}
|
||||
|
||||
.export-chat-avatar:nth-child(2) {
|
||||
left: 12px;
|
||||
top: 8px;
|
||||
}
|
||||
.export-chat-avatar:nth-child(3) {
|
||||
left: 24px;
|
||||
top: 13px;
|
||||
}
|
||||
}
|
||||
.export-add-chat-button {
|
||||
margin-left: auto;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--wxex-brand);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font: 600 12px/18px var(--wxex-font);
|
||||
}
|
||||
.export-chat-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
@@ -259,6 +340,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-format-grid button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.export-section {
|
||||
margin-bottom: 25px;
|
||||
|
||||
@@ -764,6 +850,21 @@
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.export-task-detail {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&.completed {
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
&.failed {
|
||||
color: var(--wxex-danger, #c43d3d);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 5px;
|
||||
|
||||
+15
-8
@@ -14,10 +14,19 @@ export type ExportMessageKind =
|
||||
|
||||
export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname'
|
||||
|
||||
export interface ExportRequest {
|
||||
jobId: string
|
||||
export interface ExportTarget {
|
||||
userMd5: string
|
||||
name: string
|
||||
type: 'user' | 'group'
|
||||
avatarUrl?: string
|
||||
nameMode?: ExportNameMode
|
||||
nameMap?: Record<string, string>
|
||||
avatarUrls?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
jobId: string
|
||||
targets: ExportTarget[]
|
||||
format: ExportFormat
|
||||
outputName: string
|
||||
startTime?: number
|
||||
@@ -28,15 +37,12 @@ export interface ExportRequest {
|
||||
fallbackThumbnail?: boolean
|
||||
keepMissing?: 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'
|
||||
phase: 'reading' | 'writing' | 'compressing' | 'completed' | 'cancelled' | 'failed'
|
||||
processed: number
|
||||
total?: number
|
||||
percent?: number
|
||||
@@ -46,8 +52,9 @@ export interface ExportJobProgress {
|
||||
|
||||
export interface ExportTaskRecord {
|
||||
jobId: string
|
||||
contactId: string
|
||||
contactName: string
|
||||
targetIds: string[]
|
||||
targetNames: string[]
|
||||
targetLabel: string
|
||||
format: ExportFormat
|
||||
status: 'running' | 'completed' | 'cancelled' | 'failed'
|
||||
progress: ExportJobProgress
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface Message {
|
||||
exportShowAvatar?: boolean
|
||||
exportMediaError?: string
|
||||
exportAvatarUrl?: string
|
||||
exportConversationId?: string
|
||||
exportConversationName?: string
|
||||
exportConversationAvatarUrl?: string
|
||||
}
|
||||
|
||||
type TextContent = { type: 'text'; content: string }
|
||||
|
||||
Reference in New Issue
Block a user