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

This commit is contained in:
电摇小子
2026-08-11 02:59:54 +08:00
parent d1a090bb6b
commit 7a5499f093
28 changed files with 1909 additions and 193 deletions
@@ -9,6 +9,7 @@ const baseProps = {
previewBytes: 0,
selfInfo: null,
selectedCount: 1,
allExport: false,
jobId: 'fixture-job',
onCancel: vi.fn(),
onReveal: vi.fn()
@@ -70,4 +71,34 @@ describe('export progress panel', () => {
expect(progressbar).toHaveAttribute('aria-valuenow', '31')
expect(progressbar.querySelector('span')).toHaveStyle({ width: '31%' })
})
it('shows the current conversation and overall position for all export', () => {
render(
<ExportPreviewPanel
{...baseProps}
allExport
selectedCount={30}
progress={{
jobId: 'fixture-job',
phase: 'media',
processed: 5,
total: 12,
percent: 36,
currentTargetIndex: 11,
currentTargetCount: 30,
currentTargetName: '项目讨论群',
currentTargetType: 'group'
}}
includeVoiceTranscripts={false}
zip={false}
/>
)
expect(screen.getByText('群聊')).toBeVisible()
expect(screen.getByText('第 11/30 个:项目讨论群')).toBeVisible()
expect(screen.getByRole('progressbar', { name: '导出进度' })).toHaveAttribute(
'aria-valuenow',
'36'
)
})
})
@@ -52,4 +52,42 @@ describe('export task center', () => {
expect(writeText.mock.calls[0][0]).toContain('EPERM: operation not permitted, copyfile')
expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument()
})
it('shows the current conversation for a background all-export task', () => {
render(
<ExportTaskCenter
open
taskCount={1}
tasks={[
{
jobId: 'all-export',
scope: 'all',
allContactTypes: ['group', 'user'],
targetIds: [],
targetNames: [],
targetLabel: '全部 20 个聊天',
format: 'html',
status: 'running',
progress: {
jobId: 'all-export',
phase: 'media',
processed: 3,
total: 8,
percent: 42,
currentTargetIndex: 9,
currentTargetCount: 20,
currentTargetName: '项目群',
currentTargetType: 'group'
},
createdAt: Date.now()
}
]}
onToggle={vi.fn()}
onCancel={vi.fn()}
/>
)
expect(screen.getByText('第 9/20 个:项目群')).toBeVisible()
expect(screen.getByText('42%')).toBeVisible()
})
})
+87 -2
View File
@@ -38,7 +38,8 @@ describe('ExportWorkspace multi-chat selection', () => {
})
const renderWorkspace = (
onStartExport = vi.fn(async () => ({ success: false }))
onStartExport = vi.fn(async () => ({ success: false })),
exportTasks: ExportTaskRecord[] = []
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => {
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
render(
@@ -49,7 +50,7 @@ describe('ExportWorkspace multi-chat selection', () => {
dbReady
loadPreviewMessages={loadPreviewMessages}
onOpenSettings={vi.fn()}
exportTasks={[]}
exportTasks={exportTasks}
onStartExport={onStartExport}
onCancelExport={vi.fn(async () => undefined)}
/>
@@ -112,6 +113,90 @@ describe('ExportWorkspace multi-chat selection', () => {
expect(screen.getByText('已选 4 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: /聊天 F/ })).toBeEnabled()
})
it('exports every chat into its own format folder without loading every preview', async () => {
const onStartExport = vi.fn(async () => ({ success: false }))
const { loadPreviewMessages } = renderWorkspace(onStartExport)
await screen.findByText('聊天 A 的预览')
await userEvent.click(screen.getByRole('button', { name: /全部导出/ }))
expect(screen.getByText(/全部群聊 1 个和全部联系人 5 个/)).toBeVisible()
expect(screen.getByRole('button', { name: 'CSV' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'CSV' })).toHaveClass('active')
expect(screen.getByRole('button', { name: 'JSON' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Markdown' })).toBeEnabled()
expect(loadPreviewMessages).toHaveBeenCalledTimes(1)
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
expect(onStartExport.mock.calls[0][0]).toMatchObject({
scope: 'all',
allContactTypes: ['group', 'user'],
format: 'csv',
outputName: '全部聊天记录'
})
expect(onStartExport.mock.calls[0][0].targets).toHaveLength(contacts.length)
expect(window.api.getGroupSnapshot).not.toHaveBeenCalled()
})
it('allows all export to include only groups and replaces the single-chat avatars', async () => {
const onStartExport = vi.fn(async () => ({ success: false }))
renderWorkspace(onStartExport)
await userEvent.click(screen.getByRole('button', { name: /全部导出/ }))
expect(document.querySelector('.export-all-chat-avatar.group')).toHaveTextContent('群')
expect(document.querySelector('.export-all-chat-avatar.user')).toHaveTextContent('联')
await userEvent.click(screen.getByRole('checkbox', { name: '导出全部联系人' }))
expect(document.querySelector('.export-all-chat-avatar.user')).not.toBeInTheDocument()
expect(screen.getAllByText(/全部群聊 1 个/)).toHaveLength(2)
expect(screen.getByRole('button', { name: /聊天 A/ })).toHaveAttribute('aria-pressed', 'false')
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
expect(onStartExport.mock.calls[0][0]).toMatchObject({
scope: 'all',
allContactTypes: ['group'],
startTime: undefined,
endTime: undefined
})
expect(onStartExport.mock.calls[0][0].targets).toEqual([
expect.objectContaining({ userMd5: 'contact-3', type: 'group' })
])
})
it('restores a running all-export task after returning to the page', async () => {
const runningTask: ExportTaskRecord = {
jobId: 'background-all',
scope: 'all',
allContactTypes: ['group'],
targetIds: [],
targetNames: [],
targetLabel: '全部 1 个聊天',
format: 'html',
status: 'running',
progress: {
jobId: 'background-all',
phase: 'media',
processed: 4,
total: 10,
percent: 48,
currentTargetIndex: 1,
currentTargetCount: 1,
currentTargetName: '聊天 C',
currentTargetType: 'group'
},
createdAt: Date.now()
}
const { loadPreviewMessages } = renderWorkspace(undefined, [runningTask])
expect(await screen.findByText('第 1/1 个:聊天 C')).toBeVisible()
expect(screen.getByRole('checkbox', { name: '导出全部群聊' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: '导出全部联系人' })).not.toBeChecked()
expect(document.querySelector('.export-all-chat-avatar.group')).toHaveTextContent('群')
expect(document.querySelector('.export-all-chat-avatar.user')).not.toBeInTheDocument()
expect(loadPreviewMessages).not.toHaveBeenCalled()
})
})
describe('ExportTaskCenter details', () => {
+81
View File
@@ -0,0 +1,81 @@
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
import type { Contact } from '../../src/shared/types'
const groupContact: Contact = {
md5: 'group-md5',
m_nsUsrName: 'group@chatroom',
m_nsNickName: '测试群',
type: 'group'
}
describe('daily report controls', () => {
beforeEach(() => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getAppLogPath: vi.fn(async () => ''),
revealAppLog: vi.fn(async () => undefined),
getGroupSnapshot: vi.fn(async () => ({
members: [
{
wxid: 'wxid-one',
nickname: '兼容名称一',
groupNickname: '群内昵称一',
wechatNickname: '微信昵称一',
remark: '通讯录备注一',
avatar: ''
},
{
wxid: 'wxid-two',
nickname: '兼容名称二',
groupNickname: '群内昵称二',
wechatNickname: '微信昵称二',
remark: '通讯录备注二',
avatar: ''
}
]
}))
}
})
})
it('shows a separate voice progress bar only when voice is selected', () => {
const progress = { processed: 2, total: 3, succeeded: 2, failed: 0 }
const { rerender } = render(
<ReportTaskStatusPanel
phase="transcribingVoice"
error=""
voiceTranscriptionProgress={progress}
voiceTranscriptionEnabled
onRetry={vi.fn()}
/>
)
expect(screen.getByText('2/5')).toBeVisible()
expect(screen.getByRole('progressbar', { name: '语音转写进度' })).toHaveAttribute('value', '2')
rerender(
<ReportTaskStatusPanel
phase="preparingInput"
error=""
voiceTranscriptionProgress={null}
voiceTranscriptionEnabled={false}
onRetry={vi.fn()}
/>
)
expect(screen.queryByText('转写语音消息')).not.toBeInTheDocument()
expect(screen.getByText('2/4')).toBeVisible()
})
it('loads and displays group nickname, WeChat nickname, and remark separately', async () => {
render(<ReportGroupMemberSelector sourceContact={groupContact} />)
await waitFor(() => expect(screen.getAllByText('群内昵称一')).toHaveLength(2))
expect(screen.getByText('微信昵称一')).toBeVisible()
expect(screen.getByText('通讯录备注一')).toBeVisible()
expect(screen.getByText('wxid-one')).toBeVisible()
})
})
+231 -6
View File
@@ -27,6 +27,21 @@ const state = vi.hoisted(() => ({
messages: [] as Message[],
messagesByUser: {} as Record<string, Message[]>,
exportReads: [] as string[],
selfInfoReads: 0,
groupSnapshotReads: [] as string[],
groupSnapshots: {} as Record<
string,
{
members: Array<{
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}>
}
>,
voiceLookups: [] as number[],
videoLookups: [] as {
createTime?: number
@@ -87,12 +102,19 @@ vi.mock('../../src/main/services/chat-service', () => ({
})
}),
getContactAvatars: () => ({ ...state.avatarMap }),
getSelfAccountInfoAsync: async () => ({
wxid: 'a969409112',
nickname: '濑岛田井卫',
avatar: state.selfAvatar,
accountRoot: state.accountRoot
})
getGroupSnapshotAsync: async (userMd5: string) => {
state.groupSnapshotReads.push(userMd5)
return structuredClone(state.groupSnapshots[userMd5] || null)
},
getSelfAccountInfoAsync: async () => {
state.selfInfoReads += 1
return {
wxid: 'a969409112',
nickname: '濑岛田井卫',
avatar: state.selfAvatar,
accountRoot: state.accountRoot
}
}
}))
vi.mock('../../src/main/services/image-key-config-service', () => ({
ImageKeyConfigService: class {
@@ -238,6 +260,9 @@ describe('media export flow', () => {
state.videoLookups = []
state.messagesByUser = {}
state.exportReads = []
state.selfInfoReads = 0
state.groupSnapshotReads = []
state.groupSnapshots = {}
state.voiceLookups = []
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
mkdirSync(fileMonth, { recursive: true })
@@ -924,6 +949,206 @@ describe('media export flow', () => {
}
})
it('exports more than five chats in all scope and refreshes a changing conversation set', async () => {
const { runExport } = await import('../../src/main/export-service')
const progress: Array<{ phase: string; currentTargetName?: string; percent?: number }> = []
const win = {
isDestroyed: () => false,
webContents: {
send: (
_channel: string,
item: { phase: string; currentTargetName?: string; percent?: number }
) => progress.push(item)
}
}
const targets: ExportTarget[] = Array.from({ length: 6 }, (_, index) => ({
userMd5: `all-${index + 1}`,
name: `聊天 ${index + 1}`,
type: index === 5 ? 'group' : 'user',
nameMode: 'groupNickname'
}))
state.messagesByUser = Object.fromEntries(
targets.map((item, index) => [
item.userMd5,
[
message({
id: `message-${index + 1}`,
senderId: index === 5 ? 'wxid-group-member' : `wxid-${index + 1}`,
content: `会话 ${index + 1}`,
createTime: 100 + index
})
]
])
)
state.groupSnapshots['all-6'] = {
members: [
{
wxid: 'wxid-group-member',
nickname: '兼容名称',
groupNickname: '群内名称',
wechatNickname: '微信名称',
remark: '通讯录备注',
avatar: ''
}
]
}
const request = {
scope: 'all' as const,
targets,
format: 'html' as const,
outputName: 'all-conversations',
kinds: ['text'] as const,
includeMedia: false
}
const legacyOutputDir = join(state.documents, 'WechatExplorer', '导出', 'all-conversations')
mkdirSync(join(legacyOutputDir, 'data'), { recursive: true })
writeFileSync(join(legacyOutputDir, 'index.html'), 'legacy combined archive')
writeFileSync(join(legacyOutputDir, 'data', 'messages.js'), 'legacy data')
const first = await runExport(
{ ...request, jobId: 'all-conversations-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
expect(first.messageCount).toBe(6)
const outputDir = first.outputPath!
const firstUserArchive = readArchive(join(outputDir, '联系人', '聊天 1', 'index.html'))
const firstGroupArchive = readArchive(join(outputDir, '群聊', '聊天 6', 'index.html'))
expect(firstUserArchive.conversations).toHaveLength(1)
expect(firstGroupArchive.messages.find((item) => item.id === 'message-6')?.name).toBe(
'群内名称'
)
expect(state.groupSnapshotReads).toEqual(['all-6'])
expect(state.selfInfoReads).toBe(1)
const manifest = JSON.parse(readFileSync(join(outputDir, '导出清单.json'), 'utf8')) as {
conversations: Array<{ id: string }>
}
expect(manifest.conversations).toHaveLength(6)
expect(readFileSync(join(outputDir, '旧版合并档案', 'index.html'), 'utf8')).toBe(
'legacy combined archive'
)
expect(existsSync(join(outputDir, '群聊', '聊天 6', 'data', 'messages.js'))).toBe(true)
expect(existsSync(join(outputDir, '联系人', '聊天 1', 'data', 'messages.js'))).toBe(true)
expect(progress.some((item) => item.currentTargetName === '聊天 6')).toBe(true)
expect(progress.at(-1)).toMatchObject({ phase: 'completed', percent: 100 })
const second = await runExport(
{
...request,
jobId: 'all-conversations-second',
targets: targets.slice(0, 5),
kinds: [...request.kinds]
},
win as never
)
expect(second.success, second.error).toBe(true)
const secondManifest = JSON.parse(
readFileSync(join(second.outputPath!, '导出清单.json'), 'utf8')
) as { conversations: Array<{ id: string }> }
expect(secondManifest.conversations).toHaveLength(5)
expect(secondManifest.conversations.some((item) => item.id === 'all-6')).toBe(false)
})
it('writes every all-export CSV inside its group or contact conversation folder', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = {
isDestroyed: () => false,
webContents: { send: vi.fn() }
}
const targets: ExportTarget[] = [
{ ...target('csv-group', '测试群聊'), type: 'group' },
target('csv-user', '测试联系人')
]
state.messagesByUser = {
'csv-group': [message({ id: 'group-text', content: '群聊消息' })],
'csv-user': [message({ id: 'user-text', content: '联系人消息' })]
}
const result = await runExport(
{
jobId: 'all-conversations-csv',
scope: 'all',
allContactTypes: ['group', 'user'],
targets,
format: 'csv',
outputName: '全部聊天记录',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result.success, result.error).toBe(true)
const outputDir = result.outputPath!
const groupDir = join(outputDir, '群聊', '测试群聊')
const userDir = join(outputDir, '联系人', '测试联系人')
const groupFiles = readdirSync(groupDir)
const userFiles = readdirSync(userDir)
expect(groupFiles).toHaveLength(1)
expect(userFiles).toHaveLength(1)
expect(groupFiles[0]).toMatch(/^测试群聊_\d{8}_\d{6}\.csv$/)
expect(userFiles[0]).toMatch(/^测试联系人_\d{8}_\d{6}\.csv$/)
expect(readFileSync(join(groupDir, groupFiles[0]), 'utf8')).toContain('群聊消息')
expect(readFileSync(join(userDir, userFiles[0]), 'utf8')).toContain('联系人消息')
expect(existsSync(join(outputDir, 'index.html'))).toBe(false)
})
it('cancels an all-export task between conversations without starting the next database read', async () => {
const { cancelExport, runExport } = await import('../../src/main/export-service')
const jobId = 'all-export-cancel'
const targets = [
target('cancel-1', '联系人一'),
target('cancel-2', '联系人二'),
target('cancel-3', '联系人三')
]
state.messagesByUser = Object.fromEntries(
targets.map((item, index) => [
item.userMd5,
[message({ id: `cancel-message-${index}`, content: item.name })]
])
)
const win = {
isDestroyed: () => false,
webContents: {
send: (
_channel: string,
progress: { phase: string; currentTargetIndex?: number }
): void => {
if (progress.phase === 'reading' && progress.currentTargetIndex === 2) {
cancelExport(jobId)
}
}
}
}
const result = await runExport(
{
jobId,
scope: 'all',
allContactTypes: ['user'],
targets,
format: 'html',
outputName: 'cancelled-all-conversations',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result).toEqual({ success: false, error: '已取消' })
expect(state.exportReads).toEqual(['cancel-1'])
const outputDir = join(state.documents, 'WechatExplorer', '导出', 'cancelled-all-conversations')
expect(existsSync(join(outputDir, '联系人', '联系人一', 'index.html'))).toBe(true)
expect(existsSync(join(outputDir, '联系人', '联系人二', 'index.html'))).toBe(false)
const partialManifest = JSON.parse(readFileSync(join(outputDir, '导出清单.json'), 'utf8')) as {
status: string
conversations: Array<{ id: string }>
}
expect(partialManifest.status).toBe('cancelled')
expect(partialManifest.conversations.map((item) => item.id)).toEqual(['cancel-1'])
})
it('creates a replaceable ZIP containing the complete top-level archive folder', async () => {
const { runExport } = await import('../../src/main/export-service')
const progress: unknown[][] = []
+17
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import { parseGroupDailyReport } from '../../src/renderer/src/utils/group-report'
import { summaryContent } from '../../src/renderer/src/utils/group-report-facts'
import type { GroupReportMetadata } from '../../src/shared/group-report'
import type { Message } from '../../src/shared/types'
const metadata: GroupReportMetadata = {
groupName: '测试群',
@@ -23,6 +25,21 @@ const media = {
}
describe('group report parsing', () => {
it('includes a cached voice transcript in the report input content', () => {
const message: Message = {
id: 'voice-1',
from: 'member',
type: '语音',
datetime: '2026-08-06 10:00:00',
content: '[语音]',
isSender: false,
contentData: { type: 'voice', duration: 3 },
voiceTranscript: '今晚八点确认发布。'
}
expect(summaryContent(message)).toContain('今晚八点确认发布。')
})
it('falls back to topic keywords when the model omits top-level keywords', () => {
const report = parseGroupDailyReport(
JSON.stringify({
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { resolveMemberName } from '../../src/shared/member-names'
const member = {
wxid: 'wxid-member',
nickname: '兼容名称',
groupNickname: '群内名称',
wechatNickname: '微信名称',
remark: '通讯录备注'
}
describe('member name resolution', () => {
it('keeps group nickname and WeChat nickname modes independent from remarks', () => {
expect(resolveMemberName(member, 'groupNickname')).toBe('群内名称')
expect(resolveMemberName(member, 'wechatNickname')).toBe('微信名称')
expect(resolveMemberName(member, 'remark')).toBe('通讯录备注')
})
it('does not leak whitespace and uses the wxid when the selected source is empty', () => {
expect(
resolveMemberName(
{ ...member, groupNickname: ' ', wechatNickname: '', remark: '不能串用的备注' },
'groupNickname'
)
).toBe('wxid-member')
})
})
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
import type { VoiceModelStatus } from '../../src/shared/voice-recognition'
import {
toVoiceMessageReference,
transcribeVoiceMessages,
type VoiceTranscriptionProgress
} from '../../src/renderer/src/utils/voice-message-reference'
const status = (state: VoiceModelStatus['state']): VoiceModelStatus =>
({
modelId: 'fixture',
version: '1',
state,
downloadedBytes: 1,
totalBytes: 1,
progress: 1,
platform: 'win32',
architecture: 'x64',
supported: true
}) as VoiceModelStatus
const voice = (id: string, localId: number, transcript?: string): Message => ({
id,
from: 'member',
type: '语音',
datetime: '2026-08-10 10:00:00',
content: '[语音]',
isSender: false,
sessionId: 'session',
localId,
createTime: 1_786_320_000,
contentData: { type: 'voice', duration: 2 },
voiceTranscript: transcript
})
describe('daily report voice transcription', () => {
it('creates a local voice reference from a parsed message', () => {
expect(toVoiceMessageReference(voice('voice-1', 7))).toEqual({
sessionId: 'session',
localId: 7,
createTime: 1_786_320_000,
svrId: undefined
})
})
it('reuses cached text and reports success/failure progress', async () => {
const recognize = vi
.fn()
.mockResolvedValueOnce({ success: true, transcript: '新转写内容' })
.mockResolvedValueOnce({ success: false, error: '音频缺失' })
const onProgress = vi.fn<(progress: VoiceTranscriptionProgress) => void>()
const result = await transcribeVoiceMessages(
[voice('cached', 1, '缓存内容'), voice('fresh', 2), voice('failed', 3)],
{
getModelStatus: vi.fn(async () => status('ready')),
recognize,
onProgress
}
)
expect(result.map((message) => message.voiceTranscript)).toEqual([
'缓存内容',
'新转写内容',
undefined
])
expect(result[2].voiceTranscriptError).toBe('音频缺失')
expect(recognize).toHaveBeenCalledTimes(2)
expect(onProgress).toHaveBeenLastCalledWith({
processed: 3,
total: 3,
succeeded: 2,
failed: 1
})
})
it('does not require the model when every transcript is cached', async () => {
const getModelStatus = vi.fn(async () => status('missing'))
const recognize = vi.fn()
const result = await transcribeVoiceMessages([voice('cached', 1, '已有文本')], {
getModelStatus,
recognize,
onProgress: vi.fn()
})
expect(result[0].voiceTranscript).toBe('已有文本')
expect(getModelStatus).not.toHaveBeenCalled()
expect(recognize).not.toHaveBeenCalled()
})
it('stops before recognition when the local model is not ready', async () => {
const recognize = vi.fn()
await expect(
transcribeVoiceMessages([voice('pending', 1)], {
getModelStatus: vi.fn(async () => status('missing')),
recognize,
onProgress: vi.fn()
})
).rejects.toThrow('准备离线语音识别模型')
expect(recognize).not.toHaveBeenCalled()
})
it('counts a voice message with incomplete local identifiers as failed', async () => {
const incomplete = { ...voice('incomplete', 1), sessionId: undefined }
const onProgress = vi.fn<(progress: VoiceTranscriptionProgress) => void>()
const result = await transcribeVoiceMessages([incomplete], {
getModelStatus: vi.fn(async () => status('ready')),
recognize: vi.fn(),
onProgress
})
expect(result[0].voiceTranscriptError).toContain('标识不完整')
expect(onProgress).toHaveBeenLastCalledWith({
processed: 1,
total: 1,
succeeded: 0,
failed: 1
})
})
})