mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 完善日报语音转写与全部聊天分目录导出
This commit is contained in:
+365
-20
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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[][] = []
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user