feat: 完善日报语音转写与全部聊天分目录导出

This commit is contained in:
电摇小子
2026-08-11 02:59:54 +08:00
parent d1a090bb6b
commit 7a5499f093
28 changed files with 1909 additions and 193 deletions
+365 -20
View File
@@ -21,9 +21,10 @@ import { VideoAssetService } from './video-asset-service'
import { StickerService } from './sticker-service'
import { getImageExportAttempts } from '../shared/export-media'
import { FileAssetService } from './file-asset-service'
import { mergeCachedSelfInfo } from './services/bootstrap-cache'
import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-cache'
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
import { imageFileQuality } from '../shared/image-quality'
import { resolveMemberName } from '../shared/member-names'
const jobs = new Set<string>()
const activeArchives = new Map<string, Archiver>()
@@ -321,7 +322,8 @@ export function stripHtmlArchiveInlineAvatars(messages: Message[]): Message[] {
export async function readHtmlArchive(
outputDir: string,
targets: ExportTarget[],
name: string
name: string,
allowTargetChanges = false
): Promise<HtmlExportArchive> {
const dataPath = join(outputDir, 'data', 'messages.js')
let source = ''
@@ -383,7 +385,7 @@ export async function readHtmlArchive(
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
.map((conversation) => conversation.id)
.sort()
if (actualIds.join('|') !== expectedIds.join('|')) {
if (!allowTargetChanges && actualIds.join('|') !== expectedIds.join('|')) {
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
}
return {
@@ -463,6 +465,20 @@ export async function writeHtmlArchive(
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
const source = `${archiveDataPrefix}${JSON.stringify(archive)};\n`
await fs.writeFile(
join(dataDir, 'conversations.json'),
JSON.stringify(
{
version: archive.version,
name: archive.name,
exportedAt: archive.exportedAt,
conversations: archive.conversations
},
null,
2
),
'utf8'
)
await fs.writeFile(temporaryPath, source, 'utf8')
try {
await fs.rename(temporaryPath, dataPath)
@@ -624,19 +640,129 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
].join('\n')
}
export async function runExport(
interface SingleExportOptions {
outputRoot?: string
outputFolderName?: string
manageJob?: boolean
sendProgress?: (progress: ExportJobProgress) => void
selfInfo?: CachedSelfInfo | null
}
interface AllExportManifestEntry {
id: string
name: string
type: ExportTarget['type']
folder: string
messageCount: number
}
const writeAllExportManifest = async (
outputDir: string,
conversations: AllExportManifestEntry[],
messageCount: number,
status: 'running' | 'completed' | 'cancelled' | 'failed',
error?: string
): Promise<void> => {
const manifestPath = join(outputDir, '导出清单.json')
const temporaryPath = `${manifestPath}.tmp-${process.pid}-${Date.now()}`
await fs.writeFile(
temporaryPath,
JSON.stringify(
{
version: 1,
status,
exportedAt: new Date().toISOString(),
messageCount,
conversations,
error
},
null,
2
),
'utf8'
)
try {
await fs.rename(temporaryPath, manifestPath)
} catch (error) {
if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error
await fs.rm(manifestPath, { force: true })
await fs.rename(temporaryPath, manifestPath)
} finally {
await fs.rm(temporaryPath, { force: true })
}
}
const conversationFolderNames = (targets: ExportTarget[]): Map<string, string> => {
const names = new Map<string, string>()
const used = new Set<string>()
for (const target of targets) {
const base = safeFilePart(target.name).slice(0, 80).trim() || '未命名聊天'
let candidate = base
const usedKey = (value: string): string => `${target.type}:${value.toLowerCase()}`
if (used.has(usedKey(candidate))) {
candidate = `${base}_${hashPart(target.userMd5, 8)}`
}
let suffix = 2
while (used.has(usedKey(candidate))) {
candidate = `${base}_${hashPart(target.userMd5, 8)}_${suffix}`
suffix += 1
}
used.add(usedKey(candidate))
names.set(target.userMd5, candidate)
}
return names
}
const pathExists = async (value: string): Promise<boolean> => {
try {
await fs.stat(value)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
throw error
}
}
const preserveLegacyCombinedArchive = async (outputDir: string): Promise<void> => {
const legacyIndex = join(outputDir, 'index.html')
const legacyData = join(outputDir, 'data', 'messages.js')
if (!(await pathExists(legacyIndex)) || !(await pathExists(legacyData))) return
let legacyDir = join(outputDir, '旧版合并档案')
if (await pathExists(legacyDir)) {
legacyDir = join(outputDir, `_${exportStamp()}`)
}
await fs.mkdir(legacyDir, { recursive: true })
for (const entry of ['index.html', 'data', 'avatars', 'media', 'voices', 'files']) {
const source = join(outputDir, entry)
if (await pathExists(source)) await fs.rename(source, join(legacyDir, entry))
}
}
async function runSingleExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>,
options: SingleExportOptions = {}
): Promise<ExportResult> {
jobs.add(request.jobId)
const send = (p: ExportJobProgress): void => {
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
}
const manageJob = options.manageJob !== false
if (manageJob) jobs.add(request.jobId)
const send =
options.sendProgress ||
((p: ExportJobProgress): void => {
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 个聊天')
const targets = (request.targets || []).map((target) => ({
...target,
nameMap: { ...(target.nameMap || {}) },
avatarUrls: { ...(target.avatarUrls || {}) }
}))
const targetLimit = request.scope === 'all' ? null : 5
if (targets.length < 1 || (targetLimit !== null && targets.length > targetLimit)) {
throw new Error(
targetLimit === null ? '全部导出至少需要一个聊天' : '一次导出必须选择 1 到 5 个聊天'
)
}
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
throw new Error('导出聊天不能重复')
@@ -654,6 +780,25 @@ export async function runExport(
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
return { success: false, error: '已取消' }
}
if (
target.type === 'group' &&
(request.scope === 'all' || !Object.keys(target.nameMap || {}).length)
) {
const snapshot = await chat.getGroupSnapshotAsync(target.userMd5)
for (const member of snapshot?.members || []) {
target.nameMap![member.wxid] = resolveMemberName(
{
wxid: member.wxid,
nickname: member.nickname,
groupNickname: member.groupNickname,
wechatNickname: member.wechatNickname,
remark: member.remark
},
target.nameMode || 'groupNickname'
)
if (member.avatar) target.avatarUrls![member.wxid] = member.avatar
}
}
const targetMessages = (
await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime)
).filter((message) => request.kinds.includes(kindOf(message)))
@@ -684,8 +829,12 @@ export async function runExport(
return left.messageOrder - right.messageOrder
})
.map((entry) => entry.message)
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
const selfInfo =
options.selfInfo !== undefined
? options.selfInfo
: await chat
.getSelfAccountInfoAsync()
.then((value) => (value ? mergeCachedSelfInfo(value.accountRoot, value) : null))
const client = chat.getChatDb()?.getWcdb4Client()
const isUsableSelfName = (value: string | undefined): value is string => {
const name = String(value || '').trim()
@@ -759,12 +908,12 @@ export async function runExport(
total: messages.length,
percent: request.format === 'html' ? 18 : 20
})
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
const root = options.outputRoot || join(app.getPath('documents'), 'WechatExplorer', '导出')
await fs.mkdir(root, { recursive: true })
const ext = request.format === 'markdown' ? 'md' : request.format
const outputFolder =
request.format === 'html'
? safeFilePart(request.outputName)
? options.outputFolderName || safeFilePart(request.outputName)
: `${safeFilePart(request.outputName)}_${exportStamp()}`
const outputDir = join(root, outputFolder)
const outputPath =
@@ -772,12 +921,24 @@ export async function runExport(
? join(outputDir, 'index.html')
: join(root, `${outputFolder}.${ext}`)
if (request.format === 'html') {
const previousArchive = await readHtmlArchive(outputDir, targets, archiveName)
const previousArchive = await readHtmlArchive(
outputDir,
targets,
archiveName,
request.scope === 'all'
)
const currentTargetIds = new Set(targets.map((target) => target.userMd5))
const previousMessages =
request.scope === 'all'
? previousArchive.messages.filter((message) =>
currentTargetIds.has(message.exportConversationId || targets[0].userMd5)
)
: previousArchive.messages
const previousMessagesByKey = new Map(
previousArchive.messages.map((message) => [exportMessageKey(message), message])
previousMessages.map((message) => [exportMessageKey(message), message])
)
const latestPreviousAvatarUrls = new Map<string, string>()
for (const message of previousArchive.messages) {
for (const message of previousMessages) {
if (!message.exportAvatarUrl) continue
const conversationId = message.exportConversationId || targets[0].userMd5
latestPreviousAvatarUrls.set(
@@ -1232,7 +1393,7 @@ export async function runExport(
})
}
const mergedMessages = mergeHtmlArchiveMessages(
previousArchive.messages,
previousMessages,
messages,
'',
targets.map((target) => target.userMd5)
@@ -1319,10 +1480,194 @@ export async function runExport(
}
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
return { success: false, error: message }
} finally {
if (manageJob) jobs.delete(request.jobId)
}
}
async function runAllExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
): Promise<ExportResult> {
jobs.add(request.jobId)
const send = (progress: ExportJobProgress): void => {
if (!win.isDestroyed()) win.webContents.send('export:progress', progress)
}
let outputDir = ''
const manifest: AllExportManifestEntry[] = []
let totalMessages = 0
try {
const targets = [...(request.targets || [])].sort((left, right) =>
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
)
if (!targets.length) throw new Error('全部导出至少需要一个聊天')
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
throw new Error('导出聊天不能重复')
}
const exportRoot = join(app.getPath('documents'), 'WechatExplorer', '导出')
const outputFolder = safeFilePart(request.outputName)
outputDir = join(exportRoot, outputFolder)
const folderNames = conversationFolderNames(targets)
let lastProgressAt = 0
let lastProgressKey = ''
await fs.mkdir(outputDir, { recursive: true })
await preserveLegacyCombinedArchive(outputDir)
const selectedTypes = request.allContactTypes?.length
? request.allContactTypes
: Array.from(new Set(targets.map((target) => target.type)))
for (const type of selectedTypes) {
await fs.mkdir(join(outputDir, type === 'group' ? '群聊' : '联系人'), { recursive: true })
}
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
for (const [targetIndex, target] of targets.entries()) {
if (!jobs.has(request.jobId)) throw new Error('已取消')
const categoryName = target.type === 'group' ? '群聊' : '联系人'
const categoryDir = join(outputDir, categoryName)
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
await fs.mkdir(categoryDir, { recursive: true })
const conversationOutputRoot =
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
const basePercent = Math.floor((targetIndex / targets.length) * 100)
send({
jobId: request.jobId,
phase: 'reading',
processed: 0,
percent: basePercent,
currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
const result = await runSingleExport(
{
...request,
targets: [target],
outputName: target.name,
zip: false
},
win,
voiceRecognition,
{
outputRoot: conversationOutputRoot,
outputFolderName: request.format === 'html' ? folderName : undefined,
manageJob: false,
selfInfo,
sendProgress: (childProgress) => {
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
const percent = Math.min(
99,
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
)
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
const now = Date.now()
const progressKey = `${targetIndex}:${phase}:${percent}`
const terminal = phase === 'failed' || phase === 'cancelled'
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
lastProgressKey = progressKey
lastProgressAt = now
send({
...childProgress,
jobId: request.jobId,
phase,
percent,
outputPath: undefined,
currentTargetIndex: targetIndex + 1,
currentTargetCount: targets.length,
currentTargetName: target.name,
currentTargetType: target.type
})
}
}
)
if (!result.success) {
if (result.error === '已取消') throw new Error('已取消')
throw new Error(`${target.name}${result.error || '导出失败'}`)
}
const messageCount = result.messageCount || 0
totalMessages += messageCount
manifest.push({
id: target.userMd5,
name: target.name,
type: target.type,
folder: `${categoryName}/${folderName}`,
messageCount
})
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
}
await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed')
let completedPath = outputDir
if (request.zip) {
if (!jobs.has(request.jobId)) throw new Error('已取消')
const zipPath = join(exportRoot, `${outputFolder}.zip`)
send({
jobId: request.jobId,
phase: 'compressing',
processed: totalMessages,
total: totalMessages,
percent: 99
})
await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId)
completedPath = zipPath
}
send({
jobId: request.jobId,
phase: 'completed',
processed: totalMessages,
total: totalMessages,
percent: 100,
outputPath: completedPath,
currentTargetIndex: targets.length,
currentTargetCount: targets.length,
currentTargetName: targets.at(-1)?.name,
currentTargetType: targets.at(-1)?.type
})
return { success: true, outputPath: completedPath, messageCount: totalMessages }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const cancelled = !jobs.has(request.jobId) || message === '已取消'
if (outputDir) {
try {
await writeAllExportManifest(
outputDir,
manifest,
totalMessages,
cancelled ? 'cancelled' : 'failed',
cancelled ? undefined : message
)
} catch (manifestError) {
console.warn('[Export] failed to update all-export manifest:', manifestError)
}
}
if (cancelled) {
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 {
jobs.delete(request.jobId)
}
}
export async function runExport(
request: ExportRequest,
win: BrowserWindow,
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
): Promise<ExportResult> {
return request.scope === 'all'
? runAllExport(request, win, voiceRecognition)
: runSingleExport(request, win, voiceRecognition)
}
export function cancelExport(jobId: string): void {
jobs.delete(jobId)
activeArchives.get(jobId)?.abort()
+18 -6
View File
@@ -1707,7 +1707,13 @@ export class Wcdb4Client {
'contactRemark',
'contact_remark'
])
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
const rowGroupNickname = this.pickString(row, [
'groupNickname',
'group_nickname',
'displayName',
'display_name'
])
const memberNickname = this.pickString(row, ['name'])
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
@@ -1721,8 +1727,8 @@ export class Wcdb4Client {
return {
m_nsUsrName: username,
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
groupNickname: groupNicknames.get(username) || '',
nickname: wechatNickname || memberNickname || remark || rowGroupNickname,
groupNickname: groupNicknames.get(username) || rowGroupNickname,
wechatNickname: wechatNickname || memberNickname,
remark,
m_nsHeadImgUrl: avatar
@@ -1843,7 +1849,13 @@ export class Wcdb4Client {
'contactRemark',
'contact_remark'
])
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
const rowGroupNickname = this.pickString(row, [
'groupNickname',
'group_nickname',
'displayName',
'display_name'
])
const memberNickname = this.pickString(row, ['name'])
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
@@ -1853,8 +1865,8 @@ export class Wcdb4Client {
if (username && avatar) this.avatarCache.set(username, avatar)
return {
m_nsUsrName: username,
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
groupNickname: groupNicknames.get(username) || '',
nickname: wechatNickname || memberNickname || remark || rowGroupNickname,
groupNickname: groupNicknames.get(username) || rowGroupNickname,
wechatNickname: wechatNickname || memberNickname,
remark,
m_nsHeadImgUrl: avatar
+13 -4
View File
@@ -378,12 +378,19 @@ 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 exportAll = request.scope === 'all'
const targetCount = request.targets.length
const targetNames = exportAll ? [] : request.targets.map((target) => target.name)
const targetLabel = exportAll
? `全部 ${targetCount} 个聊天`
: targetNames.length > 1
? `${targetNames[0]}${targetNames.length} 个聊天`
: targetNames[0]
const task: ExportTaskRecord = {
jobId: request.jobId,
targetIds: request.targets.map((target) => target.userMd5),
scope: request.scope,
allContactTypes: request.allContactTypes,
targetIds: exportAll ? [] : request.targets.map((target) => target.userMd5),
targetNames,
targetLabel,
format: request.format,
@@ -1677,6 +1684,8 @@ function App(): React.ReactElement {
<ReportTaskStatusPanel
phase={reportGeneration.phase}
error={reportGeneration.error}
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
onRetry={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.retry()
@@ -1,4 +1,5 @@
import React from 'react'
import type { ExportContactType } from '../../../../shared/export'
import type { Contact, SelfInfo } from './exportTypes'
import { displayName } from './exportUtils'
@@ -8,6 +9,9 @@ interface ExportContactPanelProps {
activeContact: Contact | null
selectedContactIds: string[]
selectionMode: boolean
exportAll: boolean
allContactTypes: ExportContactType[]
exportRunning: boolean
selectionLimit: number
selfInfo: SelfInfo | null
dbReady: boolean
@@ -17,6 +21,8 @@ interface ExportContactPanelProps {
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
onSelectContact: (contact: Contact) => void
onCompleteSelection: () => void
onExportAll: () => void
onToggleAllContactType: (type: ExportContactType) => void
onOpenSettings: () => void
}
@@ -26,6 +32,9 @@ export function ExportContactPanel({
activeContact,
selectedContactIds,
selectionMode,
exportAll,
allContactTypes,
exportRunning,
selectionLimit,
selfInfo,
dbReady,
@@ -35,8 +44,16 @@ export function ExportContactPanel({
onContactTypeChange,
onSelectContact,
onCompleteSelection,
onExportAll,
onToggleAllContactType,
onOpenSettings
}: ExportContactPanelProps): React.ReactElement {
const groupCount = contacts.filter((contact) => contact.type === 'group').length
const userCount = contacts.length - groupCount
const selectedAllCount =
(allContactTypes.includes('group') ? groupCount : 0) +
(allContactTypes.includes('user') ? userCount : 0)
return (
<aside className="export-contact-panel">
<div className="export-panel-header">
@@ -71,9 +88,55 @@ export function ExportContactPanel({
</button>
))}
</div>
<button
type="button"
className={`export-all-button ${exportAll ? 'active' : ''}`}
aria-pressed={exportAll}
onClick={onExportAll}
>
<span>
<strong></strong>
<small></small>
</span>
<b>{(exportAll ? selectedAllCount : contacts.length).toLocaleString()}</b>
</button>
{exportAll && (
<div className="export-all-type-options" aria-label="全部导出范围">
{(
[
['group', '群聊'],
['user', '联系人']
] as const
).map(([type, label]) => {
const count = type === 'group' ? groupCount : userCount
return (
<label key={type}>
<input
type="checkbox"
aria-label={`导出全部${label}`}
checked={allContactTypes.includes(type)}
disabled={
exportRunning || (allContactTypes.length === 1 && allContactTypes[0] === type)
}
onChange={() => onToggleAllContactType(type)}
/>
<span>{label}</span>
<b>{count.toLocaleString()}</b>
</label>
)
})}
</div>
)}
</div>
{selectionMode && (
{exportAll ? (
<div className="export-all-status">
{allContactTypes.includes('group') ? `全部群聊 ${groupCount}` : ''}
{allContactTypes.length === 2 ? '和' : ''}
{allContactTypes.includes('user') ? `全部联系人 ${userCount}` : ''}
</div>
) : selectionMode ? (
<div className="export-multi-select-bar">
<span>
{selectedContactIds.length} / {selectionLimit}
@@ -82,21 +145,24 @@ export function ExportContactPanel({
</button>
</div>
)}
) : null}
<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
const selectedByAll = exportAll && allContactTypes.includes(contact.type)
const visuallySelected = exportAll ? selectedByAll : selected
const atLimit =
!exportAll && selectionMode && !selected && selectedContactIds.length >= selectionLimit
return (
<button
key={contact.md5}
type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''} ${selected ? 'selected' : ''}`}
className={`export-contact-item ${!exportAll && activeContact?.md5 === contact.md5 ? 'active' : ''} ${visuallySelected ? 'selected' : ''}`}
onClick={() => onSelectContact(contact)}
disabled={atLimit}
aria-pressed={selected}
aria-pressed={visuallySelected}
>
<span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
@@ -105,7 +171,7 @@ export function ExportContactPanel({
<strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
{selectionMode && (
{!exportAll && selectionMode && (
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
{selected ? '✓' : ''}
</span>
@@ -13,6 +13,7 @@ interface ExportPreviewPanelProps {
includeVoiceTranscripts: boolean
zip: boolean
selectedCount: number
allExport: boolean
jobId: string
onCancel: (jobId: string) => void
onReveal: (path: string) => void
@@ -28,6 +29,7 @@ export function ExportPreviewPanel({
includeVoiceTranscripts,
zip,
selectedCount,
allExport,
jobId,
onCancel,
onReveal
@@ -61,6 +63,9 @@ export function ExportPreviewPanel({
: phase === 'parsing'
? `正在解析消息内容... ${percent}%`
: `正在读取消息... ${percent}%`
const currentTargetText = progress?.currentTargetName
? `${progress.currentTargetIndex || 1}/${progress.currentTargetCount || selectedCount} 个:${progress.currentTargetName}`
: ''
return (
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
@@ -69,23 +74,39 @@ export function ExportPreviewPanel({
<div className="export-preview-heading">
<strong></strong>
<span>
{selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'}
{allExport
? `${selectedCount} 个聊天 · 分目录导出`
: selectedCount > 1
? `${selectedCount} 个聊天 · 合并预览`
: '仅预览最近 20 条'}
</span>
</div>
<div className="export-message-preview">
<div className="export-preview-date"></div>
{(previewItems.length
? previewItems
: [
<div className="export-preview-date">{allExport ? '全量归档' : '最近消息'}</div>
{(allExport
? [
{
id: 'empty',
from: 'user',
content: '导出预览将在这里显示',
type: '文字',
id: 'all-export',
from: 'system',
content: '每个聊天将保存为独立 HTML 档案',
type: '系统消息',
datetime: '',
isSender: false
isSender: false,
contentData: { type: 'system' as const, content: '全量归档' }
}
]
: previewItems.length
? previewItems
: [
{
id: 'empty',
from: 'user',
content: '导出预览将在这里显示',
type: '文字',
datetime: '',
isSender: false
}
]
).map((message) => (
<div
key={`${message.exportConversationId || 'single'}:${message.id}`}
@@ -146,6 +167,12 @@ export function ExportPreviewPanel({
<div className="export-job-state">
<h2></h2>
<p></p>
{currentTargetText && (
<div className="export-current-target">
<span>{progress?.currentTargetType === 'group' ? '群聊' : '联系人'}</span>
<strong>{currentTargetText}</strong>
</div>
)}
<ol>
<li className="done"></li>
{steps.map((step, index) => (
@@ -197,7 +224,7 @@ export function ExportPreviewPanel({
className="export-primary-button"
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
>
{allExport ? '打开导出目录' : '打开档案'}
</button>
<button
type="button"
@@ -22,6 +22,9 @@ const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
}
const taskDetail = (task: ExportTaskRecord): string | null => {
if (task.status === 'running' && task.progress.currentTargetName) {
return `${task.progress.currentTargetIndex || 1}/${task.progress.currentTargetCount || '?'} 个:${task.progress.currentTargetName}`
}
if (task.status === 'completed') {
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
}
@@ -48,6 +51,7 @@ export function ExportTaskCenter({
`格式:${task.format.toUpperCase()}`,
`状态:${task.progress.phase}`,
`进度:${task.progress.percent ?? 0}%`,
`当前聊天:${task.progress.currentTargetName || '未开始'}`,
`错误:${task.progress.error || '未记录具体错误'}`
].join('\n')
await navigator.clipboard.writeText(log)
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react'
import type { Message } from '../../../../shared/types'
import type {
ExportContactType,
ExportJobProgress,
ExportMessageKind,
ExportNameMode,
@@ -20,6 +21,11 @@ import type {
} from './exportTypes'
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
import { resolveMemberName } from '../../../../shared/member-names'
const ALL_CONTACT_TYPES: ExportContactType[] = ['group', 'user']
const contactTypeKey = (types: ExportContactType[] | undefined): string =>
[...(types?.length ? types : ALL_CONTACT_TYPES)].sort().join('|')
export function ExportWorkspace({
contacts,
@@ -33,17 +39,26 @@ export function ExportWorkspace({
onCancelExport
}: ExportWorkspaceProps): React.ReactElement {
const initialSelection = initialContact || contacts[0] || null
const runningAllTask = exportTasks.find(
(task) => task.scope === 'all' && task.status === 'running'
)
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 [exportAll, setExportAll] = useState(() => Boolean(runningAllTask))
const [allContactTypes, setAllContactTypes] = useState<ExportContactType[]>(() =>
runningAllTask?.allContactTypes?.length
? [...runningAllTask.allContactTypes]
: [...ALL_CONTACT_TYPES]
)
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 [range, setRange] = useState<ExportRange>(() => (runningAllTask ? 'all' : 'today'))
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
@@ -57,8 +72,8 @@ export function ExportWorkspace({
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(true)
const [format, setFormat] = useState<ExportFormat>('csv')
const [zip, setZip] = useState(false)
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv')
const [zip, setZip] = useState(() => runningAllTask?.zip === true)
const [fileName, setFileName] = useState('')
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
@@ -80,6 +95,7 @@ export function ExportWorkspace({
}, [contacts, initialContact, selectedContacts.length])
React.useEffect(() => {
if (exportAll) return
for (const contact of selectedContacts) {
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
previewLoadingRef.current.add(contact.md5)
@@ -88,7 +104,7 @@ export function ExportWorkspace({
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
})
}
}, [loadPreviewMessages, previewByContact, selectedContacts])
}, [exportAll, loadPreviewMessages, previewByContact, selectedContacts])
const filteredContacts = useMemo(() => {
const keyword = contactFilter.trim().toLowerCase()
@@ -105,31 +121,48 @@ export function ExportWorkspace({
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 exportContacts = exportAll
? contacts.filter((contact) => allContactTypes.includes(contact.type))
: selectedContacts
const selectedTargetKey = exportAll
? ''
: selectedContacts
.map((contact) => contact.md5)
.sort()
.join('|')
const currentTask = exportTasks.find((task) =>
exportAll
? task.scope === 'all' &&
contactTypeKey(task.allContactTypes) === contactTypeKey(allContactTypes)
: task.scope !== 'all' && [...task.targetIds].sort().join('|') === selectedTargetKey
)
const taskCount = exportTasks.filter((task) => task.status === 'running').length
const activeName = displayName(activeContact)
const selectedNames = selectedContacts.map(displayName)
const selectedLabel =
selectedNames.length > 1
const allGroupCount = exportContacts.filter((contact) => contact.type === 'group').length
const allUserCount = exportContacts.length - allGroupCount
const selectedLabel = exportAll
? allContactTypes.length === 2
? `全部群聊 ${allGroupCount.toLocaleString()} 个、联系人 ${allUserCount.toLocaleString()}`
: allContactTypes[0] === 'group'
? `全部群聊 ${allGroupCount.toLocaleString()}`
: `全部联系人 ${allUserCount.toLocaleString()}`
: 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 preview = exportAll
? []
: 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 || '') ||
@@ -139,12 +172,13 @@ export function ExportWorkspace({
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
0
)
const defaultOutputName =
selectedContacts.length > 1
const defaultOutputName = exportAll
? '全部聊天记录'
: selectedContacts.length > 1
? `${selectedNames[0]}${selectedContacts.length}个聊天_合并档案`
: `${activeName}_聊天档案`
const outputName = fileName.trim() || defaultOutputName
const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.some(
const nameOptions: { value: ExportNameMode; label: string }[] = exportContacts.some(
(contact) => contact.type === 'group'
)
? [
@@ -170,6 +204,10 @@ export function ExportWorkspace({
}))
const handleSelectContact = (contact: Contact): void => {
if (exportAll) {
setExportAll(false)
setRange('today')
}
if (!selectionMode) {
setSelectedContacts([contact])
setActiveContactId(contact.md5)
@@ -193,6 +231,26 @@ export function ExportWorkspace({
setStatus('idle')
}
const handleExportAll = (): void => {
if (!contacts.length || status === 'running') return
setExportAll(true)
setAllContactTypes([...ALL_CONTACT_TYPES])
setSelectionMode(false)
setRange('all')
setStatus('idle')
}
const toggleAllContactType = (type: ExportContactType): void => {
if (status === 'running') return
setAllContactTypes((current) => {
if (current.includes(type)) {
return current.length === 1 ? current : current.filter((item) => item !== type)
}
return ALL_CONTACT_TYPES.filter((item) => item === type || current.includes(item))
})
setStatus('idle')
}
React.useEffect(() => {
let active = true
void window.api
@@ -214,10 +272,11 @@ export function ExportWorkspace({
}
const handleStart = async (): Promise<void> => {
if (!activeContact || selectedContacts.length === 0 || status === 'running') return
if (!activeContact || exportContacts.length === 0 || status === 'running') return
// Runs only from the export button event; a fresh id is required for each job.
const nextJobId = `export-${Date.now()}`
const exportFormat = selectedContacts.length > 1 ? 'html' : format
const exportFormat = !exportAll && exportContacts.length > 1 ? 'html' : format
const shouldZip = exportFormat === 'html' && zip
const shouldIncludeVoiceTranscripts =
includeVoiceTranscripts &&
includeMedia &&
@@ -226,31 +285,28 @@ export function ExportWorkspace({
voiceModelStatus?.state === 'ready'
setJobId(nextJobId)
setProgress(null)
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip })
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip: shouldZip })
setStatus('running')
const targets: ExportTarget[] = await Promise.all(
selectedContacts.map(async (contact) => {
exportContacts.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
if (!exportAll) {
const snapshot = await window.api.getGroupSnapshot(contact.md5)
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
nameMap[member.wxid] = resolveMemberName(member, nameMode)
if (member.avatar) avatarUrls[member.wxid] = member.avatar
}
}
} else {
} else if (!exportAll) {
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) {
if (selfInfo?.wxid && (!exportAll || contact.type === 'group')) {
nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
}
@@ -273,19 +329,25 @@ export function ExportWorkspace({
: null
const request: ExportRequest = {
jobId: nextJobId,
scope: exportAll ? 'all' : 'selected',
allContactTypes: exportAll ? allContactTypes : undefined,
targets,
format: exportFormat,
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,
startTime: exportAll
? undefined
: startOfRange
? Math.floor(startOfRange.getTime() / 1000)
: range === 'custom' && startDate
? Math.floor(new Date(startDate).getTime() / 1000)
: undefined,
endTime: exportAll
? undefined
: startOfRange
? Math.floor(endOfToday.getTime() / 1000)
: range === 'custom' && endDate
? Math.floor(new Date(endDate).getTime() / 1000)
: undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia,
includeVoiceTranscripts: shouldIncludeVoiceTranscripts,
@@ -293,7 +355,7 @@ export function ExportWorkspace({
fallbackThumbnail,
keepMissing,
includeAvatars,
zip
zip: shouldZip
}
const result = await onStartExport(request)
if (result.success) {
@@ -330,6 +392,8 @@ export function ExportWorkspace({
includeVoiceTranscripts: currentTask.includeVoiceTranscripts === true,
zip: currentTask.zip === true
})
setFormat(currentTask.format)
setZip(currentTask.zip === true)
setStatus(
currentTask.status === 'running'
? 'running'
@@ -344,6 +408,8 @@ export function ExportWorkspace({
setSelectedContacts(contact ? [contact] : [])
setActiveContactId(contact?.md5 || '')
setSelectionMode(false)
setExportAll(false)
setAllContactTypes([...ALL_CONTACT_TYPES])
setRange('today')
setStartDate('')
setEndDate('')
@@ -363,8 +429,11 @@ export function ExportWorkspace({
setActiveJobOptions({ includeVoiceTranscripts: false, zip: false })
}
const targetPath =
format === 'html'
const targetPath = exportAll
? format === 'html' && zip
? `文稿/WechatExplorer/导出/${outputName}.zip`
: `文稿/WechatExplorer/导出/${outputName}/`
: format === 'html'
? zip
? `文稿/WechatExplorer/导出/${outputName}.zip`
: `文稿/WechatExplorer/导出/${outputName}/`
@@ -378,6 +447,9 @@ export function ExportWorkspace({
activeContact={activeContact}
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
selectionMode={selectionMode}
exportAll={exportAll}
allContactTypes={allContactTypes}
exportRunning={status === 'running'}
selectionLimit={selectionLimit}
selfInfo={selfInfo}
dbReady={dbReady}
@@ -387,6 +459,8 @@ export function ExportWorkspace({
onContactTypeChange={setContactType}
onSelectContact={handleSelectContact}
onCompleteSelection={() => setSelectionMode(false)}
onExportAll={handleExportAll}
onToggleAllContactType={toggleAllContactType}
onOpenSettings={onOpenSettings}
/>
@@ -401,15 +475,24 @@ export function ExportWorkspace({
/>
<header className="export-config-header">
<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>
))}
{exportAll
? allContactTypes.map((type) => (
<span
className={`export-chat-avatar export-all-chat-avatar ${type}`}
key={type}
>
{type === 'group' ? '群' : '联'}
</span>
))
: 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 className="export-config-title">
<h1></h1>
@@ -418,9 +501,13 @@ export function ExportWorkspace({
<button
type="button"
className="export-add-chat-button"
onClick={() => setSelectionMode((current) => !current)}
disabled={exportAll}
onClick={() => {
setExportAll(false)
setSelectionMode((current) => !current)
}}
>
{selectionMode ? '完成选择' : '+ 添加聊天'}
{exportAll ? '已选择全部聊天' : selectionMode ? '完成选择' : '+ 添加聊天'}
</button>
</header>
@@ -432,7 +519,7 @@ export function ExportWorkspace({
key={value}
type="button"
className={format === value ? 'active' : ''}
disabled={selectedContacts.length > 1 && value !== 'html'}
disabled={!exportAll && exportContacts.length > 1 && value !== 'html'}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
@@ -441,9 +528,11 @@ export function ExportWorkspace({
))}
</div>
<p className="export-helper-text">
{selectedContacts.length > 1
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
{exportAll
? '全部导出固定使用全部时间;每个群聊或联系人都会在自己的目录中生成所选格式的独立档案。'
: selectedContacts.length > 1
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
</p>
{format === 'html' && (
<>
@@ -482,34 +571,45 @@ export function ExportWorkspace({
<div className="export-range-toggle">
<button
type="button"
className={range === 'today' ? 'active' : ''}
onClick={() => setRange('today')}
className={range === 'all' ? 'active' : ''}
onClick={() => setRange('all')}
>
</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>
{!exportAll && (
<>
<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' && (
{!exportAll && range === 'custom' && (
<div className="export-date-fields">
<label>
@@ -683,7 +783,7 @@ export function ExportWorkspace({
<button
type="button"
className="export-primary-button"
disabled={!activeContact || status === 'running'}
disabled={!activeContact || !exportContacts.length || status === 'running'}
onClick={handleStart}
>
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
@@ -700,7 +800,8 @@ export function ExportWorkspace({
progress={progress}
includeVoiceTranscripts={activeJobOptions.includeVoiceTranscripts}
zip={activeJobOptions.zip}
selectedCount={selectedContacts.length}
selectedCount={exportContacts.length}
allExport={exportAll}
jobId={jobId}
onCancel={(exportJobId) => {
void window.api.cancelExport(exportJobId)
@@ -8,7 +8,7 @@ import type {
ExportTaskRecord
} from '../../../../shared/export'
export type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
export type ExportRange = 'all' | 'today' | 'threeDays' | 'sevenDays' | 'custom'
export type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
export type ExportStatus = 'idle' | 'running' | 'completed'
@@ -1,5 +1,6 @@
import type { Contact, Message } from '../../../../shared/types'
import type { ExportFormat, GroupMemberName } from './exportTypes'
import { resolveMemberName } from '../../../../shared/member-names'
export const messageKinds = [
['text', '文字'],
@@ -43,13 +44,10 @@ export function buildNameMap(
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
map[member.wxid] = resolveMemberName(
member,
nameMode as 'groupNickname' | 'remark' | 'wechatNickname'
)
}
} else if (activeContact) {
map[activeContact.m_nsUsrName] =
@@ -13,6 +13,7 @@ import { ModelSummary } from './ModelSummary'
import { ReportDensitySelector } from './ReportDensitySelector'
import { ReportRangeSelector } from './ReportRangeSelector'
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
import { ReportGroupMemberSelector } from './ReportGroupMemberSelector'
import { ReportSectionSelector } from './ReportSectionSelector'
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
@@ -169,6 +170,7 @@ export function AiReportWorkspace({
onChange={onMemberNamePreferenceChange}
disabled={configDisabled}
/>
<ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} />
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
<section className="report-config-section report-timeout-section">
<div>
@@ -0,0 +1,141 @@
import React, { useEffect, useMemo, useState } from 'react'
import type { Contact } from '../../../../shared/types'
interface ReportGroupMember {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
interface ReportGroupMemberSelectorProps {
sourceContact: Contact | null
disabled?: boolean
}
interface GroupMemberSnapshotState {
contactId: string
members: ReportGroupMember[]
error: string
}
const displayMemberName = (member: ReportGroupMember): string =>
member.groupNickname || member.wechatNickname || member.remark || member.nickname || member.wxid
export function ReportGroupMemberSelector({
sourceContact,
disabled
}: ReportGroupMemberSelectorProps): React.ReactElement | null {
const [snapshotState, setSnapshotState] = useState<GroupMemberSnapshotState>({
contactId: '',
members: [],
error: ''
})
const [selectedWxid, setSelectedWxid] = useState('')
const [filter, setFilter] = useState('')
const sourceContactId = sourceContact?.type === 'group' ? sourceContact.md5 : ''
useEffect(() => {
if (!sourceContactId) return
let active = true
void window.api
.getGroupSnapshot(sourceContactId)
.then((snapshot) => {
if (!active) return
setSnapshotState({
contactId: sourceContactId,
members: (snapshot?.members || []) as ReportGroupMember[],
error: ''
})
})
.catch((loadError) => {
if (!active) return
setSnapshotState({
contactId: sourceContactId,
members: [],
error: loadError instanceof Error ? loadError.message : '群成员信息读取失败'
})
})
return () => {
active = false
}
}, [sourceContactId])
const filteredMembers = useMemo(() => {
const loadedMembers = snapshotState.contactId === sourceContactId ? snapshotState.members : []
const keyword = filter.trim().toLowerCase()
if (!keyword) return loadedMembers
return loadedMembers.filter((member) =>
[member.wxid, member.nickname, member.groupNickname, member.wechatNickname, member.remark]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(keyword))
)
}, [filter, snapshotState, sourceContactId])
const loading = Boolean(sourceContactId) && snapshotState.contactId !== sourceContactId
const error = snapshotState.contactId === sourceContactId ? snapshotState.error : ''
const selectedMember =
filteredMembers.find((member) => member.wxid === selectedWxid) || filteredMembers[0] || null
if (!sourceContact || sourceContact.type !== 'group') return null
return (
<section className="report-section report-group-member-selector">
<h3></h3>
<p className="report-section-desc">
使
</p>
<div className="report-member-tools">
<input
type="search"
value={filter}
disabled={disabled || loading}
onChange={(event) => setFilter(event.target.value)}
placeholder="搜索成员或 wxid"
aria-label="搜索群成员"
/>
<select
value={selectedMember?.wxid || ''}
disabled={disabled || loading || !filteredMembers.length}
onChange={(event) => setSelectedWxid(event.target.value)}
aria-label="选择群成员"
>
{filteredMembers.length ? (
filteredMembers.map((member) => (
<option key={member.wxid} value={member.wxid}>
{displayMemberName(member)}
</option>
))
) : (
<option value=""></option>
)}
</select>
</div>
{loading && <p className="report-member-status">...</p>}
{error && <p className="report-member-status error">{error}</p>}
{selectedMember && (
<dl className="report-member-details">
<div>
<dt></dt>
<dd>{selectedMember.groupNickname || '未设置'}</dd>
</div>
<div>
<dt></dt>
<dd>{selectedMember.wechatNickname || '未读取到'}</dd>
</div>
<div>
<dt></dt>
<dd>{selectedMember.remark || '未设置'}</dd>
</div>
<div>
<dt>wxid</dt>
<dd className="report-member-wxid">{selectedMember.wxid}</dd>
</div>
</dl>
)}
</section>
)
}
@@ -1,31 +1,29 @@
import React, { useEffect, useState } from 'react'
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
import {
REPORT_TASK_STEPS,
ReportGenerationPhase,
VoiceTranscriptionProgress
} from '../../hooks/useGroupReportGeneration'
interface ReportTaskStatusPanelProps {
phase: ReportGenerationPhase
error: string
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
voiceTranscriptionEnabled: boolean
onRetry: () => void
}
const TASK_STEPS: Array<{
id: Exclude<ReportGenerationPhase, 'idle' | 'success' | 'error'>
label: string
}> = [
{ id: 'loadingMessages', label: '读取聊天记录' },
{ id: 'preparingInput', label: '整理日报输入' },
{ id: 'requestingModel', label: '调用模型生成内容' },
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
]
const phaseIndex = (phase: ReportGenerationPhase): number =>
TASK_STEPS.findIndex((step) => step.id === phase)
export function ReportTaskStatusPanel({
phase,
error,
voiceTranscriptionProgress,
voiceTranscriptionEnabled,
onRetry
}: ReportTaskStatusPanelProps): React.ReactElement {
const activeIndex = phaseIndex(phase)
const taskSteps = voiceTranscriptionEnabled
? REPORT_TASK_STEPS
: REPORT_TASK_STEPS.filter((step) => step.id !== 'transcribingVoice')
const activeIndex = taskSteps.findIndex((step) => step.id === phase)
const completedAll = phase === 'success'
const [logPath, setLogPath] = useState('')
@@ -46,12 +44,12 @@ export function ReportTaskStatusPanel({
: phase === 'error'
? '生成失败'
: activeIndex >= 0
? `${activeIndex + 1}/${TASK_STEPS.length}`
? `${activeIndex + 1}/${taskSteps.length}`
: '等待开始'}
</p>
</div>
<div className="report-task-steps">
{TASK_STEPS.map((step, index) => {
{taskSteps.map((step, index) => {
const state =
completedAll || (activeIndex >= 0 && index < activeIndex)
? 'done'
@@ -71,6 +69,25 @@ export function ReportTaskStatusPanel({
)
})}
</div>
{phase === 'transcribingVoice' && voiceTranscriptionProgress && (
<div className="report-voice-progress">
<div>
<span></span>
<strong>
{voiceTranscriptionProgress.processed}/{voiceTranscriptionProgress.total}
</strong>
</div>
<progress
value={voiceTranscriptionProgress.processed}
max={Math.max(1, voiceTranscriptionProgress.total)}
aria-label="语音转写进度"
/>
<small>
{voiceTranscriptionProgress.succeeded} {voiceTranscriptionProgress.failed}{' '}
</small>
</div>
)}
{phase === 'error' && (
<div className="report-task-error">
<b></b>
@@ -12,6 +12,14 @@ import {
SummaryMessageType
} from '../utils/group-report'
import { ReportTemplateId } from '../components/reports/ReportTemplateSelector'
import {
transcribeVoiceMessages as transcribeReportVoiceMessages,
type VoiceTranscriptionProgress
} from '../utils/voice-message-reference'
import type { VoiceModelStatus } from '../../../shared/voice-recognition'
import { resolveMemberName } from '../../../shared/member-names'
export type { VoiceTranscriptionProgress } from '../utils/voice-message-reference'
const REPORT_STEP_TIMEOUT_MS = 90_000
const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
@@ -19,6 +27,7 @@ const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
export type ReportGenerationPhase =
| 'idle'
| 'loadingMessages'
| 'transcribingVoice'
| 'preparingInput'
| 'requestingModel'
| 'exportingReport'
@@ -80,6 +89,7 @@ export interface ReportTaskStep {
export const REPORT_TASK_STEPS: ReportTaskStep[] = [
{ id: 'loadingMessages', label: '读取并筛选聊天记录' },
{ id: 'transcribingVoice', label: '转写语音消息' },
{ id: 'preparingInput', label: '整理日报输入' },
{ id: 'requestingModel', label: '调用模型生成内容' },
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
@@ -210,17 +220,10 @@ const applyGroupMemberNames = async (
const senderId = String(message.senderId || message.name || '')
const member = memberMap.get(senderId)
if (!member) return message
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
// Keep the three modes semantically distinct. `member.nickname` may
// already be a contact remark, so it must not leak into the first two.
groupNickname: [member.groupNickname, member.wechatNickname],
wechatNickname: [member.wechatNickname, member.groupNickname],
remark: [member.remark, member.groupNickname, member.wechatNickname, member.nickname]
}
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
const name = resolveMemberName({ ...member, wxid: senderId }, preference)
return {
...message,
name: name || (preference === 'remark' ? message.name : ''),
name: !isInternalName(name) ? name : preference === 'remark' ? message.name : '',
img: message.img || member.avatar
}
})
@@ -238,6 +241,7 @@ export function useGroupReportGeneration({
reportMessages: Message[]
messageTypeCounts: Record<SummaryMessageType, number>
rangeState: RangeMessageState
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
generatedImage: string | null
reportPaths: ReportPaths | null
generationMetadata: ReportGenerationMetadata
@@ -260,6 +264,8 @@ export function useGroupReportGeneration({
const [error, setError] = useState('')
const [rangeMessages, setRangeMessages] = useState<Message[]>([])
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
const [voiceTranscriptionProgress, setVoiceTranscriptionProgress] =
useState<VoiceTranscriptionProgress | null>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
@@ -290,6 +296,7 @@ export function useGroupReportGeneration({
const isGenerating =
phase === 'loadingMessages' ||
phase === 'transcribingVoice' ||
phase === 'preparingInput' ||
phase === 'requestingModel' ||
phase === 'exportingReport'
@@ -367,9 +374,26 @@ export function useGroupReportGeneration({
setError('')
setGeneratedImage(null)
setReportPaths(null)
setVoiceTranscriptionProgress(null)
setGenerationMetadata({ generationLogs: [] })
}, [])
const transcribeSelectedVoiceMessages = useCallback(
async (messages: Message[]): Promise<Message[]> => {
setVoiceTranscriptionProgress(null)
return transcribeReportVoiceMessages(messages, {
getModelStatus: () =>
withTimeout(
window.api.getVoiceModelStatus(),
'检查语音模型'
) as Promise<VoiceModelStatus>,
recognize: (reference) => withTimeout(window.api.recognizeVoice(reference), '语音转写'),
onProgress: setVoiceTranscriptionProgress
})
},
[]
)
const generate = useCallback(async (): Promise<void> => {
if (isGenerating) return
if (!sourceContact) {
@@ -446,12 +470,16 @@ export function useGroupReportGeneration({
filteredMessageCount: filteredMessages.length
})
setPhase('preparingInput')
setPhase(selectedTypes.has('语音') ? 'transcribingVoice' : 'preparingInput')
failedAt = '整理日报输入'
const input = await trackStep('整理输入', async () => {
const messagesWithTranscripts = selectedTypes.has('语音')
? await transcribeSelectedVoiceMessages(filteredMessages)
: filteredMessages
setPhase('preparingInput')
const namedReportMessages = await applyGroupMemberNames(
sourceContact,
filteredMessages,
messagesWithTranscripts,
memberNamePreference
)
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
@@ -610,7 +638,8 @@ export function useGroupReportGeneration({
sourceContact,
summaryDateRange,
summaryMessageTypes,
templateId
templateId,
transcribeSelectedVoiceMessages
])
const clearError = useCallback((): void => {
@@ -639,6 +668,7 @@ export function useGroupReportGeneration({
reportMessages,
messageTypeCounts,
rangeState,
voiceTranscriptionProgress,
generatedImage,
reportPaths,
generationMetadata,
+121
View File
@@ -109,6 +109,89 @@
}
}
.export-all-button {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 12px;
margin-top: 10px;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
text-align: left;
span {
display: grid;
min-width: 0;
gap: 1px;
}
strong {
font-size: 12px;
}
small {
color: var(--wxex-text-muted);
font-size: 10px;
}
b {
color: var(--wxex-text-secondary);
font-size: 11px;
}
&.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
}
.export-all-status {
padding: 9px 16px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 11px;
line-height: 17px;
}
.export-all-type-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
margin-top: 8px;
label {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
min-width: 0;
padding: 7px 8px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
cursor: pointer;
font-size: 11px;
}
input {
margin: 0;
accent-color: var(--wxex-brand);
}
b {
color: var(--wxex-text-muted);
font-size: 10px;
}
}
.export-contact-list {
flex: 1;
min-height: 0;
@@ -340,6 +423,21 @@
}
}
.export-all-chat-avatar {
border-color: #fff;
color: #fff;
font-size: 12px;
font-weight: 700;
&.group {
background: #2f7d5c;
}
&.user {
background: #416a8b;
}
}
.export-format-grid button:disabled {
cursor: not-allowed;
opacity: 0.45;
@@ -996,6 +1094,29 @@
font-weight: 500;
}
}
.export-current-target {
display: grid;
gap: 4px;
margin: 14px 0 4px;
padding: 10px 12px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: var(--wxex-bg-elevated);
span {
color: var(--wxex-text-muted);
font-size: 10px;
}
strong {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.export-progress-bar {
height: 6px;
overflow: hidden;
+86 -1
View File
@@ -649,6 +649,92 @@
font: 12px/18px var(--wxex-font);
}
.report-voice-progress {
margin-top: 14px;
padding: 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-main);
> div {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--wxex-text-secondary);
font-size: 12px;
}
progress {
display: block;
width: 100%;
height: 6px;
margin: 9px 0 6px;
}
small {
color: var(--wxex-text-muted);
font-size: 11px;
}
}
.report-member-tools {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(150px, 1.2fr);
gap: 8px;
margin-top: 10px;
input,
select {
min-width: 0;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
font: inherit;
padding: 8px 10px;
}
}
.report-member-status {
margin: 8px 0 0;
color: var(--wxex-text-muted);
font-size: 12px;
&.error {
color: var(--wxex-danger);
}
}
.report-member-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
margin: 12px 0 0;
div {
min-width: 0;
}
dt {
color: var(--wxex-text-muted);
font-size: 11px;
}
dd {
margin: 3px 0 0;
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-member-wxid {
color: var(--wxex-text-muted);
font-family: var(--wxex-font);
}
}
.report-task-error {
border: 1px solid rgba(200, 90, 90, 0.35);
background: #fff4f4;
@@ -1441,4 +1527,3 @@
overflow-y: auto;
padding: 0 10px 12px;
}
+10 -3
View File
@@ -103,11 +103,16 @@ export const summaryContent = (message: Message): string => {
case 'sticker':
return '[表情]'
case 'voice':
return `[语音${data.duration ? ` ${data.duration}` : ''}]`
return message.voiceTranscript?.trim()
? `[语音${data.duration ? ` ${data.duration}` : ''}] ${message.voiceTranscript.trim()}`
: `[语音${data.duration ? ` ${data.duration}` : ''}]`
case 'share':
return data.articles?.length
? `[分享] ${data.articles
.map((article) => `${article.title}${article.description ? `${article.description}` : ''}`)
.map(
(article) =>
`${article.title}${article.description ? `${article.description}` : ''}`
)
.join('')}`
: `[分享] ${data.title}${data.des ? `${data.des}` : ''}`
case 'quote': {
@@ -671,7 +676,9 @@ export const buildGroupReportFacts = async (
transcriptRows.every((row) => row.content === '[图片]') &&
!media.visionGallery?.length
) {
throw new Error('当前范围只有图片,但这些图片暂时无法分析。请改选文字消息,或在设置中验证图片理解能力。')
throw new Error(
'当前范围只有图片,但这些图片暂时无法分析。请改选文字消息,或在设置中验证图片理解能力。'
)
}
const factsPrompt = [
+1 -1
View File
@@ -958,7 +958,7 @@ export const SUMMARY_TYPE_OPTIONS: {
value: 'voice',
label: '语音',
messageTypes: ['语音'],
description: '当前不转写语音,仅参与数量和活跃度统计。'
description: '使用本地离线语音识别,将转写内容提供给日报模型。'
},
{
value: 'share',
@@ -0,0 +1,90 @@
import type { Message } from '../../../shared/types'
import type {
VoiceMessageReference,
VoiceModelStatus,
VoiceRecognitionResult
} from '../../../shared/voice-recognition'
export interface VoiceTranscriptionProgress {
processed: number
total: number
succeeded: number
failed: number
}
interface VoiceTranscriptionDependencies {
getModelStatus: () => Promise<VoiceModelStatus>
recognize: (reference: VoiceMessageReference) => Promise<VoiceRecognitionResult>
onProgress: (progress: VoiceTranscriptionProgress) => void
}
export function toVoiceMessageReference(message: Message): VoiceMessageReference | null {
if (
(message.type !== '语音' && message.contentData?.type !== 'voice') ||
!message.sessionId ||
message.localId === undefined ||
!message.createTime
) {
return null
}
return {
sessionId: message.sessionId,
localId: message.localId,
createTime: message.createTime,
svrId: message.serverId
}
}
export async function transcribeVoiceMessages(
messages: Message[],
dependencies: VoiceTranscriptionDependencies
): Promise<Message[]> {
const voiceItems = messages
.map((message, index) => ({ message, index, reference: toVoiceMessageReference(message) }))
.filter((item) => item.message.type === '语音' || item.message.contentData?.type === 'voice')
if (!voiceItems.length) return messages
const progress: VoiceTranscriptionProgress = {
processed: 0,
total: voiceItems.length,
succeeded: 0,
failed: 0
}
dependencies.onProgress({ ...progress })
const hasPendingVoice = voiceItems.some(
(item) => item.reference && !item.message.voiceTranscript?.trim()
)
if (hasPendingVoice) {
const modelStatus = await dependencies.getModelStatus()
if (modelStatus.state !== 'ready') {
throw new Error('请先在设置中准备离线语音识别模型,再生成包含语音转写的日报')
}
}
const result = messages.map((message) => ({ ...message }))
for (const item of voiceItems) {
const cachedTranscript = item.message.voiceTranscript?.trim()
if (cachedTranscript) {
result[item.index].voiceTranscript = cachedTranscript
progress.succeeded += 1
} else if (!item.reference) {
result[item.index].voiceTranscriptError = '语音标识不完整,无法定位本地语音'
progress.failed += 1
} else {
const recognition = await dependencies.recognize(item.reference)
const transcript = recognition.transcript?.trim()
if (recognition.success && transcript) {
result[item.index].voiceTranscript = transcript
result[item.index].voiceTranscriptError = undefined
progress.succeeded += 1
} else {
result[item.index].voiceTranscriptError = recognition.error || '语音转写失败'
progress.failed += 1
}
}
progress.processed += 1
dependencies.onProgress({ ...progress })
}
return result
}
+9
View File
@@ -13,6 +13,7 @@ export type ExportMessageKind =
| 'system'
export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname'
export type ExportContactType = 'group' | 'user'
export interface ExportTarget {
userMd5: string
@@ -26,6 +27,8 @@ export interface ExportTarget {
export interface ExportRequest {
jobId: string
scope?: 'selected' | 'all'
allContactTypes?: ExportContactType[]
targets: ExportTarget[]
format: ExportFormat
outputName: string
@@ -58,10 +61,16 @@ export interface ExportJobProgress {
percent?: number
outputPath?: string
error?: string
currentTargetIndex?: number
currentTargetCount?: number
currentTargetName?: string
currentTargetType?: ExportContactType
}
export interface ExportTaskRecord {
jobId: string
scope?: 'selected' | 'all'
allContactTypes?: ExportContactType[]
targetIds: string[]
targetNames: string[]
targetLabel: string
+28
View File
@@ -0,0 +1,28 @@
export type MemberNameMode = 'groupNickname' | 'wechatNickname' | 'remark'
export interface MemberNameFields {
wxid: string
nickname?: string
groupNickname?: string
wechatNickname?: string
remark?: string
}
const firstName = (...values: Array<string | undefined>): string =>
values.map((value) => String(value || '').trim()).find(Boolean) || ''
export function resolveMemberName(member: MemberNameFields, mode: MemberNameMode): string {
if (mode === 'groupNickname') {
return firstName(member.groupNickname, member.wechatNickname, member.wxid)
}
if (mode === 'wechatNickname') {
return firstName(member.wechatNickname, member.groupNickname, member.wxid)
}
return firstName(
member.remark,
member.wechatNickname,
member.groupNickname,
member.nickname,
member.wxid
)
}
@@ -9,6 +9,7 @@ const baseProps = {
previewBytes: 0,
selfInfo: null,
selectedCount: 1,
allExport: false,
jobId: 'fixture-job',
onCancel: vi.fn(),
onReveal: vi.fn()
@@ -70,4 +71,34 @@ describe('export progress panel', () => {
expect(progressbar).toHaveAttribute('aria-valuenow', '31')
expect(progressbar.querySelector('span')).toHaveStyle({ width: '31%' })
})
it('shows the current conversation and overall position for all export', () => {
render(
<ExportPreviewPanel
{...baseProps}
allExport
selectedCount={30}
progress={{
jobId: 'fixture-job',
phase: 'media',
processed: 5,
total: 12,
percent: 36,
currentTargetIndex: 11,
currentTargetCount: 30,
currentTargetName: '项目讨论群',
currentTargetType: 'group'
}}
includeVoiceTranscripts={false}
zip={false}
/>
)
expect(screen.getByText('群聊')).toBeVisible()
expect(screen.getByText('第 11/30 个:项目讨论群')).toBeVisible()
expect(screen.getByRole('progressbar', { name: '导出进度' })).toHaveAttribute(
'aria-valuenow',
'36'
)
})
})
@@ -52,4 +52,42 @@ describe('export task center', () => {
expect(writeText.mock.calls[0][0]).toContain('EPERM: operation not permitted, copyfile')
expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument()
})
it('shows the current conversation for a background all-export task', () => {
render(
<ExportTaskCenter
open
taskCount={1}
tasks={[
{
jobId: 'all-export',
scope: 'all',
allContactTypes: ['group', 'user'],
targetIds: [],
targetNames: [],
targetLabel: '全部 20 个聊天',
format: 'html',
status: 'running',
progress: {
jobId: 'all-export',
phase: 'media',
processed: 3,
total: 8,
percent: 42,
currentTargetIndex: 9,
currentTargetCount: 20,
currentTargetName: '项目群',
currentTargetType: 'group'
},
createdAt: Date.now()
}
]}
onToggle={vi.fn()}
onCancel={vi.fn()}
/>
)
expect(screen.getByText('第 9/20 个:项目群')).toBeVisible()
expect(screen.getByText('42%')).toBeVisible()
})
})
+87 -2
View File
@@ -38,7 +38,8 @@ describe('ExportWorkspace multi-chat selection', () => {
})
const renderWorkspace = (
onStartExport = vi.fn(async () => ({ success: false }))
onStartExport = vi.fn(async () => ({ success: false })),
exportTasks: ExportTaskRecord[] = []
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => {
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
render(
@@ -49,7 +50,7 @@ describe('ExportWorkspace multi-chat selection', () => {
dbReady
loadPreviewMessages={loadPreviewMessages}
onOpenSettings={vi.fn()}
exportTasks={[]}
exportTasks={exportTasks}
onStartExport={onStartExport}
onCancelExport={vi.fn(async () => undefined)}
/>
@@ -112,6 +113,90 @@ describe('ExportWorkspace multi-chat selection', () => {
expect(screen.getByText('已选 4 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: /聊天 F/ })).toBeEnabled()
})
it('exports every chat into its own format folder without loading every preview', async () => {
const onStartExport = vi.fn(async () => ({ success: false }))
const { loadPreviewMessages } = renderWorkspace(onStartExport)
await screen.findByText('聊天 A 的预览')
await userEvent.click(screen.getByRole('button', { name: /全部导出/ }))
expect(screen.getByText(/全部群聊 1 个和全部联系人 5 个/)).toBeVisible()
expect(screen.getByRole('button', { name: 'CSV' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'CSV' })).toHaveClass('active')
expect(screen.getByRole('button', { name: 'JSON' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Markdown' })).toBeEnabled()
expect(loadPreviewMessages).toHaveBeenCalledTimes(1)
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
expect(onStartExport.mock.calls[0][0]).toMatchObject({
scope: 'all',
allContactTypes: ['group', 'user'],
format: 'csv',
outputName: '全部聊天记录'
})
expect(onStartExport.mock.calls[0][0].targets).toHaveLength(contacts.length)
expect(window.api.getGroupSnapshot).not.toHaveBeenCalled()
})
it('allows all export to include only groups and replaces the single-chat avatars', async () => {
const onStartExport = vi.fn(async () => ({ success: false }))
renderWorkspace(onStartExport)
await userEvent.click(screen.getByRole('button', { name: /全部导出/ }))
expect(document.querySelector('.export-all-chat-avatar.group')).toHaveTextContent('群')
expect(document.querySelector('.export-all-chat-avatar.user')).toHaveTextContent('联')
await userEvent.click(screen.getByRole('checkbox', { name: '导出全部联系人' }))
expect(document.querySelector('.export-all-chat-avatar.user')).not.toBeInTheDocument()
expect(screen.getAllByText(/全部群聊 1 个/)).toHaveLength(2)
expect(screen.getByRole('button', { name: /聊天 A/ })).toHaveAttribute('aria-pressed', 'false')
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
expect(onStartExport.mock.calls[0][0]).toMatchObject({
scope: 'all',
allContactTypes: ['group'],
startTime: undefined,
endTime: undefined
})
expect(onStartExport.mock.calls[0][0].targets).toEqual([
expect.objectContaining({ userMd5: 'contact-3', type: 'group' })
])
})
it('restores a running all-export task after returning to the page', async () => {
const runningTask: ExportTaskRecord = {
jobId: 'background-all',
scope: 'all',
allContactTypes: ['group'],
targetIds: [],
targetNames: [],
targetLabel: '全部 1 个聊天',
format: 'html',
status: 'running',
progress: {
jobId: 'background-all',
phase: 'media',
processed: 4,
total: 10,
percent: 48,
currentTargetIndex: 1,
currentTargetCount: 1,
currentTargetName: '聊天 C',
currentTargetType: 'group'
},
createdAt: Date.now()
}
const { loadPreviewMessages } = renderWorkspace(undefined, [runningTask])
expect(await screen.findByText('第 1/1 个:聊天 C')).toBeVisible()
expect(screen.getByRole('checkbox', { name: '导出全部群聊' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: '导出全部联系人' })).not.toBeChecked()
expect(document.querySelector('.export-all-chat-avatar.group')).toHaveTextContent('群')
expect(document.querySelector('.export-all-chat-avatar.user')).not.toBeInTheDocument()
expect(loadPreviewMessages).not.toHaveBeenCalled()
})
})
describe('ExportTaskCenter details', () => {
+81
View File
@@ -0,0 +1,81 @@
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
import type { Contact } from '../../src/shared/types'
const groupContact: Contact = {
md5: 'group-md5',
m_nsUsrName: 'group@chatroom',
m_nsNickName: '测试群',
type: 'group'
}
describe('daily report controls', () => {
beforeEach(() => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getAppLogPath: vi.fn(async () => ''),
revealAppLog: vi.fn(async () => undefined),
getGroupSnapshot: vi.fn(async () => ({
members: [
{
wxid: 'wxid-one',
nickname: '兼容名称一',
groupNickname: '群内昵称一',
wechatNickname: '微信昵称一',
remark: '通讯录备注一',
avatar: ''
},
{
wxid: 'wxid-two',
nickname: '兼容名称二',
groupNickname: '群内昵称二',
wechatNickname: '微信昵称二',
remark: '通讯录备注二',
avatar: ''
}
]
}))
}
})
})
it('shows a separate voice progress bar only when voice is selected', () => {
const progress = { processed: 2, total: 3, succeeded: 2, failed: 0 }
const { rerender } = render(
<ReportTaskStatusPanel
phase="transcribingVoice"
error=""
voiceTranscriptionProgress={progress}
voiceTranscriptionEnabled
onRetry={vi.fn()}
/>
)
expect(screen.getByText('2/5')).toBeVisible()
expect(screen.getByRole('progressbar', { name: '语音转写进度' })).toHaveAttribute('value', '2')
rerender(
<ReportTaskStatusPanel
phase="preparingInput"
error=""
voiceTranscriptionProgress={null}
voiceTranscriptionEnabled={false}
onRetry={vi.fn()}
/>
)
expect(screen.queryByText('转写语音消息')).not.toBeInTheDocument()
expect(screen.getByText('2/4')).toBeVisible()
})
it('loads and displays group nickname, WeChat nickname, and remark separately', async () => {
render(<ReportGroupMemberSelector sourceContact={groupContact} />)
await waitFor(() => expect(screen.getAllByText('群内昵称一')).toHaveLength(2))
expect(screen.getByText('微信昵称一')).toBeVisible()
expect(screen.getByText('通讯录备注一')).toBeVisible()
expect(screen.getByText('wxid-one')).toBeVisible()
})
})
+231 -6
View File
@@ -27,6 +27,21 @@ const state = vi.hoisted(() => ({
messages: [] as Message[],
messagesByUser: {} as Record<string, Message[]>,
exportReads: [] as string[],
selfInfoReads: 0,
groupSnapshotReads: [] as string[],
groupSnapshots: {} as Record<
string,
{
members: Array<{
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}>
}
>,
voiceLookups: [] as number[],
videoLookups: [] as {
createTime?: number
@@ -87,12 +102,19 @@ vi.mock('../../src/main/services/chat-service', () => ({
})
}),
getContactAvatars: () => ({ ...state.avatarMap }),
getSelfAccountInfoAsync: async () => ({
wxid: 'a969409112',
nickname: '濑岛田井卫',
avatar: state.selfAvatar,
accountRoot: state.accountRoot
})
getGroupSnapshotAsync: async (userMd5: string) => {
state.groupSnapshotReads.push(userMd5)
return structuredClone(state.groupSnapshots[userMd5] || null)
},
getSelfAccountInfoAsync: async () => {
state.selfInfoReads += 1
return {
wxid: 'a969409112',
nickname: '濑岛田井卫',
avatar: state.selfAvatar,
accountRoot: state.accountRoot
}
}
}))
vi.mock('../../src/main/services/image-key-config-service', () => ({
ImageKeyConfigService: class {
@@ -238,6 +260,9 @@ describe('media export flow', () => {
state.videoLookups = []
state.messagesByUser = {}
state.exportReads = []
state.selfInfoReads = 0
state.groupSnapshotReads = []
state.groupSnapshots = {}
state.voiceLookups = []
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
mkdirSync(fileMonth, { recursive: true })
@@ -924,6 +949,206 @@ describe('media export flow', () => {
}
})
it('exports more than five chats in all scope and refreshes a changing conversation set', async () => {
const { runExport } = await import('../../src/main/export-service')
const progress: Array<{ phase: string; currentTargetName?: string; percent?: number }> = []
const win = {
isDestroyed: () => false,
webContents: {
send: (
_channel: string,
item: { phase: string; currentTargetName?: string; percent?: number }
) => progress.push(item)
}
}
const targets: ExportTarget[] = Array.from({ length: 6 }, (_, index) => ({
userMd5: `all-${index + 1}`,
name: `聊天 ${index + 1}`,
type: index === 5 ? 'group' : 'user',
nameMode: 'groupNickname'
}))
state.messagesByUser = Object.fromEntries(
targets.map((item, index) => [
item.userMd5,
[
message({
id: `message-${index + 1}`,
senderId: index === 5 ? 'wxid-group-member' : `wxid-${index + 1}`,
content: `会话 ${index + 1}`,
createTime: 100 + index
})
]
])
)
state.groupSnapshots['all-6'] = {
members: [
{
wxid: 'wxid-group-member',
nickname: '兼容名称',
groupNickname: '群内名称',
wechatNickname: '微信名称',
remark: '通讯录备注',
avatar: ''
}
]
}
const request = {
scope: 'all' as const,
targets,
format: 'html' as const,
outputName: 'all-conversations',
kinds: ['text'] as const,
includeMedia: false
}
const legacyOutputDir = join(state.documents, 'WechatExplorer', '导出', 'all-conversations')
mkdirSync(join(legacyOutputDir, 'data'), { recursive: true })
writeFileSync(join(legacyOutputDir, 'index.html'), 'legacy combined archive')
writeFileSync(join(legacyOutputDir, 'data', 'messages.js'), 'legacy data')
const first = await runExport(
{ ...request, jobId: 'all-conversations-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
expect(first.messageCount).toBe(6)
const outputDir = first.outputPath!
const firstUserArchive = readArchive(join(outputDir, '联系人', '聊天 1', 'index.html'))
const firstGroupArchive = readArchive(join(outputDir, '群聊', '聊天 6', 'index.html'))
expect(firstUserArchive.conversations).toHaveLength(1)
expect(firstGroupArchive.messages.find((item) => item.id === 'message-6')?.name).toBe(
'群内名称'
)
expect(state.groupSnapshotReads).toEqual(['all-6'])
expect(state.selfInfoReads).toBe(1)
const manifest = JSON.parse(readFileSync(join(outputDir, '导出清单.json'), 'utf8')) as {
conversations: Array<{ id: string }>
}
expect(manifest.conversations).toHaveLength(6)
expect(readFileSync(join(outputDir, '旧版合并档案', 'index.html'), 'utf8')).toBe(
'legacy combined archive'
)
expect(existsSync(join(outputDir, '群聊', '聊天 6', 'data', 'messages.js'))).toBe(true)
expect(existsSync(join(outputDir, '联系人', '聊天 1', 'data', 'messages.js'))).toBe(true)
expect(progress.some((item) => item.currentTargetName === '聊天 6')).toBe(true)
expect(progress.at(-1)).toMatchObject({ phase: 'completed', percent: 100 })
const second = await runExport(
{
...request,
jobId: 'all-conversations-second',
targets: targets.slice(0, 5),
kinds: [...request.kinds]
},
win as never
)
expect(second.success, second.error).toBe(true)
const secondManifest = JSON.parse(
readFileSync(join(second.outputPath!, '导出清单.json'), 'utf8')
) as { conversations: Array<{ id: string }> }
expect(secondManifest.conversations).toHaveLength(5)
expect(secondManifest.conversations.some((item) => item.id === 'all-6')).toBe(false)
})
it('writes every all-export CSV inside its group or contact conversation folder', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = {
isDestroyed: () => false,
webContents: { send: vi.fn() }
}
const targets: ExportTarget[] = [
{ ...target('csv-group', '测试群聊'), type: 'group' },
target('csv-user', '测试联系人')
]
state.messagesByUser = {
'csv-group': [message({ id: 'group-text', content: '群聊消息' })],
'csv-user': [message({ id: 'user-text', content: '联系人消息' })]
}
const result = await runExport(
{
jobId: 'all-conversations-csv',
scope: 'all',
allContactTypes: ['group', 'user'],
targets,
format: 'csv',
outputName: '全部聊天记录',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result.success, result.error).toBe(true)
const outputDir = result.outputPath!
const groupDir = join(outputDir, '群聊', '测试群聊')
const userDir = join(outputDir, '联系人', '测试联系人')
const groupFiles = readdirSync(groupDir)
const userFiles = readdirSync(userDir)
expect(groupFiles).toHaveLength(1)
expect(userFiles).toHaveLength(1)
expect(groupFiles[0]).toMatch(/^测试群聊_\d{8}_\d{6}\.csv$/)
expect(userFiles[0]).toMatch(/^测试联系人_\d{8}_\d{6}\.csv$/)
expect(readFileSync(join(groupDir, groupFiles[0]), 'utf8')).toContain('群聊消息')
expect(readFileSync(join(userDir, userFiles[0]), 'utf8')).toContain('联系人消息')
expect(existsSync(join(outputDir, 'index.html'))).toBe(false)
})
it('cancels an all-export task between conversations without starting the next database read', async () => {
const { cancelExport, runExport } = await import('../../src/main/export-service')
const jobId = 'all-export-cancel'
const targets = [
target('cancel-1', '联系人一'),
target('cancel-2', '联系人二'),
target('cancel-3', '联系人三')
]
state.messagesByUser = Object.fromEntries(
targets.map((item, index) => [
item.userMd5,
[message({ id: `cancel-message-${index}`, content: item.name })]
])
)
const win = {
isDestroyed: () => false,
webContents: {
send: (
_channel: string,
progress: { phase: string; currentTargetIndex?: number }
): void => {
if (progress.phase === 'reading' && progress.currentTargetIndex === 2) {
cancelExport(jobId)
}
}
}
}
const result = await runExport(
{
jobId,
scope: 'all',
allContactTypes: ['user'],
targets,
format: 'html',
outputName: 'cancelled-all-conversations',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result).toEqual({ success: false, error: '已取消' })
expect(state.exportReads).toEqual(['cancel-1'])
const outputDir = join(state.documents, 'WechatExplorer', '导出', 'cancelled-all-conversations')
expect(existsSync(join(outputDir, '联系人', '联系人一', 'index.html'))).toBe(true)
expect(existsSync(join(outputDir, '联系人', '联系人二', 'index.html'))).toBe(false)
const partialManifest = JSON.parse(readFileSync(join(outputDir, '导出清单.json'), 'utf8')) as {
status: string
conversations: Array<{ id: string }>
}
expect(partialManifest.status).toBe('cancelled')
expect(partialManifest.conversations.map((item) => item.id)).toEqual(['cancel-1'])
})
it('creates a replaceable ZIP containing the complete top-level archive folder', async () => {
const { runExport } = await import('../../src/main/export-service')
const progress: unknown[][] = []
+17
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import { parseGroupDailyReport } from '../../src/renderer/src/utils/group-report'
import { summaryContent } from '../../src/renderer/src/utils/group-report-facts'
import type { GroupReportMetadata } from '../../src/shared/group-report'
import type { Message } from '../../src/shared/types'
const metadata: GroupReportMetadata = {
groupName: '测试群',
@@ -23,6 +25,21 @@ const media = {
}
describe('group report parsing', () => {
it('includes a cached voice transcript in the report input content', () => {
const message: Message = {
id: 'voice-1',
from: 'member',
type: '语音',
datetime: '2026-08-06 10:00:00',
content: '[语音]',
isSender: false,
contentData: { type: 'voice', duration: 3 },
voiceTranscript: '今晚八点确认发布。'
}
expect(summaryContent(message)).toContain('今晚八点确认发布。')
})
it('falls back to topic keywords when the model omits top-level keywords', () => {
const report = parseGroupDailyReport(
JSON.stringify({
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { resolveMemberName } from '../../src/shared/member-names'
const member = {
wxid: 'wxid-member',
nickname: '兼容名称',
groupNickname: '群内名称',
wechatNickname: '微信名称',
remark: '通讯录备注'
}
describe('member name resolution', () => {
it('keeps group nickname and WeChat nickname modes independent from remarks', () => {
expect(resolveMemberName(member, 'groupNickname')).toBe('群内名称')
expect(resolveMemberName(member, 'wechatNickname')).toBe('微信名称')
expect(resolveMemberName(member, 'remark')).toBe('通讯录备注')
})
it('does not leak whitespace and uses the wxid when the selected source is empty', () => {
expect(
resolveMemberName(
{ ...member, groupNickname: ' ', wechatNickname: '', remark: '不能串用的备注' },
'groupNickname'
)
).toBe('wxid-member')
})
})
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
import type { VoiceModelStatus } from '../../src/shared/voice-recognition'
import {
toVoiceMessageReference,
transcribeVoiceMessages,
type VoiceTranscriptionProgress
} from '../../src/renderer/src/utils/voice-message-reference'
const status = (state: VoiceModelStatus['state']): VoiceModelStatus =>
({
modelId: 'fixture',
version: '1',
state,
downloadedBytes: 1,
totalBytes: 1,
progress: 1,
platform: 'win32',
architecture: 'x64',
supported: true
}) as VoiceModelStatus
const voice = (id: string, localId: number, transcript?: string): Message => ({
id,
from: 'member',
type: '语音',
datetime: '2026-08-10 10:00:00',
content: '[语音]',
isSender: false,
sessionId: 'session',
localId,
createTime: 1_786_320_000,
contentData: { type: 'voice', duration: 2 },
voiceTranscript: transcript
})
describe('daily report voice transcription', () => {
it('creates a local voice reference from a parsed message', () => {
expect(toVoiceMessageReference(voice('voice-1', 7))).toEqual({
sessionId: 'session',
localId: 7,
createTime: 1_786_320_000,
svrId: undefined
})
})
it('reuses cached text and reports success/failure progress', async () => {
const recognize = vi
.fn()
.mockResolvedValueOnce({ success: true, transcript: '新转写内容' })
.mockResolvedValueOnce({ success: false, error: '音频缺失' })
const onProgress = vi.fn<(progress: VoiceTranscriptionProgress) => void>()
const result = await transcribeVoiceMessages(
[voice('cached', 1, '缓存内容'), voice('fresh', 2), voice('failed', 3)],
{
getModelStatus: vi.fn(async () => status('ready')),
recognize,
onProgress
}
)
expect(result.map((message) => message.voiceTranscript)).toEqual([
'缓存内容',
'新转写内容',
undefined
])
expect(result[2].voiceTranscriptError).toBe('音频缺失')
expect(recognize).toHaveBeenCalledTimes(2)
expect(onProgress).toHaveBeenLastCalledWith({
processed: 3,
total: 3,
succeeded: 2,
failed: 1
})
})
it('does not require the model when every transcript is cached', async () => {
const getModelStatus = vi.fn(async () => status('missing'))
const recognize = vi.fn()
const result = await transcribeVoiceMessages([voice('cached', 1, '已有文本')], {
getModelStatus,
recognize,
onProgress: vi.fn()
})
expect(result[0].voiceTranscript).toBe('已有文本')
expect(getModelStatus).not.toHaveBeenCalled()
expect(recognize).not.toHaveBeenCalled()
})
it('stops before recognition when the local model is not ready', async () => {
const recognize = vi.fn()
await expect(
transcribeVoiceMessages([voice('pending', 1)], {
getModelStatus: vi.fn(async () => status('missing')),
recognize,
onProgress: vi.fn()
})
).rejects.toThrow('准备离线语音识别模型')
expect(recognize).not.toHaveBeenCalled()
})
it('counts a voice message with incomplete local identifiers as failed', async () => {
const incomplete = { ...voice('incomplete', 1), sessionId: undefined }
const onProgress = vi.fn<(progress: VoiceTranscriptionProgress) => void>()
const result = await transcribeVoiceMessages([incomplete], {
getModelStatus: vi.fn(async () => status('ready')),
recognize: vi.fn(),
onProgress
})
expect(result[0].voiceTranscriptError).toContain('标识不完整')
expect(onProgress).toHaveBeenLastCalledWith({
processed: 1,
total: 1,
succeeded: 0,
failed: 1
})
})
})