mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
fix: 修复导出日期、语音和联系人名字问题
- 导出的 HTML:点月份先看日期,选中日期后才跳到对应消息。 - 语音转文字:不同账号、不同聊天里的语音不会再串到一起。 - 联系人名字:正常昵称显示不变;昵称异常时使用可见的备注或账号,导出文件名也能正常使用。
This commit is contained in:
@@ -329,6 +329,32 @@ body {
|
||||
color: var(--accent);
|
||||
}
|
||||
.timeline-month small { color: inherit; }
|
||||
.timeline-month-entry { display: grid; gap: 2px; }
|
||||
.timeline-days {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
margin: 0 0 4px 11px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.timeline-days[hidden] { display: none; }
|
||||
.timeline-day {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 5px 7px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
.timeline-day:hover, .timeline-day.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.timeline-day small { color: inherit; }
|
||||
.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; }
|
||||
.lazy-hint {
|
||||
width: min(100%, 820px);
|
||||
@@ -787,6 +813,23 @@ body {
|
||||
}
|
||||
.timeline-months { display: flex; gap: 6px; }
|
||||
.timeline-months[hidden] { display: none; }
|
||||
.timeline-month-entry { display: grid; gap: 2px; }
|
||||
.timeline-days {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
.timeline-days[hidden] { display: none; }
|
||||
.timeline-day {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 5px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timeline-day:hover, .timeline-day.active { border-bottom-color: var(--accent); }
|
||||
.timeline-month {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
@@ -879,6 +922,9 @@ const renderExportScript = (name: string): string => `
|
||||
let scrollLoadSuppressed = false
|
||||
let activeMonthUpdatePending = false
|
||||
let expandedTimelineYear = ''
|
||||
let expandedTimelineMonth = ''
|
||||
let selectedTimelineMonth = ''
|
||||
let selectedTimelineDate = ''
|
||||
const tabPositions = new Map()
|
||||
let lastScrollTop = 0
|
||||
let zoom = 1
|
||||
@@ -941,6 +987,13 @@ const renderExportScript = (name: string): string => `
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1)
|
||||
}
|
||||
const dateKey = (message) => {
|
||||
const timestamp = Number(message.createTime || 0)
|
||||
if (!timestamp) return 'unknown'
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (Number.isNaN(date.getTime())) return 'unknown'
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
|
||||
}
|
||||
const kindOf = (message) => {
|
||||
const data = message.contentData || {}
|
||||
const shareType = String(data.typeVal || '')
|
||||
@@ -1317,6 +1370,15 @@ const renderExportScript = (name: string): string => `
|
||||
if (months) months.hidden = !expanded
|
||||
})
|
||||
}
|
||||
const setExpandedTimelineMonth = (month) => {
|
||||
expandedTimelineMonth = month || ''
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
const expanded = button.dataset.month === expandedTimelineMonth
|
||||
button.setAttribute('aria-expanded', String(expanded))
|
||||
const days = button.nextElementSibling
|
||||
if (days) days.hidden = !expanded
|
||||
})
|
||||
}
|
||||
const renderTimeline = () => {
|
||||
if (filtered.length === 0) {
|
||||
expandedTimelineYear = ''
|
||||
@@ -1327,18 +1389,36 @@ const renderExportScript = (name: string): string => `
|
||||
for (const message of filtered) {
|
||||
const key = monthKey(message)
|
||||
if (key === 'unknown') continue
|
||||
groups.set(key, (groups.get(key) || 0) + 1)
|
||||
const date = dateKey(message)
|
||||
const group = groups.get(key) || { total: 0, days: new Map() }
|
||||
group.total += 1
|
||||
if (date !== 'unknown') group.days.set(date, (group.days.get(date) || 0) + 1)
|
||||
groups.set(key, group)
|
||||
}
|
||||
const yearGroups = new Map()
|
||||
for (const [key, total] of groups) {
|
||||
for (const [key, group] of groups) {
|
||||
const parts = key.split('-')
|
||||
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], [])
|
||||
yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total })
|
||||
yearGroups.get(parts[0]).push({
|
||||
key,
|
||||
month: Number(parts[1]),
|
||||
total: group.total,
|
||||
days: Array.from(group.days.entries())
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([date, total]) => ({ date, day: Number(date.slice(-2)), total }))
|
||||
})
|
||||
}
|
||||
const years = Array.from(yearGroups.keys())
|
||||
const years = Array.from(yearGroups.keys()).sort((left, right) => left.localeCompare(right))
|
||||
if (!years.includes(expandedTimelineYear)) {
|
||||
expandedTimelineYear = years[years.length - 1] || ''
|
||||
}
|
||||
for (const entries of yearGroups.values()) {
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
const allMonthKeys = Array.from(groups.keys()).sort((left, right) => left.localeCompare(right))
|
||||
if (!allMonthKeys.includes(expandedTimelineMonth)) {
|
||||
expandedTimelineMonth = allMonthKeys[allMonthKeys.length - 1] || ''
|
||||
}
|
||||
let html = ''
|
||||
for (const year of years) {
|
||||
const expanded = year === expandedTimelineYear
|
||||
@@ -1348,10 +1428,18 @@ const renderExportScript = (name: string): string => `
|
||||
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
|
||||
esc(year) + ' 年</button>' +
|
||||
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
|
||||
yearGroups.get(year).map((entry) =>
|
||||
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) + '">' +
|
||||
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>'
|
||||
).join('') + '</div></section>'
|
||||
yearGroups.get(year).map((entry) => {
|
||||
const monthExpanded = entry.key === expandedTimelineMonth
|
||||
return '<div class="timeline-month-entry">' +
|
||||
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) +
|
||||
'" aria-expanded="' + String(monthExpanded) + '">' +
|
||||
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>' +
|
||||
'<div class="timeline-days" data-days-for-month="' + esc(entry.key) + '"' +
|
||||
(monthExpanded ? '' : ' hidden') + '>' + entry.days.map((day) =>
|
||||
'<button class="timeline-day" type="button" data-date="' + esc(day.date) + '">' +
|
||||
'<span>' + esc(day.date.slice(5)) + '</span><small>' + day.total + '</small></button>'
|
||||
).join('') + '</div></div>'
|
||||
}).join('') + '</div></section>'
|
||||
}
|
||||
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
|
||||
}
|
||||
@@ -1377,16 +1465,27 @@ const renderExportScript = (name: string): string => `
|
||||
const activeMessage = atBottom
|
||||
? visible[visible.length - 1] || messages[messages.length - 1]
|
||||
: anchoredMessage || visible[0] || messages[0]
|
||||
const key = activeMessage && activeMessage.dataset.month
|
||||
const key = selectedTimelineMonth || (selectedTimelineDate
|
||||
? selectedTimelineDate.slice(0, 7)
|
||||
: '') ||
|
||||
(activeMessage && activeMessage.dataset.month)
|
||||
const activeIndex = activeMessage ? Number(activeMessage.dataset.index) : -1
|
||||
const activeDate =
|
||||
selectedTimelineDate ||
|
||||
(activeIndex >= 0 && filtered[activeIndex] ? dateKey(filtered[activeIndex]) : '')
|
||||
let activeButton
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
const active = button.dataset.month === key
|
||||
button.classList.toggle('active', active)
|
||||
if (active) activeButton = button
|
||||
})
|
||||
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.date === activeDate)
|
||||
})
|
||||
if (!activeButton) return
|
||||
const activeYear = key.split('-')[0]
|
||||
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
|
||||
if (key !== expandedTimelineMonth) setExpandedTimelineMonth(key)
|
||||
const timelineBounds = timeline.getBoundingClientRect()
|
||||
const buttonBounds = activeButton.getBoundingClientRect()
|
||||
if (buttonBounds.top < timelineBounds.top) {
|
||||
@@ -1535,6 +1634,8 @@ const renderExportScript = (name: string): string => `
|
||||
)
|
||||
}
|
||||
const applyFilters = (restorePosition = false) => {
|
||||
selectedTimelineMonth = ''
|
||||
selectedTimelineDate = ''
|
||||
filtered = matchingMessages()
|
||||
renderTimeline()
|
||||
if (!restorePosition || !restoreTabPosition()) resetWindow(true)
|
||||
@@ -1572,18 +1673,32 @@ const renderExportScript = (name: string): string => `
|
||||
target.classList.add('located')
|
||||
window.setTimeout(() => target.classList.remove('located'), 1600)
|
||||
}
|
||||
const jumpToMonth = (key) => {
|
||||
const index = filtered.findIndex((message) => monthKey(message) === key)
|
||||
const selectTimelineMonth = (key) => {
|
||||
if (!filtered.some((message) => monthKey(message) === key)) return
|
||||
selectedTimelineMonth = key
|
||||
selectedTimelineDate = ''
|
||||
setExpandedTimelineYear(key.split('-')[0])
|
||||
setExpandedTimelineMonth(key)
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.month === key)
|
||||
})
|
||||
}
|
||||
const jumpToDate = (key) => {
|
||||
const index = filtered.findIndex((message) => dateKey(message) === key)
|
||||
if (index < 0) return
|
||||
selectedTimelineMonth = ''
|
||||
selectedTimelineDate = key
|
||||
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
|
||||
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
|
||||
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
|
||||
renderWindow()
|
||||
const target = list.querySelector('.message[data-index="' + index + '"]')
|
||||
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
|
||||
setExpandedTimelineYear(key.split('-')[0])
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.month === key)
|
||||
const month = key.slice(0, 7)
|
||||
setExpandedTimelineYear(month.slice(0, 4))
|
||||
setExpandedTimelineMonth(month)
|
||||
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.date === key)
|
||||
})
|
||||
}
|
||||
const slideWindow = (direction) => {
|
||||
@@ -1620,6 +1735,10 @@ const renderExportScript = (name: string): string => `
|
||||
const nearTop = currentTop < 180
|
||||
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
||||
lastScrollTop = currentTop
|
||||
if (!scrollLoadSuppressed) {
|
||||
selectedTimelineMonth = ''
|
||||
selectedTimelineDate = ''
|
||||
}
|
||||
scheduleActiveMonthUpdate()
|
||||
if (scrollLoadSuppressed) return
|
||||
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
||||
@@ -1676,7 +1795,12 @@ const renderExportScript = (name: string): string => `
|
||||
return
|
||||
}
|
||||
const button = event.target.closest('[data-month]')
|
||||
if (button) jumpToMonth(button.dataset.month)
|
||||
if (button) {
|
||||
selectTimelineMonth(button.dataset.month)
|
||||
return
|
||||
}
|
||||
const dayButton = event.target.closest('[data-date]')
|
||||
if (dayButton) jumpToDate(dayButton.dataset.date)
|
||||
})
|
||||
|
||||
const updateZoom = () => preview.style.setProperty('--zoom', zoom)
|
||||
|
||||
+130
-74
@@ -25,11 +25,12 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
|
||||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||
import { imageFileQuality } from '../shared/image-quality'
|
||||
import { resolveMemberName } from '../shared/member-names'
|
||||
import { filesystemSafeName } from '../shared/contact-name'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const activeArchives = new Map<string, Archiver>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||
const safeFilePart = (value: string, fallback = '聊天档案'): string =>
|
||||
filesystemSafeName(value, fallback)
|
||||
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
||||
try {
|
||||
await fs.chmod(destination, 0o644)
|
||||
@@ -643,6 +644,7 @@ const kindOf = (message: Message): ExportMessageKind => {
|
||||
return 'text'
|
||||
}
|
||||
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const csvBom = '\uFEFF'
|
||||
|
||||
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||
if (format === 'html') return renderExportPage(name)
|
||||
@@ -650,7 +652,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
||||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||
if (format === 'markdown')
|
||||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
|
||||
return [
|
||||
return csvBom + [
|
||||
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
||||
...messages.map((m) =>
|
||||
[
|
||||
@@ -664,7 +666,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
||||
.map(csv)
|
||||
.join(',')
|
||||
)
|
||||
].join('\n')
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
interface SingleExportOptions {
|
||||
@@ -681,6 +683,8 @@ interface AllExportManifestEntry {
|
||||
type: ExportTarget['type']
|
||||
folder: string
|
||||
messageCount: number
|
||||
status: 'completed' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const writeAllExportManifest = async (
|
||||
@@ -1086,7 +1090,10 @@ async function runSingleExport(
|
||||
}
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||
? new VoiceService(
|
||||
chat.getChatDb()!.getWcdb4Client(),
|
||||
chat.getChatDb()!.getWcdb4Client().getAccountRoot()
|
||||
)
|
||||
: null
|
||||
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
|
||||
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
|
||||
@@ -1635,6 +1642,8 @@ async function runAllExport(
|
||||
let outputDir = ''
|
||||
const manifest: AllExportManifestEntry[] = []
|
||||
let totalMessages = 0
|
||||
let failedCount = 0
|
||||
const failureMessages: string[] = []
|
||||
try {
|
||||
const targets = [...(request.targets || [])].sort((left, right) =>
|
||||
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
|
||||
@@ -1667,80 +1676,121 @@ async function runAllExport(
|
||||
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 folder = `${categoryName}/${folderName}`
|
||||
let result: ExportResult
|
||||
try {
|
||||
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
|
||||
})
|
||||
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 || '导出失败'}`)
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
|
||||
result = { success: false, error: message }
|
||||
}
|
||||
|
||||
const messageCount = result.messageCount || 0
|
||||
totalMessages += messageCount
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder: `${categoryName}/${folderName}`,
|
||||
messageCount
|
||||
})
|
||||
if (!result.success) {
|
||||
if (result.error === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
|
||||
const error = `${target.name}:${result.error || '导出失败'}`
|
||||
failedCount += 1
|
||||
failureMessages.push(error)
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder,
|
||||
messageCount: 0,
|
||||
status: 'failed',
|
||||
error: result.error || '导出失败'
|
||||
})
|
||||
const failedPercent = Math.min(
|
||||
99,
|
||||
Math.floor(((targetIndex + 1) / targets.length) * 100)
|
||||
)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'failed',
|
||||
processed: totalMessages,
|
||||
total: totalMessages,
|
||||
percent: failedPercent,
|
||||
error,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
} else {
|
||||
const messageCount = result.messageCount || 0
|
||||
totalMessages += messageCount
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder,
|
||||
messageCount,
|
||||
status: 'completed'
|
||||
})
|
||||
}
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
||||
}
|
||||
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed')
|
||||
const finalStatus = failedCount ? 'failed' : 'completed'
|
||||
const finalError = failedCount
|
||||
? `全部导出完成:成功 ${targets.length - failedCount} 个,失败 ${failedCount} 个。${failureMessages.join(';')}`
|
||||
: undefined
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, finalStatus, finalError)
|
||||
|
||||
let completedPath = outputDir
|
||||
if (request.zip) {
|
||||
@@ -1759,17 +1809,23 @@ async function runAllExport(
|
||||
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
phase: failedCount ? 'failed' : 'completed',
|
||||
processed: totalMessages,
|
||||
total: totalMessages,
|
||||
percent: 100,
|
||||
error: finalError,
|
||||
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 }
|
||||
return {
|
||||
success: failedCount === 0,
|
||||
outputPath: completedPath,
|
||||
messageCount: totalMessages,
|
||||
...(finalError ? { error: finalError } : {})
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const cancelled = !jobs.has(request.jobId) || message === '已取消'
|
||||
|
||||
+2
-2
@@ -818,7 +818,7 @@ app.whenReady().then(async () => {
|
||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||
voiceService = new VoiceService(wcdb4Client)
|
||||
voiceService = new VoiceService(wcdb4Client, resolvedRoot)
|
||||
voiceRecognition?.connect(voiceService, resolvedRoot)
|
||||
stickerService = new StickerService(wcdb4Client)
|
||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||
@@ -1958,7 +1958,7 @@ app.whenReady().then(async () => {
|
||||
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
if (client) {
|
||||
voiceService = new VoiceService(client)
|
||||
voiceService = new VoiceService(client, client.getAccountRoot())
|
||||
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
||||
const monitoring = await client.startMonitor((type, json) => {
|
||||
client.invalidateSessionCache()
|
||||
|
||||
+13
-11
@@ -4,6 +4,7 @@ import {
|
||||
createDefaultAudioDecoderRegistry,
|
||||
type EncodedVoiceSource
|
||||
} from './voice-pipeline/audio-decoder'
|
||||
import { voiceAccountIdentity, voiceMessageIdentity } from './voice-pipeline/voice-message-identity'
|
||||
|
||||
export {
|
||||
findSilkWasmRuntimeLocation,
|
||||
@@ -36,12 +37,16 @@ export interface VoiceReference {
|
||||
|
||||
export class VoiceService {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private readonly accountIdentity: string
|
||||
private voiceCache = new Map<string, string>()
|
||||
private pcmCache = new Map<string, ResolvedPcmAudio>()
|
||||
private readonly decoderRegistry = createDefaultAudioDecoderRegistry()
|
||||
|
||||
constructor(wcdb4Client: Wcdb4Client) {
|
||||
constructor(wcdb4Client: Wcdb4Client, accountRoot?: string) {
|
||||
this.wcdb4Client = wcdb4Client
|
||||
const clientWithRoot = wcdb4Client as unknown as { getAccountRoot?: () => string }
|
||||
const resolvedRoot = accountRoot || clientWithRoot.getAccountRoot?.() || 'unbound-account'
|
||||
this.accountIdentity = voiceAccountIdentity(resolvedRoot)
|
||||
}
|
||||
|
||||
async resolveVoice(
|
||||
@@ -50,7 +55,8 @@ export class VoiceService {
|
||||
createTime: number,
|
||||
svrId?: string | number
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
const cacheKey = this.buildCacheKey(sessionId, localId, createTime)
|
||||
const reference = { sessionId, localId, createTime, svrId }
|
||||
const cacheKey = this.buildCacheKey(reference)
|
||||
|
||||
const cached = this.voiceCache.get(cacheKey)
|
||||
if (cached) {
|
||||
@@ -89,7 +95,7 @@ export class VoiceService {
|
||||
const missing: Array<{ index: number; reference: VoiceReference }> = []
|
||||
references.forEach((reference, index) => {
|
||||
const cached = this.voiceCache.get(
|
||||
this.buildCacheKey(reference.sessionId, reference.localId, reference.createTime)
|
||||
this.buildCacheKey(reference)
|
||||
)
|
||||
if (cached) results[index] = { success: true, data: cached }
|
||||
else missing.push({ index, reference })
|
||||
@@ -131,11 +137,7 @@ export class VoiceService {
|
||||
decoded.channels
|
||||
)
|
||||
const data = wavData.toString('base64')
|
||||
const cacheKey = this.buildCacheKey(
|
||||
reference.sessionId,
|
||||
reference.localId,
|
||||
reference.createTime
|
||||
)
|
||||
const cacheKey = this.buildCacheKey(reference)
|
||||
this.voiceCache.set(cacheKey, data)
|
||||
this.pcmCache.set(cacheKey, { ...decoded, codec: 'silk' })
|
||||
results[index] = { success: true, data }
|
||||
@@ -168,7 +170,7 @@ export class VoiceService {
|
||||
createTime: number,
|
||||
svrId?: string | number
|
||||
): Promise<ResolvePcmResult> {
|
||||
const cacheKey = this.buildCacheKey(sessionId, localId, createTime)
|
||||
const cacheKey = this.buildCacheKey({ sessionId, localId, createTime, svrId })
|
||||
const cached = this.pcmCache.get(cacheKey)
|
||||
if (cached) return { success: true, audio: cached }
|
||||
|
||||
@@ -217,8 +219,8 @@ export class VoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
private buildCacheKey(sessionId: string, localId: number, createTime: number): string {
|
||||
return `${sessionId}-${localId}-${createTime}`
|
||||
private buildCacheKey(reference: VoiceReference): string {
|
||||
return `voice-cache-v2:${this.accountIdentity}:${voiceMessageIdentity(reference)}`
|
||||
}
|
||||
|
||||
private buildCandidates(sessionId: string): string[] {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import { displayContactName } from '../../../../shared/contact-name'
|
||||
|
||||
interface ConversationItemProps {
|
||||
contact: Contact
|
||||
@@ -12,10 +13,9 @@ export function ConversationItem({
|
||||
active,
|
||||
onSelect
|
||||
}: ConversationItemProps): React.ReactElement {
|
||||
const nickname = contact.m_nsNickName?.trim()
|
||||
const avatarUsername = contact.m_nsUsrName
|
||||
const internalWxid = contact.wxid || avatarUsername
|
||||
const displayName = nickname || internalWxid || '未命名会话'
|
||||
const displayName = displayContactName(contact, '未命名会话')
|
||||
const initial = (displayName || internalWxid || '?').charAt(0)
|
||||
const [repairedAvatar, setRepairedAvatar] = useState<{
|
||||
username: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Contact, Message } from '../../../../shared/types'
|
||||
import type { ExportFormat, GroupMemberName } from './exportTypes'
|
||||
import { resolveMemberName } from '../../../../shared/member-names'
|
||||
import { displayContactName } from '../../../../shared/contact-name'
|
||||
|
||||
export const messageKinds = [
|
||||
['text', '文字'],
|
||||
@@ -24,7 +25,7 @@ export const formatLabels: Record<ExportFormat, { label: string; hint?: string }
|
||||
export const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
|
||||
|
||||
export function displayName(contact: Contact | null): string {
|
||||
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
|
||||
return displayContactName(contact, '未选择会话')
|
||||
}
|
||||
|
||||
export function formatPreviewTime(message: Message): string {
|
||||
|
||||
@@ -598,6 +598,8 @@ export function ScheduledReportsWorkspace({
|
||||
const [editingTask, setEditingTask] = React.useState<ScheduledReportTask | null>(null)
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [deletingTask, setDeletingTask] = React.useState<ScheduledReportTask | null>(null)
|
||||
const [retryingExecution, setRetryingExecution] =
|
||||
React.useState<ScheduledReportExecution | null>(null)
|
||||
const [busyTaskId, setBusyTaskId] = React.useState<string | null>(null)
|
||||
|
||||
const load = React.useCallback(async (): Promise<void> => {
|
||||
@@ -784,6 +786,13 @@ export function ScheduledReportsWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
const confirmRetrySend = async (): Promise<void> => {
|
||||
if (!retryingExecution || busyTaskId) return
|
||||
const execution = retryingExecution
|
||||
setRetryingExecution(null)
|
||||
await retrySend(execution)
|
||||
}
|
||||
|
||||
const testErrorNotification = async (task: ScheduledReportTask): Promise<void> => {
|
||||
setBusyTaskId(task.id)
|
||||
try {
|
||||
@@ -1090,7 +1099,7 @@ export function ScheduledReportsWorkspace({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void retrySend(execution)}
|
||||
onClick={() => setRetryingExecution(execution)}
|
||||
>
|
||||
{busy ? '发送中…' : '重新发送'}
|
||||
</Button>
|
||||
@@ -1177,6 +1186,44 @@ export function ScheduledReportsWorkspace({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog
|
||||
open={Boolean(retryingExecution)}
|
||||
onOpenChange={(open) => !open && setRetryingExecution(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>将重新发送这份日报?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="grid gap-1">
|
||||
<span>
|
||||
目标:
|
||||
{retryingExecution?.sendTarget ||
|
||||
tasks.find((item) => item.id === retryingExecution?.taskId)?.target ||
|
||||
tasks.find((item) => item.id === retryingExecution?.taskId)?.group ||
|
||||
'未指定'}
|
||||
</span>
|
||||
<span>
|
||||
日报:
|
||||
{tasks.find((item) => item.id === retryingExecution?.taskId)?.name || '定时日报'}
|
||||
</span>
|
||||
<span>日期:{formatDateTime(retryingExecution?.startedAt)}</span>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={Boolean(busyTaskId)}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void confirmRetrySend()
|
||||
}}
|
||||
disabled={Boolean(busyTaskId)}
|
||||
>
|
||||
{busyTaskId ? '发送中…' : '确认重新发送'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import { displayContactName } from '../../../../shared/contact-name'
|
||||
export type {
|
||||
GeneratedReportRecord,
|
||||
ReportAssetStatus,
|
||||
@@ -12,4 +13,4 @@ export type {
|
||||
export type ReportWorkspaceView = 'configure' | 'result'
|
||||
|
||||
export const contactDisplayName = (contact: Contact | null): string =>
|
||||
contact?.m_nsNickName?.trim() || contact?.m_nsUsrName || '未命名群聊'
|
||||
displayContactName(contact, '未命名群聊')
|
||||
|
||||
@@ -1000,37 +1000,43 @@ export function AISearchWorkspace({
|
||||
<div className="ai-search-insufficient-icon">!</div>
|
||||
<span className="ai-search-kicker">检索反馈</span>
|
||||
<h2>{analysisError || '当前范围没有足够证据'}</h2>
|
||||
<p>可以扩大时间范围、切换群聊,或换一个更具体的问题。</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-5"
|
||||
onClick={() => {
|
||||
const expandToAll = range === '30d' || range === 'all'
|
||||
setRange(expandToAll ? 'all' : '30d')
|
||||
setTimeRangeOverride(
|
||||
expandToAll
|
||||
? {
|
||||
label: '全部历史',
|
||||
reason: '用户主动扩大到全部历史',
|
||||
source: 'user_retry'
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
skipNextCache()
|
||||
void runAnalysis(undefined, {
|
||||
range: expandToAll ? 'all' : '30d',
|
||||
timeRangeOverride: expandToAll
|
||||
? {
|
||||
label: '全部历史',
|
||||
reason: '用户主动扩大到全部历史',
|
||||
source: 'user_retry'
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
}}
|
||||
>
|
||||
{range === '30d' || range === 'all' ? '搜索全部历史' : '扩大到近 30 天'}
|
||||
</Button>
|
||||
{queryAgentEnabled ? (
|
||||
<p>可以切换聊天范围,或换一个更具体的问题。</p>
|
||||
) : (
|
||||
<>
|
||||
<p>可以扩大时间范围、切换群聊,或换一个更具体的问题。</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-5"
|
||||
onClick={() => {
|
||||
const expandToAll = range === '30d' || range === 'all'
|
||||
setRange(expandToAll ? 'all' : '30d')
|
||||
setTimeRangeOverride(
|
||||
expandToAll
|
||||
? {
|
||||
label: '全部历史',
|
||||
reason: '用户主动扩大到全部历史',
|
||||
source: 'user_retry'
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
skipNextCache()
|
||||
void runAnalysis(undefined, {
|
||||
range: expandToAll ? 'all' : '30d',
|
||||
timeRangeOverride: expandToAll
|
||||
? {
|
||||
label: '全部历史',
|
||||
reason: '用户主动扩大到全部历史',
|
||||
source: 'user_retry'
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
}}
|
||||
>
|
||||
{range === '30d' || range === 'all' ? '搜索全部历史' : '扩大到近 30 天'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1400,7 +1406,7 @@ export function AISearchWorkspace({
|
||||
<AISearchComposer
|
||||
query={query}
|
||||
sourceLabel={sourceLabel}
|
||||
rangeLabel={RANGE_LABELS[range]}
|
||||
rangeLabel={queryAgentEnabled ? '时间由问题决定' : RANGE_LABELS[range]}
|
||||
history={history}
|
||||
historyOpen={historyOpen}
|
||||
loading={stage === 'loading'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
|
||||
import { isKnowledgeFresh } from '../../../../shared/knowledge'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import { displayContactName } from '../../../../shared/contact-name'
|
||||
import type { SearchTrace } from './searchTypes'
|
||||
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
@@ -83,11 +84,7 @@ export const formatKnowledgeProcessed = (status: KnowledgeRuntimeStatus): string
|
||||
}
|
||||
|
||||
export const contactLabel = (contact: Contact | null | undefined): string =>
|
||||
contact?.m_nsNickName ||
|
||||
contact?.remark ||
|
||||
contact?.wechatNickname ||
|
||||
contact?.m_nsUsrName ||
|
||||
'未选择会话'
|
||||
displayContactName(contact, '未选择会话')
|
||||
|
||||
export const formatSearchTraceOverview = (
|
||||
trace: SearchTrace
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { Contact } from '../../../../../shared/types'
|
||||
import { displayContactName } from '../../../../../shared/contact-name'
|
||||
import type {
|
||||
VoiceBatchConversationSummary,
|
||||
VoiceBatchProgress,
|
||||
@@ -68,7 +69,7 @@ const hasBatchApi = (): boolean =>
|
||||
const BATCH_PAGE_SIZE = 12
|
||||
|
||||
function contactName(contact: Contact): string {
|
||||
return contact.m_nsNickName || contact.remark || contact.wechatNickname || contact.m_nsUsrName
|
||||
return displayContactName(contact)
|
||||
}
|
||||
|
||||
export function VoiceRecognitionPage({
|
||||
@@ -640,7 +641,7 @@ export function VoiceRecognitionPage({
|
||||
{' · '}剩余 {formatTaskDuration(batchProgress.estimatedRemainingMs)}
|
||||
</small>
|
||||
{currentBatchConversation && (
|
||||
<small>当前会话:{currentBatchConversation.m_nsNickName}</small>
|
||||
<small>当前会话:{contactName(currentBatchConversation)}</small>
|
||||
)}
|
||||
</div>
|
||||
<progress value={batchProgress.processed} max={Math.max(1, batchProgress.total)} />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Contact } from './types'
|
||||
|
||||
type ContactNameSource = Partial<Pick<
|
||||
Contact,
|
||||
| 'm_nsNickName'
|
||||
| 'm_nsUsrName'
|
||||
| 'remark'
|
||||
| 'alias'
|
||||
| 'wechatNickname'
|
||||
| 'wechatId'
|
||||
| 'wxid'
|
||||
>>
|
||||
|
||||
const nonRenderingCharacters = /[\p{Cc}\p{Cf}\p{Co}\p{Cs}]/gu
|
||||
const trailingPathCharacters = /[. ]+$/u
|
||||
const reservedDeviceName = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?$/iu
|
||||
|
||||
/** Remove characters that can make a name invisible while preserving normal emoji and CJK text. */
|
||||
export function visibleNamePart(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replace(nonRenderingCharacters, '')
|
||||
.replace(/[\r\n\t]+/gu, ' ')
|
||||
.replace(/ {2,}/gu, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function isVisibleName(value: unknown): boolean {
|
||||
return visibleNamePart(value).length > 0
|
||||
}
|
||||
|
||||
/** Resolve a contact label without ever exposing an all-invisible raw nickname. */
|
||||
export function displayContactName(
|
||||
contact: ContactNameSource | null | undefined,
|
||||
fallback = '未命名联系人'
|
||||
): string {
|
||||
const candidates = [
|
||||
contact?.m_nsNickName,
|
||||
contact?.remark,
|
||||
contact?.alias,
|
||||
contact?.wechatNickname,
|
||||
contact?.wechatId,
|
||||
contact?.wxid,
|
||||
contact?.m_nsUsrName
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const visible = visibleNamePart(candidate)
|
||||
if (visible) return visible
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Make a human-readable label safe for a file or directory name. */
|
||||
export function filesystemSafeName(value: unknown, fallback = '未命名联系人'): string {
|
||||
const visible = visibleNamePart(value)
|
||||
const replaced = visible
|
||||
.replace(/[\\/:*?"<>|]/gu, '_')
|
||||
.replace(/[\r\n\t]+/gu, ' ')
|
||||
.replace(/ {2,}/gu, ' ')
|
||||
.replace(trailingPathCharacters, '')
|
||||
.trim()
|
||||
const candidate = replaced || visibleNamePart(fallback) || '未命名联系人'
|
||||
if (reservedDeviceName.test(candidate)) return `${candidate}_`
|
||||
return Array.from(candidate).slice(0, 80).join('') || '未命名联系人'
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { visibleNamePart } from './contact-name'
|
||||
|
||||
export type MemberNameMode = 'groupNickname' | 'wechatNickname' | 'remark'
|
||||
|
||||
export interface MemberNameFields {
|
||||
@@ -9,7 +11,7 @@ export interface MemberNameFields {
|
||||
}
|
||||
|
||||
const firstName = (...values: Array<string | undefined>): string =>
|
||||
values.map((value) => String(value || '').trim()).find(Boolean) || ''
|
||||
values.map((value) => visibleNamePart(value)).find(Boolean) || ''
|
||||
|
||||
export function resolveMemberName(member: MemberNameFields, mode: MemberNameMode): string {
|
||||
if (mode === 'groupNickname') {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user