fix: 修复导出日期、语音和联系人名字问题

- 导出的 HTML:点月份先看日期,选中日期后才跳到对应消息。
- 语音转文字:不同账号、不同聊天里的语音不会再串到一起。
- 联系人名字:正常昵称显示不变;昵称异常时使用可见的备注或账号,导出文件名也能正常使用。
This commit is contained in:
Wxw-Gu
2026-09-13 11:15:30 +08:00
parent ab3b3731b7
commit add16885fe
19 changed files with 702 additions and 150 deletions
@@ -222,6 +222,8 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据
// 时间范围控件在 Query Agent 主路径下移除
expect(screen.queryByText('时间范围')).toBeNull()
expect(screen.queryByText('不限时间')).toBeNull()
expect(screen.queryByText('近 30 天')).toBeNull()
expect(screen.getByText('时间由问题决定')).toBeTruthy()
})
it('Query Agent 关闭时恢复 Legacy 的时间范围控件', async () => {
@@ -58,6 +58,10 @@ describe('ScheduledReportsWorkspace', () => {
deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })),
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } })),
retryScheduledReportSend: vi.fn(async () => ({
success: true,
data: { status: 'success' }
})),
testScheduledReportErrorNotification: vi.fn(async () => ({
success: true,
data: { status: 'failed', notificationStatus: 'sent' }
@@ -214,4 +218,42 @@ describe('ScheduledReportsWorkspace', () => {
'success'
)
})
it('confirms a manual resend and does not retry when the dialog is cancelled', async () => {
const execution = {
id: 'execution-1',
taskId: task.id,
startedAt: '2026-08-27T01:00:00.000Z',
finishedAt: '2026-08-27T01:01:00.000Z',
status: 'waiting_to_send' as const,
sendTarget: group.m_nsUsrName,
userMessage: '日报已生成,但微信暂不可用'
}
vi.mocked(window.api.listScheduledReportExecutions).mockResolvedValue([execution])
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText('日报已生成,但微信暂不可用')).toBeVisible())
fireEvent.click(screen.getByRole('button', { name: '重新发送' }))
const dialog = await screen.findByRole('alertdialog')
expect(within(dialog).getByText('目标:' + group.m_nsUsrName)).toBeVisible()
expect(within(dialog).getByText('日报:技术交流 · 每日日报')).toBeVisible()
expect(within(dialog).getByText(/日期:/)).toBeVisible()
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
expect(window.api.retryScheduledReportSend).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '重新发送' }))
const secondDialog = await screen.findByRole('alertdialog')
fireEvent.click(within(secondDialog).getByRole('button', { name: '确认重新发送' }))
await waitFor(() =>
expect(window.api.retryScheduledReportSend).toHaveBeenCalledWith(execution.id)
)
expect(window.api.retryScheduledReportSend).toHaveBeenCalledTimes(1)
})
})
+45 -1
View File
@@ -567,7 +567,51 @@ test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing
}
})
test('EXPORT-ARCHIVE-05 each message tab restores its previous scroll anchor', async ({ page }) => {
test('EXPORT-ARCHIVE-05 date navigation jumps across days and years', async ({ page }) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-date-timeline-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('日期时间轴档案'), 'utf8')
const timestamp = (value: string): number => Math.floor(new Date(value).getTime() / 1000)
const messages = [
archiveMessage('date-3', 'timeline', '日期时间轴档案', '十二月三十一日', timestamp('2025-12-31T12:00:00+08:00')),
archiveMessage('date-1', 'timeline', '日期时间轴档案', '九月一日', timestamp('2026-09-01T12:00:00+08:00')),
archiveMessage('date-2', 'timeline', '日期时间轴档案', '九月三日', timestamp('2026-09-03T12:00:00+08:00'))
]
writeFileSync(
dataPath,
'window.__WECHAT_EXPORT__ = ' + JSON.stringify({
version: 1,
sourceId: 'timeline',
name: '日期时间轴档案',
exportedAt: '2026-09-04T00:00:00.000Z',
messages
}) + ';\n',
'utf8'
)
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
const september = page.locator('.timeline-month[data-month="2026-09"]')
await expect(september).toBeVisible()
await september.click()
await expect(page.locator('.timeline-day[data-date="2026-09-01"]')).toBeVisible()
await expect(page.locator('.timeline-day[data-date="2026-09-03"]')).toBeVisible()
await page.locator('.timeline-day[data-date="2026-09-01"]').click()
await expect(page.locator('.timeline-day.active')).toHaveAttribute('data-date', '2026-09-01')
await expect(page.locator('.message').filter({ hasText: '九月一日' })).toBeVisible()
await page.locator('.timeline-year[data-year="2025"]').click()
await page.locator('.timeline-month[data-month="2025-12"]').click()
await page.locator('.timeline-day[data-date="2025-12-31"]').click()
await expect(page.locator('.timeline-day.active')).toHaveAttribute('data-date', '2025-12-31')
await expect(page.locator('.message').filter({ hasText: '十二月三十一日' })).toBeVisible()
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
})
test('EXPORT-ARCHIVE-06 each message tab restores its previous scroll anchor', async ({ page }) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-tab-position-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
+67 -2
View File
@@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({
avatarMap: {} as Record<string, string>,
messages: [] as Message[],
messagesByUser: {} as Record<string, Message[]>,
exportErrors: {} as Record<string, string>,
exportReads: [] as string[],
selfInfoReads: 0,
groupSnapshotReads: [] as string[],
@@ -97,6 +98,7 @@ vi.mock('../../src/main/services/chat-service', () => ({
structuredClone(state.messagesByUser[userMd5] || state.messages),
listMessagesForExport: async (userMd5: string) => {
state.exportReads.push(userMd5)
if (state.exportErrors[userMd5]) throw new Error(state.exportErrors[userMd5])
return structuredClone(state.messagesByUser[userMd5] || state.messages)
},
getChatDb: () => ({
@@ -279,6 +281,7 @@ describe('media export flow', () => {
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
state.videoLookups = []
state.messagesByUser = {}
state.exportErrors = {}
state.exportReads = []
state.selfInfoReads = 0
state.groupSnapshotReads = []
@@ -1233,7 +1236,9 @@ describe('media export flow', () => {
target('csv-user', '测试联系人')
]
state.messagesByUser = {
'csv-group': [message({ id: 'group-text', content: '群聊消息' })],
'csv-group': [
message({ id: 'group-text', content: '群聊消息, 引号 \" 和换行\\n😀' })
],
'csv-user': [message({ id: 'user-text', content: '联系人消息' })]
}
@@ -1261,11 +1266,71 @@ describe('media export flow', () => {
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('群聊消息')
const groupCsv = readFileSync(join(groupDir, groupFiles[0]))
expect(groupCsv.subarray(0, 3)).toEqual(Buffer.from([0xef, 0xbb, 0xbf]))
expect(groupCsv.toString('utf8')).toContain('\"群聊消息, 引号 \"\" 和换行\\n😀\"')
expect(groupCsv.toString('utf8')).toContain('\r\n')
expect(readFileSync(join(userDir, userFiles[0]), 'utf8')).toContain('联系人消息')
expect(existsSync(join(outputDir, 'index.html'))).toBe(false)
})
it('continues all-export after one conversation fails and records a truthful manifest', async () => {
const { runExport } = await import('../../src/main/export-service')
const targets: ExportTarget[] = [
target('all-fail-1', '成功会话一'),
target('all-fail-2', '失败会话'),
target('all-fail-3', '成功会话三')
]
state.messagesByUser = Object.fromEntries(
targets.map((item) => [item.userMd5, [message({ id: item.userMd5, content: item.name })]])
)
state.exportErrors = { 'all-fail-2': '模拟单会话读取失败' }
const progress: Array<{ phase: string; percent?: number; error?: string }> = []
const win = {
isDestroyed: () => false,
webContents: {
send: (_channel: string, item: (typeof progress)[number]) => progress.push(item)
}
}
const result = await runExport(
{
jobId: 'all-export-continue-after-failure',
scope: 'all',
targets,
format: 'html',
outputName: 'all-export-continue-after-failure',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result.success).toBe(false)
expect(result.error).toContain('失败会话')
expect(result.outputPath).toBeTruthy()
expect(state.exportReads).toEqual(['all-fail-1', 'all-fail-2', 'all-fail-3'])
expect(existsSync(join(result.outputPath!, '联系人', '成功会话一', 'index.html'))).toBe(true)
expect(existsSync(join(result.outputPath!, '联系人', '成功会话三', 'index.html'))).toBe(true)
const manifest = JSON.parse(
readFileSync(join(result.outputPath!, '导出清单.json'), 'utf8')
) as {
status: string
conversations: Array<{ id: string; status: string; error?: string }>
}
expect(manifest.status).toBe('failed')
expect(manifest.conversations).toEqual([
expect.objectContaining({ id: 'all-fail-1', status: 'completed' }),
expect.objectContaining({
id: 'all-fail-2',
status: 'failed',
error: '模拟单会话读取失败'
}),
expect.objectContaining({ id: 'all-fail-3', status: 'completed' })
])
expect(progress.at(-1)).toMatchObject({ phase: 'failed', percent: 100 })
})
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'
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import {
displayContactName,
filesystemSafeName,
isVisibleName,
visibleNamePart
} from '../../src/shared/contact-name'
describe('contact name safety', () => {
it('recognizes private-use, zero-width, and control-only labels as invisible', () => {
expect(isVisibleName('\uE000\uE000')).toBe(false)
expect(isVisibleName('\u200B\u200D')).toBe(false)
expect(displayContactName({ m_nsNickName: '\uE000\uE000', remark: '备注名', wxid: 'wxid_a' })).toBe(
'备注名'
)
})
it('preserves visible emoji and removes only non-rendering code points', () => {
expect(visibleNamePart(' 👩‍💻\u200B ')).toBe('👩💻')
expect(
displayContactName({
m_nsNickName: String.fromCodePoint(0xE0020),
wechatId: 'wechat-alias'
})
).toBe(
'wechat-alias'
)
})
it('sanitizes reserved path characters, trailing dots, device names, and length', () => {
expect(filesystemSafeName('a/b:c*?"<>|. ')).toMatch(/^a_b_c_+$/)
expect(filesystemSafeName('CON')).toBe('CON_')
expect(filesystemSafeName('\uE000\uE000', '未命名联系人')).toBe('未命名联系人')
expect(Array.from(filesystemSafeName('😀'.repeat(100))).length).toBe(80)
})
})
+62
View File
@@ -51,4 +51,66 @@ describe('VoiceService batch lookup', () => {
expect(resolveVoice).toHaveBeenNthCalledWith(1, 'session', 10, 100, '1000')
expect(resolveVoice).toHaveBeenNthCalledWith(2, 'session', 11, 101, '1001')
})
it('keeps same local ids isolated by session and server identity, then reuses the exact cache entries', async () => {
const getVoiceDataBatch = vi
.fn()
.mockResolvedValue([
{ success: true, hex: '0102' },
{ success: true, hex: '0304' },
{ success: true, hex: '0506' }
])
const service = new VoiceService({ getVoiceDataBatch } as never, '/accounts/fixture-a')
const decoder = (service as unknown as {
decoderRegistry: { decode: ReturnType<typeof vi.fn> }
}).decoderRegistry
vi.spyOn(decoder, 'decode').mockImplementation(async (source: { data: Buffer }) => ({
pcm: Buffer.from([source.data[0]]),
sampleRate: 16_000,
channels: 1
}))
const references = [
{ sessionId: 'session-a', localId: 7, createTime: 100, svrId: 'same-second-a' },
{ sessionId: 'session-b', localId: 7, createTime: 100, svrId: 'same-second-a' },
{ sessionId: 'session-a', localId: 7, createTime: 100, svrId: 'same-second-b' }
]
const first = await service.resolveVoices(references)
expect(first.every((item) => item.success)).toBe(true)
expect(new Set(first.map((item) => item.data)).size).toBe(3)
expect(getVoiceDataBatch).toHaveBeenCalledOnce()
const second = await service.resolveVoices(references)
expect(second).toEqual(first)
expect(getVoiceDataBatch).toHaveBeenCalledOnce()
})
it('versions the in-memory namespace per account so a restarted or switched account cannot reuse old audio', async () => {
const client = {
getVoiceDataBatch: vi.fn().mockResolvedValue([{ success: true, hex: '0102' }]),
getAccountRoot: () => '/accounts/fixture-a'
}
const first = new VoiceService(client as never)
const second = new VoiceService(
{ ...client, getAccountRoot: () => '/accounts/fixture-b' } as never
)
const decode = (service: VoiceService, value: number): void => {
const decoder = (service as unknown as {
decoderRegistry: { decode: ReturnType<typeof vi.fn> }
}).decoderRegistry
vi.spyOn(decoder, 'decode').mockResolvedValue({
pcm: Buffer.from([value]),
sampleRate: 16_000,
channels: 1
})
}
decode(first, 1)
decode(second, 2)
const firstCache = (first as unknown as { voiceCache: Map<string, string> }).voiceCache
const secondCache = (second as unknown as { voiceCache: Map<string, string> }).voiceCache
await expect(first.resolveVoices([{ sessionId: 'session', localId: 1, createTime: 2 }])).resolves.toHaveLength(1)
await expect(second.resolveVoices([{ sessionId: 'session', localId: 1, createTime: 2 }])).resolves.toHaveLength(1)
expect([...firstCache.keys()][0]).not.toBe([...secondCache.keys()][0])
expect(client.getVoiceDataBatch).toHaveBeenCalledTimes(2)
})
})