mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 完善日报语音转写与全部聊天分目录导出
This commit is contained in:
+364
-19
@@ -21,9 +21,10 @@ import { VideoAssetService } from './video-asset-service'
|
|||||||
import { StickerService } from './sticker-service'
|
import { StickerService } from './sticker-service'
|
||||||
import { getImageExportAttempts } from '../shared/export-media'
|
import { getImageExportAttempts } from '../shared/export-media'
|
||||||
import { FileAssetService } from './file-asset-service'
|
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 type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||||
import { imageFileQuality } from '../shared/image-quality'
|
import { imageFileQuality } from '../shared/image-quality'
|
||||||
|
import { resolveMemberName } from '../shared/member-names'
|
||||||
|
|
||||||
const jobs = new Set<string>()
|
const jobs = new Set<string>()
|
||||||
const activeArchives = new Map<string, Archiver>()
|
const activeArchives = new Map<string, Archiver>()
|
||||||
@@ -321,7 +322,8 @@ export function stripHtmlArchiveInlineAvatars(messages: Message[]): Message[] {
|
|||||||
export async function readHtmlArchive(
|
export async function readHtmlArchive(
|
||||||
outputDir: string,
|
outputDir: string,
|
||||||
targets: ExportTarget[],
|
targets: ExportTarget[],
|
||||||
name: string
|
name: string,
|
||||||
|
allowTargetChanges = false
|
||||||
): Promise<HtmlExportArchive> {
|
): Promise<HtmlExportArchive> {
|
||||||
const dataPath = join(outputDir, 'data', 'messages.js')
|
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||||
let source = ''
|
let source = ''
|
||||||
@@ -383,7 +385,7 @@ export async function readHtmlArchive(
|
|||||||
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
|
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
|
||||||
.map((conversation) => conversation.id)
|
.map((conversation) => conversation.id)
|
||||||
.sort()
|
.sort()
|
||||||
if (actualIds.join('|') !== expectedIds.join('|')) {
|
if (!allowTargetChanges && actualIds.join('|') !== expectedIds.join('|')) {
|
||||||
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -463,6 +465,20 @@ export async function writeHtmlArchive(
|
|||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
}
|
||||||
const source = `${archiveDataPrefix}${JSON.stringify(archive)};\n`
|
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')
|
await fs.writeFile(temporaryPath, source, 'utf8')
|
||||||
try {
|
try {
|
||||||
await fs.rename(temporaryPath, dataPath)
|
await fs.rename(temporaryPath, dataPath)
|
||||||
@@ -624,19 +640,129 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
|||||||
].join('\n')
|
].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,
|
request: ExportRequest,
|
||||||
win: BrowserWindow,
|
win: BrowserWindow,
|
||||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
|
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>,
|
||||||
|
options: SingleExportOptions = {}
|
||||||
): Promise<ExportResult> {
|
): Promise<ExportResult> {
|
||||||
jobs.add(request.jobId)
|
const manageJob = options.manageJob !== false
|
||||||
const send = (p: ExportJobProgress): void => {
|
if (manageJob) jobs.add(request.jobId)
|
||||||
|
const send =
|
||||||
|
options.sendProgress ||
|
||||||
|
((p: ExportJobProgress): void => {
|
||||||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||||
}
|
})
|
||||||
try {
|
try {
|
||||||
const targets = request.targets || []
|
const targets = (request.targets || []).map((target) => ({
|
||||||
if (targets.length < 1 || targets.length > 5) {
|
...target,
|
||||||
throw new Error('一次导出必须选择 1 到 5 个聊天')
|
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) {
|
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
|
||||||
throw new Error('导出聊天不能重复')
|
throw new Error('导出聊天不能重复')
|
||||||
@@ -654,6 +780,25 @@ export async function runExport(
|
|||||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
|
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
|
||||||
return { success: false, error: '已取消' }
|
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 = (
|
const targetMessages = (
|
||||||
await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime)
|
await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime)
|
||||||
).filter((message) => request.kinds.includes(kindOf(message)))
|
).filter((message) => request.kinds.includes(kindOf(message)))
|
||||||
@@ -684,8 +829,12 @@ export async function runExport(
|
|||||||
return left.messageOrder - right.messageOrder
|
return left.messageOrder - right.messageOrder
|
||||||
})
|
})
|
||||||
.map((entry) => entry.message)
|
.map((entry) => entry.message)
|
||||||
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
const selfInfo =
|
||||||
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
options.selfInfo !== undefined
|
||||||
|
? options.selfInfo
|
||||||
|
: await chat
|
||||||
|
.getSelfAccountInfoAsync()
|
||||||
|
.then((value) => (value ? mergeCachedSelfInfo(value.accountRoot, value) : null))
|
||||||
const client = chat.getChatDb()?.getWcdb4Client()
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
const isUsableSelfName = (value: string | undefined): value is string => {
|
const isUsableSelfName = (value: string | undefined): value is string => {
|
||||||
const name = String(value || '').trim()
|
const name = String(value || '').trim()
|
||||||
@@ -759,12 +908,12 @@ export async function runExport(
|
|||||||
total: messages.length,
|
total: messages.length,
|
||||||
percent: request.format === 'html' ? 18 : 20
|
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 })
|
await fs.mkdir(root, { recursive: true })
|
||||||
const ext = request.format === 'markdown' ? 'md' : request.format
|
const ext = request.format === 'markdown' ? 'md' : request.format
|
||||||
const outputFolder =
|
const outputFolder =
|
||||||
request.format === 'html'
|
request.format === 'html'
|
||||||
? safeFilePart(request.outputName)
|
? options.outputFolderName || safeFilePart(request.outputName)
|
||||||
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
: `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||||
const outputDir = join(root, outputFolder)
|
const outputDir = join(root, outputFolder)
|
||||||
const outputPath =
|
const outputPath =
|
||||||
@@ -772,12 +921,24 @@ export async function runExport(
|
|||||||
? join(outputDir, 'index.html')
|
? join(outputDir, 'index.html')
|
||||||
: join(root, `${outputFolder}.${ext}`)
|
: join(root, `${outputFolder}.${ext}`)
|
||||||
if (request.format === 'html') {
|
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(
|
const previousMessagesByKey = new Map(
|
||||||
previousArchive.messages.map((message) => [exportMessageKey(message), message])
|
previousMessages.map((message) => [exportMessageKey(message), message])
|
||||||
)
|
)
|
||||||
const latestPreviousAvatarUrls = new Map<string, string>()
|
const latestPreviousAvatarUrls = new Map<string, string>()
|
||||||
for (const message of previousArchive.messages) {
|
for (const message of previousMessages) {
|
||||||
if (!message.exportAvatarUrl) continue
|
if (!message.exportAvatarUrl) continue
|
||||||
const conversationId = message.exportConversationId || targets[0].userMd5
|
const conversationId = message.exportConversationId || targets[0].userMd5
|
||||||
latestPreviousAvatarUrls.set(
|
latestPreviousAvatarUrls.set(
|
||||||
@@ -1232,7 +1393,7 @@ export async function runExport(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const mergedMessages = mergeHtmlArchiveMessages(
|
const mergedMessages = mergeHtmlArchiveMessages(
|
||||||
previousArchive.messages,
|
previousMessages,
|
||||||
messages,
|
messages,
|
||||||
'',
|
'',
|
||||||
targets.map((target) => target.userMd5)
|
targets.map((target) => target.userMd5)
|
||||||
@@ -1319,10 +1480,194 @@ export async function runExport(
|
|||||||
}
|
}
|
||||||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||||
return { success: false, 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 {
|
} finally {
|
||||||
jobs.delete(request.jobId)
|
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 {
|
export function cancelExport(jobId: string): void {
|
||||||
jobs.delete(jobId)
|
jobs.delete(jobId)
|
||||||
activeArchives.get(jobId)?.abort()
|
activeArchives.get(jobId)?.abort()
|
||||||
|
|||||||
@@ -1707,7 +1707,13 @@ export class Wcdb4Client {
|
|||||||
'contactRemark',
|
'contactRemark',
|
||||||
'contact_remark'
|
'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, [
|
const avatar = this.pickString(row, [
|
||||||
'avatarUrl',
|
'avatarUrl',
|
||||||
'avatar_url',
|
'avatar_url',
|
||||||
@@ -1721,8 +1727,8 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
m_nsUsrName: username,
|
m_nsUsrName: username,
|
||||||
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
nickname: wechatNickname || memberNickname || remark || rowGroupNickname,
|
||||||
groupNickname: groupNicknames.get(username) || '',
|
groupNickname: groupNicknames.get(username) || rowGroupNickname,
|
||||||
wechatNickname: wechatNickname || memberNickname,
|
wechatNickname: wechatNickname || memberNickname,
|
||||||
remark,
|
remark,
|
||||||
m_nsHeadImgUrl: avatar
|
m_nsHeadImgUrl: avatar
|
||||||
@@ -1843,7 +1849,13 @@ export class Wcdb4Client {
|
|||||||
'contactRemark',
|
'contactRemark',
|
||||||
'contact_remark'
|
'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, [
|
const avatar = this.pickString(row, [
|
||||||
'avatarUrl',
|
'avatarUrl',
|
||||||
'avatar_url',
|
'avatar_url',
|
||||||
@@ -1853,8 +1865,8 @@ export class Wcdb4Client {
|
|||||||
if (username && avatar) this.avatarCache.set(username, avatar)
|
if (username && avatar) this.avatarCache.set(username, avatar)
|
||||||
return {
|
return {
|
||||||
m_nsUsrName: username,
|
m_nsUsrName: username,
|
||||||
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
nickname: wechatNickname || memberNickname || remark || rowGroupNickname,
|
||||||
groupNickname: groupNicknames.get(username) || '',
|
groupNickname: groupNicknames.get(username) || rowGroupNickname,
|
||||||
wechatNickname: wechatNickname || memberNickname,
|
wechatNickname: wechatNickname || memberNickname,
|
||||||
remark,
|
remark,
|
||||||
m_nsHeadImgUrl: avatar
|
m_nsHeadImgUrl: avatar
|
||||||
|
|||||||
@@ -378,12 +378,19 @@ function App(): React.ReactElement {
|
|||||||
const handleStartExport = async (
|
const handleStartExport = async (
|
||||||
request: ExportRequest
|
request: ExportRequest
|
||||||
): Promise<import('../../shared/export').ExportResult> => {
|
): Promise<import('../../shared/export').ExportResult> => {
|
||||||
const targetNames = request.targets.map((target) => target.name)
|
const exportAll = request.scope === 'all'
|
||||||
const targetLabel =
|
const targetCount = request.targets.length
|
||||||
targetNames.length > 1 ? `${targetNames[0]} 等 ${targetNames.length} 个聊天` : targetNames[0]
|
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 = {
|
const task: ExportTaskRecord = {
|
||||||
jobId: request.jobId,
|
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,
|
targetNames,
|
||||||
targetLabel,
|
targetLabel,
|
||||||
format: request.format,
|
format: request.format,
|
||||||
@@ -1677,6 +1684,8 @@ function App(): React.ReactElement {
|
|||||||
<ReportTaskStatusPanel
|
<ReportTaskStatusPanel
|
||||||
phase={reportGeneration.phase}
|
phase={reportGeneration.phase}
|
||||||
error={reportGeneration.error}
|
error={reportGeneration.error}
|
||||||
|
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
|
||||||
|
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
reportGeneration.resetGenerationStatus()
|
reportGeneration.resetGenerationStatus()
|
||||||
void reportGeneration.retry()
|
void reportGeneration.retry()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import type { ExportContactType } from '../../../../shared/export'
|
||||||
import type { Contact, SelfInfo } from './exportTypes'
|
import type { Contact, SelfInfo } from './exportTypes'
|
||||||
import { displayName } from './exportUtils'
|
import { displayName } from './exportUtils'
|
||||||
|
|
||||||
@@ -8,6 +9,9 @@ interface ExportContactPanelProps {
|
|||||||
activeContact: Contact | null
|
activeContact: Contact | null
|
||||||
selectedContactIds: string[]
|
selectedContactIds: string[]
|
||||||
selectionMode: boolean
|
selectionMode: boolean
|
||||||
|
exportAll: boolean
|
||||||
|
allContactTypes: ExportContactType[]
|
||||||
|
exportRunning: boolean
|
||||||
selectionLimit: number
|
selectionLimit: number
|
||||||
selfInfo: SelfInfo | null
|
selfInfo: SelfInfo | null
|
||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
@@ -17,6 +21,8 @@ interface ExportContactPanelProps {
|
|||||||
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
|
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
|
||||||
onSelectContact: (contact: Contact) => void
|
onSelectContact: (contact: Contact) => void
|
||||||
onCompleteSelection: () => void
|
onCompleteSelection: () => void
|
||||||
|
onExportAll: () => void
|
||||||
|
onToggleAllContactType: (type: ExportContactType) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +32,9 @@ export function ExportContactPanel({
|
|||||||
activeContact,
|
activeContact,
|
||||||
selectedContactIds,
|
selectedContactIds,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
|
exportAll,
|
||||||
|
allContactTypes,
|
||||||
|
exportRunning,
|
||||||
selectionLimit,
|
selectionLimit,
|
||||||
selfInfo,
|
selfInfo,
|
||||||
dbReady,
|
dbReady,
|
||||||
@@ -35,8 +44,16 @@ export function ExportContactPanel({
|
|||||||
onContactTypeChange,
|
onContactTypeChange,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onCompleteSelection,
|
onCompleteSelection,
|
||||||
|
onExportAll,
|
||||||
|
onToggleAllContactType,
|
||||||
onOpenSettings
|
onOpenSettings
|
||||||
}: ExportContactPanelProps): React.ReactElement {
|
}: 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 (
|
return (
|
||||||
<aside className="export-contact-panel">
|
<aside className="export-contact-panel">
|
||||||
<div className="export-panel-header">
|
<div className="export-panel-header">
|
||||||
@@ -71,9 +88,55 @@ export function ExportContactPanel({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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">
|
<div className="export-multi-select-bar">
|
||||||
<span>
|
<span>
|
||||||
已选 {selectedContactIds.length} / {selectionLimit} 个
|
已选 {selectedContactIds.length} / {selectionLimit} 个
|
||||||
@@ -82,21 +145,24 @@ export function ExportContactPanel({
|
|||||||
完成
|
完成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<div className="export-contact-list">
|
<div className="export-contact-list">
|
||||||
{filteredContacts.map((contact) => {
|
{filteredContacts.map((contact) => {
|
||||||
const name = displayName(contact)
|
const name = displayName(contact)
|
||||||
const selected = selectedContactIds.includes(contact.md5)
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={contact.md5}
|
key={contact.md5}
|
||||||
type="button"
|
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)}
|
onClick={() => onSelectContact(contact)}
|
||||||
disabled={atLimit}
|
disabled={atLimit}
|
||||||
aria-pressed={selected}
|
aria-pressed={visuallySelected}
|
||||||
>
|
>
|
||||||
<span className="export-contact-avatar">
|
<span className="export-contact-avatar">
|
||||||
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
||||||
@@ -105,7 +171,7 @@ export function ExportContactPanel({
|
|||||||
<strong>{name}</strong>
|
<strong>{name}</strong>
|
||||||
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||||
</span>
|
</span>
|
||||||
{selectionMode && (
|
{!exportAll && selectionMode && (
|
||||||
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
|
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
|
||||||
{selected ? '✓' : ''}
|
{selected ? '✓' : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface ExportPreviewPanelProps {
|
|||||||
includeVoiceTranscripts: boolean
|
includeVoiceTranscripts: boolean
|
||||||
zip: boolean
|
zip: boolean
|
||||||
selectedCount: number
|
selectedCount: number
|
||||||
|
allExport: boolean
|
||||||
jobId: string
|
jobId: string
|
||||||
onCancel: (jobId: string) => void
|
onCancel: (jobId: string) => void
|
||||||
onReveal: (path: string) => void
|
onReveal: (path: string) => void
|
||||||
@@ -28,6 +29,7 @@ export function ExportPreviewPanel({
|
|||||||
includeVoiceTranscripts,
|
includeVoiceTranscripts,
|
||||||
zip,
|
zip,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
|
allExport,
|
||||||
jobId,
|
jobId,
|
||||||
onCancel,
|
onCancel,
|
||||||
onReveal
|
onReveal
|
||||||
@@ -61,6 +63,9 @@ export function ExportPreviewPanel({
|
|||||||
: phase === 'parsing'
|
: phase === 'parsing'
|
||||||
? `正在解析消息内容... ${percent}%`
|
? `正在解析消息内容... ${percent}%`
|
||||||
: `正在读取消息... ${percent}%`
|
: `正在读取消息... ${percent}%`
|
||||||
|
const currentTargetText = progress?.currentTargetName
|
||||||
|
? `第 ${progress.currentTargetIndex || 1}/${progress.currentTargetCount || selectedCount} 个:${progress.currentTargetName}`
|
||||||
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
|
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
|
||||||
@@ -69,12 +74,28 @@ export function ExportPreviewPanel({
|
|||||||
<div className="export-preview-heading">
|
<div className="export-preview-heading">
|
||||||
<strong>导出预览</strong>
|
<strong>导出预览</strong>
|
||||||
<span>
|
<span>
|
||||||
{selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'}
|
{allExport
|
||||||
|
? `${selectedCount} 个聊天 · 分目录导出`
|
||||||
|
: selectedCount > 1
|
||||||
|
? `${selectedCount} 个聊天 · 合并预览`
|
||||||
|
: '仅预览最近 20 条'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="export-message-preview">
|
<div className="export-message-preview">
|
||||||
<div className="export-preview-date">最近消息</div>
|
<div className="export-preview-date">{allExport ? '全量归档' : '最近消息'}</div>
|
||||||
{(previewItems.length
|
{(allExport
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'all-export',
|
||||||
|
from: 'system',
|
||||||
|
content: '每个聊天将保存为独立 HTML 档案',
|
||||||
|
type: '系统消息',
|
||||||
|
datetime: '',
|
||||||
|
isSender: false,
|
||||||
|
contentData: { type: 'system' as const, content: '全量归档' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: previewItems.length
|
||||||
? previewItems
|
? previewItems
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -146,6 +167,12 @@ export function ExportPreviewPanel({
|
|||||||
<div className="export-job-state">
|
<div className="export-job-state">
|
||||||
<h2>正在导出</h2>
|
<h2>正在导出</h2>
|
||||||
<p>导出任务在后台运行,不影响档案浏览。</p>
|
<p>导出任务在后台运行,不影响档案浏览。</p>
|
||||||
|
{currentTargetText && (
|
||||||
|
<div className="export-current-target">
|
||||||
|
<span>{progress?.currentTargetType === 'group' ? '群聊' : '联系人'}</span>
|
||||||
|
<strong>{currentTargetText}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ol>
|
<ol>
|
||||||
<li className="done">准备导出</li>
|
<li className="done">准备导出</li>
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
@@ -197,7 +224,7 @@ export function ExportPreviewPanel({
|
|||||||
className="export-primary-button"
|
className="export-primary-button"
|
||||||
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
|
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
|
||||||
>
|
>
|
||||||
打开档案
|
{allExport ? '打开导出目录' : '打开档案'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taskDetail = (task: ExportTaskRecord): string | null => {
|
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') {
|
if (task.status === 'completed') {
|
||||||
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
|
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
|
||||||
}
|
}
|
||||||
@@ -48,6 +51,7 @@ export function ExportTaskCenter({
|
|||||||
`格式:${task.format.toUpperCase()}`,
|
`格式:${task.format.toUpperCase()}`,
|
||||||
`状态:${task.progress.phase}`,
|
`状态:${task.progress.phase}`,
|
||||||
`进度:${task.progress.percent ?? 0}%`,
|
`进度:${task.progress.percent ?? 0}%`,
|
||||||
|
`当前聊天:${task.progress.currentTargetName || '未开始'}`,
|
||||||
`错误:${task.progress.error || '未记录具体错误'}`
|
`错误:${task.progress.error || '未记录具体错误'}`
|
||||||
].join('\n')
|
].join('\n')
|
||||||
await navigator.clipboard.writeText(log)
|
await navigator.clipboard.writeText(log)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import type { Message } from '../../../../shared/types'
|
import type { Message } from '../../../../shared/types'
|
||||||
import type {
|
import type {
|
||||||
|
ExportContactType,
|
||||||
ExportJobProgress,
|
ExportJobProgress,
|
||||||
ExportMessageKind,
|
ExportMessageKind,
|
||||||
ExportNameMode,
|
ExportNameMode,
|
||||||
@@ -20,6 +21,11 @@ import type {
|
|||||||
} from './exportTypes'
|
} from './exportTypes'
|
||||||
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
|
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
|
||||||
import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
|
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({
|
export function ExportWorkspace({
|
||||||
contacts,
|
contacts,
|
||||||
@@ -33,17 +39,26 @@ export function ExportWorkspace({
|
|||||||
onCancelExport
|
onCancelExport
|
||||||
}: ExportWorkspaceProps): React.ReactElement {
|
}: ExportWorkspaceProps): React.ReactElement {
|
||||||
const initialSelection = initialContact || contacts[0] || null
|
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 initialContactRef = React.useRef<Contact | null>(initialSelection)
|
||||||
const previewLoadingRef = React.useRef(new Set<string>())
|
const previewLoadingRef = React.useRef(new Set<string>())
|
||||||
const [contactFilter, setContactFilter] = useState('')
|
const [contactFilter, setContactFilter] = useState('')
|
||||||
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
||||||
const [selectionMode, setSelectionMode] = useState(false)
|
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[]>(() =>
|
const [selectedContacts, setSelectedContacts] = useState<Contact[]>(() =>
|
||||||
initialSelection ? [initialSelection] : []
|
initialSelection ? [initialSelection] : []
|
||||||
)
|
)
|
||||||
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
|
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
|
||||||
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
|
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 [startDate, setStartDate] = useState('')
|
||||||
const [endDate, setEndDate] = useState('')
|
const [endDate, setEndDate] = useState('')
|
||||||
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
|
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
|
||||||
@@ -57,8 +72,8 @@ export function ExportWorkspace({
|
|||||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||||
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||||
const [keepMissing, setKeepMissing] = useState(true)
|
const [keepMissing, setKeepMissing] = useState(true)
|
||||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv')
|
||||||
const [zip, setZip] = useState(false)
|
const [zip, setZip] = useState(() => runningAllTask?.zip === true)
|
||||||
const [fileName, setFileName] = useState('')
|
const [fileName, setFileName] = useState('')
|
||||||
const [status, setStatus] = useState<ExportStatus>('idle')
|
const [status, setStatus] = useState<ExportStatus>('idle')
|
||||||
const [jobId, setJobId] = useState('')
|
const [jobId, setJobId] = useState('')
|
||||||
@@ -80,6 +95,7 @@ export function ExportWorkspace({
|
|||||||
}, [contacts, initialContact, selectedContacts.length])
|
}, [contacts, initialContact, selectedContacts.length])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (exportAll) return
|
||||||
for (const contact of selectedContacts) {
|
for (const contact of selectedContacts) {
|
||||||
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
|
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
|
||||||
previewLoadingRef.current.add(contact.md5)
|
previewLoadingRef.current.add(contact.md5)
|
||||||
@@ -88,7 +104,7 @@ export function ExportWorkspace({
|
|||||||
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
|
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [loadPreviewMessages, previewByContact, selectedContacts])
|
}, [exportAll, loadPreviewMessages, previewByContact, selectedContacts])
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
const keyword = contactFilter.trim().toLowerCase()
|
const keyword = contactFilter.trim().toLowerCase()
|
||||||
@@ -105,21 +121,38 @@ export function ExportWorkspace({
|
|||||||
selectedContacts.find((contact) => contact.md5 === activeContactId) ||
|
selectedContacts.find((contact) => contact.md5 === activeContactId) ||
|
||||||
selectedContacts[0] ||
|
selectedContacts[0] ||
|
||||||
null
|
null
|
||||||
const selectedTargetKey = selectedContacts
|
const exportContacts = exportAll
|
||||||
|
? contacts.filter((contact) => allContactTypes.includes(contact.type))
|
||||||
|
: selectedContacts
|
||||||
|
const selectedTargetKey = exportAll
|
||||||
|
? ''
|
||||||
|
: selectedContacts
|
||||||
.map((contact) => contact.md5)
|
.map((contact) => contact.md5)
|
||||||
.sort()
|
.sort()
|
||||||
.join('|')
|
.join('|')
|
||||||
const currentTask = exportTasks.find(
|
const currentTask = exportTasks.find((task) =>
|
||||||
(task) => [...task.targetIds].sort().join('|') === selectedTargetKey
|
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 taskCount = exportTasks.filter((task) => task.status === 'running').length
|
||||||
const activeName = displayName(activeContact)
|
const activeName = displayName(activeContact)
|
||||||
const selectedNames = selectedContacts.map(displayName)
|
const selectedNames = selectedContacts.map(displayName)
|
||||||
const selectedLabel =
|
const allGroupCount = exportContacts.filter((contact) => contact.type === 'group').length
|
||||||
selectedNames.length > 1
|
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.join('、')} · 共 ${selectedNames.length} 个聊天`
|
||||||
: selectedNames[0] || '未选择聊天'
|
: selectedNames[0] || '未选择聊天'
|
||||||
const preview = selectedContacts
|
const preview = exportAll
|
||||||
|
? []
|
||||||
|
: selectedContacts
|
||||||
.flatMap((contact) =>
|
.flatMap((contact) =>
|
||||||
(previewByContact[contact.md5] || []).map((message) => ({
|
(previewByContact[contact.md5] || []).map((message) => ({
|
||||||
...message,
|
...message,
|
||||||
@@ -139,12 +172,13 @@ export function ExportWorkspace({
|
|||||||
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
const defaultOutputName =
|
const defaultOutputName = exportAll
|
||||||
selectedContacts.length > 1
|
? '全部聊天记录'
|
||||||
|
: selectedContacts.length > 1
|
||||||
? `${selectedNames[0]}等${selectedContacts.length}个聊天_合并档案`
|
? `${selectedNames[0]}等${selectedContacts.length}个聊天_合并档案`
|
||||||
: `${activeName}_聊天档案`
|
: `${activeName}_聊天档案`
|
||||||
const outputName = fileName.trim() || defaultOutputName
|
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'
|
(contact) => contact.type === 'group'
|
||||||
)
|
)
|
||||||
? [
|
? [
|
||||||
@@ -170,6 +204,10 @@ export function ExportWorkspace({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const handleSelectContact = (contact: Contact): void => {
|
const handleSelectContact = (contact: Contact): void => {
|
||||||
|
if (exportAll) {
|
||||||
|
setExportAll(false)
|
||||||
|
setRange('today')
|
||||||
|
}
|
||||||
if (!selectionMode) {
|
if (!selectionMode) {
|
||||||
setSelectedContacts([contact])
|
setSelectedContacts([contact])
|
||||||
setActiveContactId(contact.md5)
|
setActiveContactId(contact.md5)
|
||||||
@@ -193,6 +231,26 @@ export function ExportWorkspace({
|
|||||||
setStatus('idle')
|
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(() => {
|
React.useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
void window.api
|
void window.api
|
||||||
@@ -214,10 +272,11 @@ export function ExportWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleStart = async (): Promise<void> => {
|
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.
|
// Runs only from the export button event; a fresh id is required for each job.
|
||||||
const nextJobId = `export-${Date.now()}`
|
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 =
|
const shouldIncludeVoiceTranscripts =
|
||||||
includeVoiceTranscripts &&
|
includeVoiceTranscripts &&
|
||||||
includeMedia &&
|
includeMedia &&
|
||||||
@@ -226,31 +285,28 @@ export function ExportWorkspace({
|
|||||||
voiceModelStatus?.state === 'ready'
|
voiceModelStatus?.state === 'ready'
|
||||||
setJobId(nextJobId)
|
setJobId(nextJobId)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip })
|
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip: shouldZip })
|
||||||
setStatus('running')
|
setStatus('running')
|
||||||
const targets: ExportTarget[] = await Promise.all(
|
const targets: ExportTarget[] = await Promise.all(
|
||||||
selectedContacts.map(async (contact) => {
|
exportContacts.map(async (contact) => {
|
||||||
const nameMap: Record<string, string> = {}
|
const nameMap: Record<string, string> = {}
|
||||||
const avatarUrls: Record<string, string> = {}
|
const avatarUrls: Record<string, string> = {}
|
||||||
if (contact.type === 'group') {
|
if (contact.type === 'group') {
|
||||||
|
if (!exportAll) {
|
||||||
const snapshot = await window.api.getGroupSnapshot(contact.md5)
|
const snapshot = await window.api.getGroupSnapshot(contact.md5)
|
||||||
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
|
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
|
||||||
nameMap[member.wxid] =
|
nameMap[member.wxid] = resolveMemberName(member, nameMode)
|
||||||
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 (member.avatar) avatarUrls[member.wxid] = member.avatar
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
} else if (!exportAll) {
|
||||||
nameMap[contact.m_nsUsrName] =
|
nameMap[contact.m_nsUsrName] =
|
||||||
nameMode === 'remark'
|
nameMode === 'remark'
|
||||||
? contact.remark || contact.m_nsNickName || contact.m_nsUsrName
|
? contact.remark || contact.m_nsNickName || contact.m_nsUsrName
|
||||||
: contact.wechatNickname || contact.m_nsUsrName
|
: contact.wechatNickname || contact.m_nsUsrName
|
||||||
if (contact.avatar) avatarUrls[contact.m_nsUsrName] = contact.avatar
|
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
|
nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||||
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
|
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
|
||||||
}
|
}
|
||||||
@@ -273,15 +329,21 @@ export function ExportWorkspace({
|
|||||||
: null
|
: null
|
||||||
const request: ExportRequest = {
|
const request: ExportRequest = {
|
||||||
jobId: nextJobId,
|
jobId: nextJobId,
|
||||||
|
scope: exportAll ? 'all' : 'selected',
|
||||||
|
allContactTypes: exportAll ? allContactTypes : undefined,
|
||||||
targets,
|
targets,
|
||||||
format: exportFormat,
|
format: exportFormat,
|
||||||
outputName,
|
outputName,
|
||||||
startTime: startOfRange
|
startTime: exportAll
|
||||||
|
? undefined
|
||||||
|
: startOfRange
|
||||||
? Math.floor(startOfRange.getTime() / 1000)
|
? Math.floor(startOfRange.getTime() / 1000)
|
||||||
: range === 'custom' && startDate
|
: range === 'custom' && startDate
|
||||||
? Math.floor(new Date(startDate).getTime() / 1000)
|
? Math.floor(new Date(startDate).getTime() / 1000)
|
||||||
: undefined,
|
: undefined,
|
||||||
endTime: startOfRange
|
endTime: exportAll
|
||||||
|
? undefined
|
||||||
|
: startOfRange
|
||||||
? Math.floor(endOfToday.getTime() / 1000)
|
? Math.floor(endOfToday.getTime() / 1000)
|
||||||
: range === 'custom' && endDate
|
: range === 'custom' && endDate
|
||||||
? Math.floor(new Date(endDate).getTime() / 1000)
|
? Math.floor(new Date(endDate).getTime() / 1000)
|
||||||
@@ -293,7 +355,7 @@ export function ExportWorkspace({
|
|||||||
fallbackThumbnail,
|
fallbackThumbnail,
|
||||||
keepMissing,
|
keepMissing,
|
||||||
includeAvatars,
|
includeAvatars,
|
||||||
zip
|
zip: shouldZip
|
||||||
}
|
}
|
||||||
const result = await onStartExport(request)
|
const result = await onStartExport(request)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -330,6 +392,8 @@ export function ExportWorkspace({
|
|||||||
includeVoiceTranscripts: currentTask.includeVoiceTranscripts === true,
|
includeVoiceTranscripts: currentTask.includeVoiceTranscripts === true,
|
||||||
zip: currentTask.zip === true
|
zip: currentTask.zip === true
|
||||||
})
|
})
|
||||||
|
setFormat(currentTask.format)
|
||||||
|
setZip(currentTask.zip === true)
|
||||||
setStatus(
|
setStatus(
|
||||||
currentTask.status === 'running'
|
currentTask.status === 'running'
|
||||||
? 'running'
|
? 'running'
|
||||||
@@ -344,6 +408,8 @@ export function ExportWorkspace({
|
|||||||
setSelectedContacts(contact ? [contact] : [])
|
setSelectedContacts(contact ? [contact] : [])
|
||||||
setActiveContactId(contact?.md5 || '')
|
setActiveContactId(contact?.md5 || '')
|
||||||
setSelectionMode(false)
|
setSelectionMode(false)
|
||||||
|
setExportAll(false)
|
||||||
|
setAllContactTypes([...ALL_CONTACT_TYPES])
|
||||||
setRange('today')
|
setRange('today')
|
||||||
setStartDate('')
|
setStartDate('')
|
||||||
setEndDate('')
|
setEndDate('')
|
||||||
@@ -363,8 +429,11 @@ export function ExportWorkspace({
|
|||||||
setActiveJobOptions({ includeVoiceTranscripts: false, zip: false })
|
setActiveJobOptions({ includeVoiceTranscripts: false, zip: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPath =
|
const targetPath = exportAll
|
||||||
format === 'html'
|
? format === 'html' && zip
|
||||||
|
? `文稿/WechatExplorer/导出/${outputName}.zip`
|
||||||
|
: `文稿/WechatExplorer/导出/${outputName}/`
|
||||||
|
: format === 'html'
|
||||||
? zip
|
? zip
|
||||||
? `文稿/WechatExplorer/导出/${outputName}.zip`
|
? `文稿/WechatExplorer/导出/${outputName}.zip`
|
||||||
: `文稿/WechatExplorer/导出/${outputName}/`
|
: `文稿/WechatExplorer/导出/${outputName}/`
|
||||||
@@ -378,6 +447,9 @@ export function ExportWorkspace({
|
|||||||
activeContact={activeContact}
|
activeContact={activeContact}
|
||||||
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
|
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
|
exportAll={exportAll}
|
||||||
|
allContactTypes={allContactTypes}
|
||||||
|
exportRunning={status === 'running'}
|
||||||
selectionLimit={selectionLimit}
|
selectionLimit={selectionLimit}
|
||||||
selfInfo={selfInfo}
|
selfInfo={selfInfo}
|
||||||
dbReady={dbReady}
|
dbReady={dbReady}
|
||||||
@@ -387,6 +459,8 @@ export function ExportWorkspace({
|
|||||||
onContactTypeChange={setContactType}
|
onContactTypeChange={setContactType}
|
||||||
onSelectContact={handleSelectContact}
|
onSelectContact={handleSelectContact}
|
||||||
onCompleteSelection={() => setSelectionMode(false)}
|
onCompleteSelection={() => setSelectionMode(false)}
|
||||||
|
onExportAll={handleExportAll}
|
||||||
|
onToggleAllContactType={toggleAllContactType}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -401,7 +475,16 @@ export function ExportWorkspace({
|
|||||||
/>
|
/>
|
||||||
<header className="export-config-header">
|
<header className="export-config-header">
|
||||||
<span className="export-chat-avatar-stack" aria-hidden>
|
<span className="export-chat-avatar-stack" aria-hidden>
|
||||||
{selectedContacts.slice(0, 3).map((contact) => (
|
{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}>
|
<span className="export-chat-avatar" key={contact.md5}>
|
||||||
{contact.avatar ? (
|
{contact.avatar ? (
|
||||||
<img src={contact.avatar} alt="" />
|
<img src={contact.avatar} alt="" />
|
||||||
@@ -418,9 +501,13 @@ export function ExportWorkspace({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="export-add-chat-button"
|
className="export-add-chat-button"
|
||||||
onClick={() => setSelectionMode((current) => !current)}
|
disabled={exportAll}
|
||||||
|
onClick={() => {
|
||||||
|
setExportAll(false)
|
||||||
|
setSelectionMode((current) => !current)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{selectionMode ? '完成选择' : '+ 添加聊天'}
|
{exportAll ? '已选择全部聊天' : selectionMode ? '完成选择' : '+ 添加聊天'}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -432,7 +519,7 @@ export function ExportWorkspace({
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
className={format === value ? 'active' : ''}
|
className={format === value ? 'active' : ''}
|
||||||
disabled={selectedContacts.length > 1 && value !== 'html'}
|
disabled={!exportAll && exportContacts.length > 1 && value !== 'html'}
|
||||||
onClick={() => setFormat(value)}
|
onClick={() => setFormat(value)}
|
||||||
>
|
>
|
||||||
<strong>{formatLabels[value].label}</strong>
|
<strong>{formatLabels[value].label}</strong>
|
||||||
@@ -441,7 +528,9 @@ export function ExportWorkspace({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="export-helper-text">
|
<p className="export-helper-text">
|
||||||
{selectedContacts.length > 1
|
{exportAll
|
||||||
|
? '全部导出固定使用全部时间;每个群聊或联系人都会在自己的目录中生成所选格式的独立档案。'
|
||||||
|
: selectedContacts.length > 1
|
||||||
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
|
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
|
||||||
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
|
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
|
||||||
</p>
|
</p>
|
||||||
@@ -480,6 +569,15 @@ export function ExportWorkspace({
|
|||||||
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
|
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="export-range-toggle">
|
<div className="export-range-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={range === 'all' ? 'active' : ''}
|
||||||
|
onClick={() => setRange('all')}
|
||||||
|
>
|
||||||
|
全部时间
|
||||||
|
</button>
|
||||||
|
{!exportAll && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={range === 'today' ? 'active' : ''}
|
className={range === 'today' ? 'active' : ''}
|
||||||
@@ -508,8 +606,10 @@ export function ExportWorkspace({
|
|||||||
>
|
>
|
||||||
自定义时间
|
自定义时间
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{range === 'custom' && (
|
{!exportAll && range === 'custom' && (
|
||||||
<div className="export-date-fields">
|
<div className="export-date-fields">
|
||||||
<label>
|
<label>
|
||||||
开始时间
|
开始时间
|
||||||
@@ -683,7 +783,7 @@ export function ExportWorkspace({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="export-primary-button"
|
className="export-primary-button"
|
||||||
disabled={!activeContact || status === 'running'}
|
disabled={!activeContact || !exportContacts.length || status === 'running'}
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
>
|
>
|
||||||
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
|
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
|
||||||
@@ -700,7 +800,8 @@ export function ExportWorkspace({
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
includeVoiceTranscripts={activeJobOptions.includeVoiceTranscripts}
|
includeVoiceTranscripts={activeJobOptions.includeVoiceTranscripts}
|
||||||
zip={activeJobOptions.zip}
|
zip={activeJobOptions.zip}
|
||||||
selectedCount={selectedContacts.length}
|
selectedCount={exportContacts.length}
|
||||||
|
allExport={exportAll}
|
||||||
jobId={jobId}
|
jobId={jobId}
|
||||||
onCancel={(exportJobId) => {
|
onCancel={(exportJobId) => {
|
||||||
void window.api.cancelExport(exportJobId)
|
void window.api.cancelExport(exportJobId)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
ExportTaskRecord
|
ExportTaskRecord
|
||||||
} from '../../../../shared/export'
|
} 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 ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
||||||
export type ExportStatus = 'idle' | 'running' | 'completed'
|
export type ExportStatus = 'idle' | 'running' | 'completed'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Contact, Message } from '../../../../shared/types'
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
import type { ExportFormat, GroupMemberName } from './exportTypes'
|
import type { ExportFormat, GroupMemberName } from './exportTypes'
|
||||||
|
import { resolveMemberName } from '../../../../shared/member-names'
|
||||||
|
|
||||||
export const messageKinds = [
|
export const messageKinds = [
|
||||||
['text', '文字'],
|
['text', '文字'],
|
||||||
@@ -43,13 +44,10 @@ export function buildNameMap(
|
|||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
if (activeContact?.type === 'group') {
|
if (activeContact?.type === 'group') {
|
||||||
for (const member of groupMembers) {
|
for (const member of groupMembers) {
|
||||||
const value =
|
map[member.wxid] = resolveMemberName(
|
||||||
nameMode === 'groupNickname'
|
member,
|
||||||
? member.groupNickname || member.nickname || member.wxid
|
nameMode as 'groupNickname' | 'remark' | 'wechatNickname'
|
||||||
: nameMode === 'remark'
|
)
|
||||||
? member.remark || member.wechatNickname || member.wxid
|
|
||||||
: member.wechatNickname || member.wxid
|
|
||||||
map[member.wxid] = value
|
|
||||||
}
|
}
|
||||||
} else if (activeContact) {
|
} else if (activeContact) {
|
||||||
map[activeContact.m_nsUsrName] =
|
map[activeContact.m_nsUsrName] =
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ModelSummary } from './ModelSummary'
|
|||||||
import { ReportDensitySelector } from './ReportDensitySelector'
|
import { ReportDensitySelector } from './ReportDensitySelector'
|
||||||
import { ReportRangeSelector } from './ReportRangeSelector'
|
import { ReportRangeSelector } from './ReportRangeSelector'
|
||||||
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
|
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
|
||||||
|
import { ReportGroupMemberSelector } from './ReportGroupMemberSelector'
|
||||||
import { ReportSectionSelector } from './ReportSectionSelector'
|
import { ReportSectionSelector } from './ReportSectionSelector'
|
||||||
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
|
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ export function AiReportWorkspace({
|
|||||||
onChange={onMemberNamePreferenceChange}
|
onChange={onMemberNamePreferenceChange}
|
||||||
disabled={configDisabled}
|
disabled={configDisabled}
|
||||||
/>
|
/>
|
||||||
|
<ReportGroupMemberSelector sourceContact={sourceContact} disabled={configDisabled} />
|
||||||
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
||||||
<section className="report-config-section report-timeout-section">
|
<section className="report-config-section report-timeout-section">
|
||||||
<div>
|
<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 React, { useEffect, useState } from 'react'
|
||||||
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
|
import {
|
||||||
|
REPORT_TASK_STEPS,
|
||||||
|
ReportGenerationPhase,
|
||||||
|
VoiceTranscriptionProgress
|
||||||
|
} from '../../hooks/useGroupReportGeneration'
|
||||||
|
|
||||||
interface ReportTaskStatusPanelProps {
|
interface ReportTaskStatusPanelProps {
|
||||||
phase: ReportGenerationPhase
|
phase: ReportGenerationPhase
|
||||||
error: string
|
error: string
|
||||||
|
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
|
||||||
|
voiceTranscriptionEnabled: boolean
|
||||||
onRetry: () => void
|
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({
|
export function ReportTaskStatusPanel({
|
||||||
phase,
|
phase,
|
||||||
error,
|
error,
|
||||||
|
voiceTranscriptionProgress,
|
||||||
|
voiceTranscriptionEnabled,
|
||||||
onRetry
|
onRetry
|
||||||
}: ReportTaskStatusPanelProps): React.ReactElement {
|
}: 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 completedAll = phase === 'success'
|
||||||
const [logPath, setLogPath] = useState('')
|
const [logPath, setLogPath] = useState('')
|
||||||
|
|
||||||
@@ -46,12 +44,12 @@ export function ReportTaskStatusPanel({
|
|||||||
: phase === 'error'
|
: phase === 'error'
|
||||||
? '生成失败'
|
? '生成失败'
|
||||||
: activeIndex >= 0
|
: activeIndex >= 0
|
||||||
? `${activeIndex + 1}/${TASK_STEPS.length}`
|
? `${activeIndex + 1}/${taskSteps.length}`
|
||||||
: '等待开始'}
|
: '等待开始'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="report-task-steps">
|
<div className="report-task-steps">
|
||||||
{TASK_STEPS.map((step, index) => {
|
{taskSteps.map((step, index) => {
|
||||||
const state =
|
const state =
|
||||||
completedAll || (activeIndex >= 0 && index < activeIndex)
|
completedAll || (activeIndex >= 0 && index < activeIndex)
|
||||||
? 'done'
|
? 'done'
|
||||||
@@ -71,6 +69,25 @@ export function ReportTaskStatusPanel({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</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' && (
|
{phase === 'error' && (
|
||||||
<div className="report-task-error">
|
<div className="report-task-error">
|
||||||
<b>错误摘要</b>
|
<b>错误摘要</b>
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import {
|
|||||||
SummaryMessageType
|
SummaryMessageType
|
||||||
} from '../utils/group-report'
|
} from '../utils/group-report'
|
||||||
import { ReportTemplateId } from '../components/reports/ReportTemplateSelector'
|
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_STEP_TIMEOUT_MS = 90_000
|
||||||
const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
|
const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
|
||||||
@@ -19,6 +27,7 @@ const REPORT_MODEL_TIMEOUT_BUFFER_MS = 10_000
|
|||||||
export type ReportGenerationPhase =
|
export type ReportGenerationPhase =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'loadingMessages'
|
| 'loadingMessages'
|
||||||
|
| 'transcribingVoice'
|
||||||
| 'preparingInput'
|
| 'preparingInput'
|
||||||
| 'requestingModel'
|
| 'requestingModel'
|
||||||
| 'exportingReport'
|
| 'exportingReport'
|
||||||
@@ -80,6 +89,7 @@ export interface ReportTaskStep {
|
|||||||
|
|
||||||
export const REPORT_TASK_STEPS: ReportTaskStep[] = [
|
export const REPORT_TASK_STEPS: ReportTaskStep[] = [
|
||||||
{ id: 'loadingMessages', label: '读取并筛选聊天记录' },
|
{ id: 'loadingMessages', label: '读取并筛选聊天记录' },
|
||||||
|
{ id: 'transcribingVoice', label: '转写语音消息' },
|
||||||
{ id: 'preparingInput', label: '整理日报输入' },
|
{ id: 'preparingInput', label: '整理日报输入' },
|
||||||
{ id: 'requestingModel', label: '调用模型生成内容' },
|
{ id: 'requestingModel', label: '调用模型生成内容' },
|
||||||
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
|
{ id: 'exportingReport', label: '导出 HTML 与 PNG' }
|
||||||
@@ -210,17 +220,10 @@ const applyGroupMemberNames = async (
|
|||||||
const senderId = String(message.senderId || message.name || '')
|
const senderId = String(message.senderId || message.name || '')
|
||||||
const member = memberMap.get(senderId)
|
const member = memberMap.get(senderId)
|
||||||
if (!member) return message
|
if (!member) return message
|
||||||
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
|
const name = resolveMemberName({ ...member, wxid: senderId }, preference)
|
||||||
// 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))
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
name: name || (preference === 'remark' ? message.name : ''),
|
name: !isInternalName(name) ? name : preference === 'remark' ? message.name : '',
|
||||||
img: message.img || member.avatar
|
img: message.img || member.avatar
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -238,6 +241,7 @@ export function useGroupReportGeneration({
|
|||||||
reportMessages: Message[]
|
reportMessages: Message[]
|
||||||
messageTypeCounts: Record<SummaryMessageType, number>
|
messageTypeCounts: Record<SummaryMessageType, number>
|
||||||
rangeState: RangeMessageState
|
rangeState: RangeMessageState
|
||||||
|
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
|
||||||
generatedImage: string | null
|
generatedImage: string | null
|
||||||
reportPaths: ReportPaths | null
|
reportPaths: ReportPaths | null
|
||||||
generationMetadata: ReportGenerationMetadata
|
generationMetadata: ReportGenerationMetadata
|
||||||
@@ -260,6 +264,8 @@ export function useGroupReportGeneration({
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [rangeMessages, setRangeMessages] = useState<Message[]>([])
|
const [rangeMessages, setRangeMessages] = useState<Message[]>([])
|
||||||
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
|
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
|
||||||
|
const [voiceTranscriptionProgress, setVoiceTranscriptionProgress] =
|
||||||
|
useState<VoiceTranscriptionProgress | null>(null)
|
||||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||||
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
||||||
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
||||||
@@ -290,6 +296,7 @@ export function useGroupReportGeneration({
|
|||||||
|
|
||||||
const isGenerating =
|
const isGenerating =
|
||||||
phase === 'loadingMessages' ||
|
phase === 'loadingMessages' ||
|
||||||
|
phase === 'transcribingVoice' ||
|
||||||
phase === 'preparingInput' ||
|
phase === 'preparingInput' ||
|
||||||
phase === 'requestingModel' ||
|
phase === 'requestingModel' ||
|
||||||
phase === 'exportingReport'
|
phase === 'exportingReport'
|
||||||
@@ -367,9 +374,26 @@ export function useGroupReportGeneration({
|
|||||||
setError('')
|
setError('')
|
||||||
setGeneratedImage(null)
|
setGeneratedImage(null)
|
||||||
setReportPaths(null)
|
setReportPaths(null)
|
||||||
|
setVoiceTranscriptionProgress(null)
|
||||||
setGenerationMetadata({ generationLogs: [] })
|
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> => {
|
const generate = useCallback(async (): Promise<void> => {
|
||||||
if (isGenerating) return
|
if (isGenerating) return
|
||||||
if (!sourceContact) {
|
if (!sourceContact) {
|
||||||
@@ -446,12 +470,16 @@ export function useGroupReportGeneration({
|
|||||||
filteredMessageCount: filteredMessages.length
|
filteredMessageCount: filteredMessages.length
|
||||||
})
|
})
|
||||||
|
|
||||||
setPhase('preparingInput')
|
setPhase(selectedTypes.has('语音') ? 'transcribingVoice' : 'preparingInput')
|
||||||
failedAt = '整理日报输入'
|
failedAt = '整理日报输入'
|
||||||
const input = await trackStep('整理输入', async () => {
|
const input = await trackStep('整理输入', async () => {
|
||||||
|
const messagesWithTranscripts = selectedTypes.has('语音')
|
||||||
|
? await transcribeSelectedVoiceMessages(filteredMessages)
|
||||||
|
: filteredMessages
|
||||||
|
setPhase('preparingInput')
|
||||||
const namedReportMessages = await applyGroupMemberNames(
|
const namedReportMessages = await applyGroupMemberNames(
|
||||||
sourceContact,
|
sourceContact,
|
||||||
filteredMessages,
|
messagesWithTranscripts,
|
||||||
memberNamePreference
|
memberNamePreference
|
||||||
)
|
)
|
||||||
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
|
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
|
||||||
@@ -610,7 +638,8 @@ export function useGroupReportGeneration({
|
|||||||
sourceContact,
|
sourceContact,
|
||||||
summaryDateRange,
|
summaryDateRange,
|
||||||
summaryMessageTypes,
|
summaryMessageTypes,
|
||||||
templateId
|
templateId,
|
||||||
|
transcribeSelectedVoiceMessages
|
||||||
])
|
])
|
||||||
|
|
||||||
const clearError = useCallback((): void => {
|
const clearError = useCallback((): void => {
|
||||||
@@ -639,6 +668,7 @@ export function useGroupReportGeneration({
|
|||||||
reportMessages,
|
reportMessages,
|
||||||
messageTypeCounts,
|
messageTypeCounts,
|
||||||
rangeState,
|
rangeState,
|
||||||
|
voiceTranscriptionProgress,
|
||||||
generatedImage,
|
generatedImage,
|
||||||
reportPaths,
|
reportPaths,
|
||||||
generationMetadata,
|
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 {
|
.export-contact-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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 {
|
.export-format-grid button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
@@ -996,6 +1094,29 @@
|
|||||||
font-weight: 500;
|
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 {
|
.export-progress-bar {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -649,6 +649,92 @@
|
|||||||
font: 12px/18px var(--wxex-font);
|
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 {
|
.report-task-error {
|
||||||
border: 1px solid rgba(200, 90, 90, 0.35);
|
border: 1px solid rgba(200, 90, 90, 0.35);
|
||||||
background: #fff4f4;
|
background: #fff4f4;
|
||||||
@@ -1441,4 +1527,3 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 10px 12px;
|
padding: 0 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,11 +103,16 @@ export const summaryContent = (message: Message): string => {
|
|||||||
case 'sticker':
|
case 'sticker':
|
||||||
return '[表情]'
|
return '[表情]'
|
||||||
case 'voice':
|
case 'voice':
|
||||||
return `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
return message.voiceTranscript?.trim()
|
||||||
|
? `[语音${data.duration ? ` ${data.duration}秒` : ''}] ${message.voiceTranscript.trim()}`
|
||||||
|
: `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
||||||
case 'share':
|
case 'share':
|
||||||
return data.articles?.length
|
return data.articles?.length
|
||||||
? `[分享] ${data.articles
|
? `[分享] ${data.articles
|
||||||
.map((article) => `${article.title}${article.description ? `:${article.description}` : ''}`)
|
.map(
|
||||||
|
(article) =>
|
||||||
|
`${article.title}${article.description ? `:${article.description}` : ''}`
|
||||||
|
)
|
||||||
.join(';')}`
|
.join(';')}`
|
||||||
: `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
|
: `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
|
||||||
case 'quote': {
|
case 'quote': {
|
||||||
@@ -671,7 +676,9 @@ export const buildGroupReportFacts = async (
|
|||||||
transcriptRows.every((row) => row.content === '[图片]') &&
|
transcriptRows.every((row) => row.content === '[图片]') &&
|
||||||
!media.visionGallery?.length
|
!media.visionGallery?.length
|
||||||
) {
|
) {
|
||||||
throw new Error('当前范围只有图片,但这些图片暂时无法分析。请改选文字消息,或在设置中验证图片理解能力。')
|
throw new Error(
|
||||||
|
'当前范围只有图片,但这些图片暂时无法分析。请改选文字消息,或在设置中验证图片理解能力。'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const factsPrompt = [
|
const factsPrompt = [
|
||||||
|
|||||||
@@ -958,7 +958,7 @@ export const SUMMARY_TYPE_OPTIONS: {
|
|||||||
value: 'voice',
|
value: 'voice',
|
||||||
label: '语音',
|
label: '语音',
|
||||||
messageTypes: ['语音'],
|
messageTypes: ['语音'],
|
||||||
description: '当前不转写语音,仅参与数量和活跃度统计。'
|
description: '使用本地离线语音识别,将转写内容提供给日报模型。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'share',
|
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'
|
| 'system'
|
||||||
|
|
||||||
export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname'
|
export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname'
|
||||||
|
export type ExportContactType = 'group' | 'user'
|
||||||
|
|
||||||
export interface ExportTarget {
|
export interface ExportTarget {
|
||||||
userMd5: string
|
userMd5: string
|
||||||
@@ -26,6 +27,8 @@ export interface ExportTarget {
|
|||||||
|
|
||||||
export interface ExportRequest {
|
export interface ExportRequest {
|
||||||
jobId: string
|
jobId: string
|
||||||
|
scope?: 'selected' | 'all'
|
||||||
|
allContactTypes?: ExportContactType[]
|
||||||
targets: ExportTarget[]
|
targets: ExportTarget[]
|
||||||
format: ExportFormat
|
format: ExportFormat
|
||||||
outputName: string
|
outputName: string
|
||||||
@@ -58,10 +61,16 @@ export interface ExportJobProgress {
|
|||||||
percent?: number
|
percent?: number
|
||||||
outputPath?: string
|
outputPath?: string
|
||||||
error?: string
|
error?: string
|
||||||
|
currentTargetIndex?: number
|
||||||
|
currentTargetCount?: number
|
||||||
|
currentTargetName?: string
|
||||||
|
currentTargetType?: ExportContactType
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportTaskRecord {
|
export interface ExportTaskRecord {
|
||||||
jobId: string
|
jobId: string
|
||||||
|
scope?: 'selected' | 'all'
|
||||||
|
allContactTypes?: ExportContactType[]
|
||||||
targetIds: string[]
|
targetIds: string[]
|
||||||
targetNames: string[]
|
targetNames: string[]
|
||||||
targetLabel: 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,
|
previewBytes: 0,
|
||||||
selfInfo: null,
|
selfInfo: null,
|
||||||
selectedCount: 1,
|
selectedCount: 1,
|
||||||
|
allExport: false,
|
||||||
jobId: 'fixture-job',
|
jobId: 'fixture-job',
|
||||||
onCancel: vi.fn(),
|
onCancel: vi.fn(),
|
||||||
onReveal: vi.fn()
|
onReveal: vi.fn()
|
||||||
@@ -70,4 +71,34 @@ describe('export progress panel', () => {
|
|||||||
expect(progressbar).toHaveAttribute('aria-valuenow', '31')
|
expect(progressbar).toHaveAttribute('aria-valuenow', '31')
|
||||||
expect(progressbar.querySelector('span')).toHaveStyle({ width: '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(writeText.mock.calls[0][0]).toContain('EPERM: operation not permitted, copyfile')
|
||||||
expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument()
|
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 = (
|
const renderWorkspace = (
|
||||||
onStartExport = vi.fn(async () => ({ success: false }))
|
onStartExport = vi.fn(async () => ({ success: false })),
|
||||||
|
exportTasks: ExportTaskRecord[] = []
|
||||||
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => {
|
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => {
|
||||||
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
|
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
|
||||||
render(
|
render(
|
||||||
@@ -49,7 +50,7 @@ describe('ExportWorkspace multi-chat selection', () => {
|
|||||||
dbReady
|
dbReady
|
||||||
loadPreviewMessages={loadPreviewMessages}
|
loadPreviewMessages={loadPreviewMessages}
|
||||||
onOpenSettings={vi.fn()}
|
onOpenSettings={vi.fn()}
|
||||||
exportTasks={[]}
|
exportTasks={exportTasks}
|
||||||
onStartExport={onStartExport}
|
onStartExport={onStartExport}
|
||||||
onCancelExport={vi.fn(async () => undefined)}
|
onCancelExport={vi.fn(async () => undefined)}
|
||||||
/>
|
/>
|
||||||
@@ -112,6 +113,90 @@ describe('ExportWorkspace multi-chat selection', () => {
|
|||||||
expect(screen.getByText('已选 4 / 5 个')).toBeVisible()
|
expect(screen.getByText('已选 4 / 5 个')).toBeVisible()
|
||||||
expect(screen.getByRole('button', { name: /聊天 F/ })).toBeEnabled()
|
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', () => {
|
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[],
|
messages: [] as Message[],
|
||||||
messagesByUser: {} as Record<string, Message[]>,
|
messagesByUser: {} as Record<string, Message[]>,
|
||||||
exportReads: [] as string[],
|
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[],
|
voiceLookups: [] as number[],
|
||||||
videoLookups: [] as {
|
videoLookups: [] as {
|
||||||
createTime?: number
|
createTime?: number
|
||||||
@@ -87,12 +102,19 @@ vi.mock('../../src/main/services/chat-service', () => ({
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
getContactAvatars: () => ({ ...state.avatarMap }),
|
getContactAvatars: () => ({ ...state.avatarMap }),
|
||||||
getSelfAccountInfoAsync: async () => ({
|
getGroupSnapshotAsync: async (userMd5: string) => {
|
||||||
|
state.groupSnapshotReads.push(userMd5)
|
||||||
|
return structuredClone(state.groupSnapshots[userMd5] || null)
|
||||||
|
},
|
||||||
|
getSelfAccountInfoAsync: async () => {
|
||||||
|
state.selfInfoReads += 1
|
||||||
|
return {
|
||||||
wxid: 'a969409112',
|
wxid: 'a969409112',
|
||||||
nickname: '濑岛田井卫',
|
nickname: '濑岛田井卫',
|
||||||
avatar: state.selfAvatar,
|
avatar: state.selfAvatar,
|
||||||
accountRoot: state.accountRoot
|
accountRoot: state.accountRoot
|
||||||
})
|
}
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
vi.mock('../../src/main/services/image-key-config-service', () => ({
|
vi.mock('../../src/main/services/image-key-config-service', () => ({
|
||||||
ImageKeyConfigService: class {
|
ImageKeyConfigService: class {
|
||||||
@@ -238,6 +260,9 @@ describe('media export flow', () => {
|
|||||||
state.videoLookups = []
|
state.videoLookups = []
|
||||||
state.messagesByUser = {}
|
state.messagesByUser = {}
|
||||||
state.exportReads = []
|
state.exportReads = []
|
||||||
|
state.selfInfoReads = 0
|
||||||
|
state.groupSnapshotReads = []
|
||||||
|
state.groupSnapshots = {}
|
||||||
state.voiceLookups = []
|
state.voiceLookups = []
|
||||||
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
||||||
mkdirSync(fileMonth, { recursive: true })
|
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 () => {
|
it('creates a replaceable ZIP containing the complete top-level archive folder', async () => {
|
||||||
const { runExport } = await import('../../src/main/export-service')
|
const { runExport } = await import('../../src/main/export-service')
|
||||||
const progress: unknown[][] = []
|
const progress: unknown[][] = []
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { parseGroupDailyReport } from '../../src/renderer/src/utils/group-report'
|
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 { GroupReportMetadata } from '../../src/shared/group-report'
|
||||||
|
import type { Message } from '../../src/shared/types'
|
||||||
|
|
||||||
const metadata: GroupReportMetadata = {
|
const metadata: GroupReportMetadata = {
|
||||||
groupName: '测试群',
|
groupName: '测试群',
|
||||||
@@ -23,6 +25,21 @@ const media = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('group report parsing', () => {
|
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', () => {
|
it('falls back to topic keywords when the model omits top-level keywords', () => {
|
||||||
const report = parseGroupDailyReport(
|
const report = parseGroupDailyReport(
|
||||||
JSON.stringify({
|
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