mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-08 15:43:22 +08:00
fix: 修复导出日期、语音和联系人名字问题
- 导出的 HTML:点月份先看日期,选中日期后才跳到对应消息。 - 语音转文字:不同账号、不同聊天里的语音不会再串到一起。 - 联系人名字:正常昵称显示不变;昵称异常时使用可见的备注或账号,导出文件名也能正常使用。
This commit is contained in:
@@ -329,6 +329,32 @@ body {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.timeline-month small { color: inherit; }
|
.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; }
|
.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; }
|
||||||
.lazy-hint {
|
.lazy-hint {
|
||||||
width: min(100%, 820px);
|
width: min(100%, 820px);
|
||||||
@@ -787,6 +813,23 @@ body {
|
|||||||
}
|
}
|
||||||
.timeline-months { display: flex; gap: 6px; }
|
.timeline-months { display: flex; gap: 6px; }
|
||||||
.timeline-months[hidden] { display: none; }
|
.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 {
|
.timeline-month {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -879,6 +922,9 @@ const renderExportScript = (name: string): string => `
|
|||||||
let scrollLoadSuppressed = false
|
let scrollLoadSuppressed = false
|
||||||
let activeMonthUpdatePending = false
|
let activeMonthUpdatePending = false
|
||||||
let expandedTimelineYear = ''
|
let expandedTimelineYear = ''
|
||||||
|
let expandedTimelineMonth = ''
|
||||||
|
let selectedTimelineMonth = ''
|
||||||
|
let selectedTimelineDate = ''
|
||||||
const tabPositions = new Map()
|
const tabPositions = new Map()
|
||||||
let lastScrollTop = 0
|
let lastScrollTop = 0
|
||||||
let zoom = 1
|
let zoom = 1
|
||||||
@@ -941,6 +987,13 @@ const renderExportScript = (name: string): string => `
|
|||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1)
|
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 kindOf = (message) => {
|
||||||
const data = message.contentData || {}
|
const data = message.contentData || {}
|
||||||
const shareType = String(data.typeVal || '')
|
const shareType = String(data.typeVal || '')
|
||||||
@@ -1317,6 +1370,15 @@ const renderExportScript = (name: string): string => `
|
|||||||
if (months) months.hidden = !expanded
|
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 = () => {
|
const renderTimeline = () => {
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
expandedTimelineYear = ''
|
expandedTimelineYear = ''
|
||||||
@@ -1327,18 +1389,36 @@ const renderExportScript = (name: string): string => `
|
|||||||
for (const message of filtered) {
|
for (const message of filtered) {
|
||||||
const key = monthKey(message)
|
const key = monthKey(message)
|
||||||
if (key === 'unknown') continue
|
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()
|
const yearGroups = new Map()
|
||||||
for (const [key, total] of groups) {
|
for (const [key, group] of groups) {
|
||||||
const parts = key.split('-')
|
const parts = key.split('-')
|
||||||
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], [])
|
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)) {
|
if (!years.includes(expandedTimelineYear)) {
|
||||||
expandedTimelineYear = years[years.length - 1] || ''
|
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 = ''
|
let html = ''
|
||||||
for (const year of years) {
|
for (const year of years) {
|
||||||
const expanded = year === expandedTimelineYear
|
const expanded = year === expandedTimelineYear
|
||||||
@@ -1348,10 +1428,18 @@ const renderExportScript = (name: string): string => `
|
|||||||
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
|
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
|
||||||
esc(year) + ' 年</button>' +
|
esc(year) + ' 年</button>' +
|
||||||
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
|
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
|
||||||
yearGroups.get(year).map((entry) =>
|
yearGroups.get(year).map((entry) => {
|
||||||
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) + '">' +
|
const monthExpanded = entry.key === expandedTimelineMonth
|
||||||
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>'
|
return '<div class="timeline-month-entry">' +
|
||||||
).join('') + '</div></section>'
|
'<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>'
|
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
|
||||||
}
|
}
|
||||||
@@ -1377,16 +1465,27 @@ const renderExportScript = (name: string): string => `
|
|||||||
const activeMessage = atBottom
|
const activeMessage = atBottom
|
||||||
? visible[visible.length - 1] || messages[messages.length - 1]
|
? visible[visible.length - 1] || messages[messages.length - 1]
|
||||||
: anchoredMessage || visible[0] || messages[0]
|
: 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
|
let activeButton
|
||||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||||
const active = button.dataset.month === key
|
const active = button.dataset.month === key
|
||||||
button.classList.toggle('active', active)
|
button.classList.toggle('active', active)
|
||||||
if (active) activeButton = button
|
if (active) activeButton = button
|
||||||
})
|
})
|
||||||
|
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||||
|
button.classList.toggle('active', button.dataset.date === activeDate)
|
||||||
|
})
|
||||||
if (!activeButton) return
|
if (!activeButton) return
|
||||||
const activeYear = key.split('-')[0]
|
const activeYear = key.split('-')[0]
|
||||||
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
|
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
|
||||||
|
if (key !== expandedTimelineMonth) setExpandedTimelineMonth(key)
|
||||||
const timelineBounds = timeline.getBoundingClientRect()
|
const timelineBounds = timeline.getBoundingClientRect()
|
||||||
const buttonBounds = activeButton.getBoundingClientRect()
|
const buttonBounds = activeButton.getBoundingClientRect()
|
||||||
if (buttonBounds.top < timelineBounds.top) {
|
if (buttonBounds.top < timelineBounds.top) {
|
||||||
@@ -1535,6 +1634,8 @@ const renderExportScript = (name: string): string => `
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const applyFilters = (restorePosition = false) => {
|
const applyFilters = (restorePosition = false) => {
|
||||||
|
selectedTimelineMonth = ''
|
||||||
|
selectedTimelineDate = ''
|
||||||
filtered = matchingMessages()
|
filtered = matchingMessages()
|
||||||
renderTimeline()
|
renderTimeline()
|
||||||
if (!restorePosition || !restoreTabPosition()) resetWindow(true)
|
if (!restorePosition || !restoreTabPosition()) resetWindow(true)
|
||||||
@@ -1572,18 +1673,32 @@ const renderExportScript = (name: string): string => `
|
|||||||
target.classList.add('located')
|
target.classList.add('located')
|
||||||
window.setTimeout(() => target.classList.remove('located'), 1600)
|
window.setTimeout(() => target.classList.remove('located'), 1600)
|
||||||
}
|
}
|
||||||
const jumpToMonth = (key) => {
|
const selectTimelineMonth = (key) => {
|
||||||
const index = filtered.findIndex((message) => monthKey(message) === 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
|
if (index < 0) return
|
||||||
|
selectedTimelineMonth = ''
|
||||||
|
selectedTimelineDate = key
|
||||||
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
|
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
|
||||||
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
|
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
|
||||||
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
|
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
|
||||||
renderWindow()
|
renderWindow()
|
||||||
const target = list.querySelector('.message[data-index="' + index + '"]')
|
const target = list.querySelector('.message[data-index="' + index + '"]')
|
||||||
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
|
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
|
||||||
setExpandedTimelineYear(key.split('-')[0])
|
const month = key.slice(0, 7)
|
||||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
setExpandedTimelineYear(month.slice(0, 4))
|
||||||
button.classList.toggle('active', button.dataset.month === key)
|
setExpandedTimelineMonth(month)
|
||||||
|
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||||
|
button.classList.toggle('active', button.dataset.date === key)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const slideWindow = (direction) => {
|
const slideWindow = (direction) => {
|
||||||
@@ -1620,6 +1735,10 @@ const renderExportScript = (name: string): string => `
|
|||||||
const nearTop = currentTop < 180
|
const nearTop = currentTop < 180
|
||||||
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
||||||
lastScrollTop = currentTop
|
lastScrollTop = currentTop
|
||||||
|
if (!scrollLoadSuppressed) {
|
||||||
|
selectedTimelineMonth = ''
|
||||||
|
selectedTimelineDate = ''
|
||||||
|
}
|
||||||
scheduleActiveMonthUpdate()
|
scheduleActiveMonthUpdate()
|
||||||
if (scrollLoadSuppressed) return
|
if (scrollLoadSuppressed) return
|
||||||
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
||||||
@@ -1676,7 +1795,12 @@ const renderExportScript = (name: string): string => `
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const button = event.target.closest('[data-month]')
|
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)
|
const updateZoom = () => preview.style.setProperty('--zoom', zoom)
|
||||||
|
|||||||
+70
-14
@@ -25,11 +25,12 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
|
|||||||
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'
|
import { resolveMemberName } from '../shared/member-names'
|
||||||
|
import { filesystemSafeName } from '../shared/contact-name'
|
||||||
|
|
||||||
const jobs = new Set<string>()
|
const jobs = new Set<string>()
|
||||||
const activeArchives = new Map<string, Archiver>()
|
const activeArchives = new Map<string, Archiver>()
|
||||||
const safeFilePart = (value: string): string =>
|
const safeFilePart = (value: string, fallback = '聊天档案'): string =>
|
||||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
filesystemSafeName(value, fallback)
|
||||||
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await fs.chmod(destination, 0o644)
|
await fs.chmod(destination, 0o644)
|
||||||
@@ -643,6 +644,7 @@ const kindOf = (message: Message): ExportMessageKind => {
|
|||||||
return 'text'
|
return 'text'
|
||||||
}
|
}
|
||||||
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||||
|
const csvBom = '\uFEFF'
|
||||||
|
|
||||||
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||||
if (format === 'html') return renderExportPage(name)
|
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)
|
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||||
if (format === 'markdown')
|
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 `# ${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) =>
|
...messages.map((m) =>
|
||||||
[
|
[
|
||||||
@@ -664,7 +666,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
|||||||
.map(csv)
|
.map(csv)
|
||||||
.join(',')
|
.join(',')
|
||||||
)
|
)
|
||||||
].join('\n')
|
].join('\r\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SingleExportOptions {
|
interface SingleExportOptions {
|
||||||
@@ -681,6 +683,8 @@ interface AllExportManifestEntry {
|
|||||||
type: ExportTarget['type']
|
type: ExportTarget['type']
|
||||||
folder: string
|
folder: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
|
status: 'completed' | 'failed'
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeAllExportManifest = async (
|
const writeAllExportManifest = async (
|
||||||
@@ -1086,7 +1090,10 @@ async function runSingleExport(
|
|||||||
}
|
}
|
||||||
const voiceService =
|
const voiceService =
|
||||||
request.includeMedia && chat.getChatDb()
|
request.includeMedia && chat.getChatDb()
|
||||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
? new VoiceService(
|
||||||
|
chat.getChatDb()!.getWcdb4Client(),
|
||||||
|
chat.getChatDb()!.getWcdb4Client().getAccountRoot()
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
|
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
|
||||||
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
|
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
|
||||||
@@ -1635,6 +1642,8 @@ async function runAllExport(
|
|||||||
let outputDir = ''
|
let outputDir = ''
|
||||||
const manifest: AllExportManifestEntry[] = []
|
const manifest: AllExportManifestEntry[] = []
|
||||||
let totalMessages = 0
|
let totalMessages = 0
|
||||||
|
let failedCount = 0
|
||||||
|
const failureMessages: string[] = []
|
||||||
try {
|
try {
|
||||||
const targets = [...(request.targets || [])].sort((left, right) =>
|
const targets = [...(request.targets || [])].sort((left, right) =>
|
||||||
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
|
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
|
||||||
@@ -1667,6 +1676,9 @@ async function runAllExport(
|
|||||||
const categoryName = target.type === 'group' ? '群聊' : '联系人'
|
const categoryName = target.type === 'group' ? '群聊' : '联系人'
|
||||||
const categoryDir = join(outputDir, categoryName)
|
const categoryDir = join(outputDir, categoryName)
|
||||||
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
|
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
|
||||||
|
const folder = `${categoryName}/${folderName}`
|
||||||
|
let result: ExportResult
|
||||||
|
try {
|
||||||
await fs.mkdir(categoryDir, { recursive: true })
|
await fs.mkdir(categoryDir, { recursive: true })
|
||||||
const conversationOutputRoot =
|
const conversationOutputRoot =
|
||||||
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
||||||
@@ -1682,7 +1694,7 @@ async function runAllExport(
|
|||||||
currentTargetType: target.type
|
currentTargetType: target.type
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await runSingleExport(
|
result = await runSingleExport(
|
||||||
{
|
{
|
||||||
...request,
|
...request,
|
||||||
targets: [target],
|
targets: [target],
|
||||||
@@ -1723,24 +1735,62 @@ async function runAllExport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (!result.success) {
|
} catch (error) {
|
||||||
if (result.error === '已取消') throw new Error('已取消')
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
throw new Error(`${target.name}:${result.error || '导出失败'}`)
|
if (message === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
|
||||||
|
result = { success: false, error: message }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
const messageCount = result.messageCount || 0
|
||||||
totalMessages += messageCount
|
totalMessages += messageCount
|
||||||
manifest.push({
|
manifest.push({
|
||||||
id: target.userMd5,
|
id: target.userMd5,
|
||||||
name: target.name,
|
name: target.name,
|
||||||
type: target.type,
|
type: target.type,
|
||||||
folder: `${categoryName}/${folderName}`,
|
folder,
|
||||||
messageCount
|
messageCount,
|
||||||
|
status: 'completed'
|
||||||
})
|
})
|
||||||
|
}
|
||||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
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
|
let completedPath = outputDir
|
||||||
if (request.zip) {
|
if (request.zip) {
|
||||||
@@ -1759,17 +1809,23 @@ async function runAllExport(
|
|||||||
|
|
||||||
send({
|
send({
|
||||||
jobId: request.jobId,
|
jobId: request.jobId,
|
||||||
phase: 'completed',
|
phase: failedCount ? 'failed' : 'completed',
|
||||||
processed: totalMessages,
|
processed: totalMessages,
|
||||||
total: totalMessages,
|
total: totalMessages,
|
||||||
percent: 100,
|
percent: 100,
|
||||||
|
error: finalError,
|
||||||
outputPath: completedPath,
|
outputPath: completedPath,
|
||||||
currentTargetIndex: targets.length,
|
currentTargetIndex: targets.length,
|
||||||
currentTargetCount: targets.length,
|
currentTargetCount: targets.length,
|
||||||
currentTargetName: targets.at(-1)?.name,
|
currentTargetName: targets.at(-1)?.name,
|
||||||
currentTargetType: targets.at(-1)?.type
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
const cancelled = !jobs.has(request.jobId) || message === '已取消'
|
const cancelled = !jobs.has(request.jobId) || message === '已取消'
|
||||||
|
|||||||
+2
-2
@@ -818,7 +818,7 @@ app.whenReady().then(async () => {
|
|||||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||||
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
||||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||||
voiceService = new VoiceService(wcdb4Client)
|
voiceService = new VoiceService(wcdb4Client, resolvedRoot)
|
||||||
voiceRecognition?.connect(voiceService, resolvedRoot)
|
voiceRecognition?.connect(voiceService, resolvedRoot)
|
||||||
stickerService = new StickerService(wcdb4Client)
|
stickerService = new StickerService(wcdb4Client)
|
||||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||||
@@ -1958,7 +1958,7 @@ app.whenReady().then(async () => {
|
|||||||
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||||
const client = chat.getChatDb()?.getWcdb4Client()
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
if (client) {
|
if (client) {
|
||||||
voiceService = new VoiceService(client)
|
voiceService = new VoiceService(client, client.getAccountRoot())
|
||||||
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
||||||
const monitoring = await client.startMonitor((type, json) => {
|
const monitoring = await client.startMonitor((type, json) => {
|
||||||
client.invalidateSessionCache()
|
client.invalidateSessionCache()
|
||||||
|
|||||||
+13
-11
@@ -4,6 +4,7 @@ import {
|
|||||||
createDefaultAudioDecoderRegistry,
|
createDefaultAudioDecoderRegistry,
|
||||||
type EncodedVoiceSource
|
type EncodedVoiceSource
|
||||||
} from './voice-pipeline/audio-decoder'
|
} from './voice-pipeline/audio-decoder'
|
||||||
|
import { voiceAccountIdentity, voiceMessageIdentity } from './voice-pipeline/voice-message-identity'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
findSilkWasmRuntimeLocation,
|
findSilkWasmRuntimeLocation,
|
||||||
@@ -36,12 +37,16 @@ export interface VoiceReference {
|
|||||||
|
|
||||||
export class VoiceService {
|
export class VoiceService {
|
||||||
private wcdb4Client: Wcdb4Client
|
private wcdb4Client: Wcdb4Client
|
||||||
|
private readonly accountIdentity: string
|
||||||
private voiceCache = new Map<string, string>()
|
private voiceCache = new Map<string, string>()
|
||||||
private pcmCache = new Map<string, ResolvedPcmAudio>()
|
private pcmCache = new Map<string, ResolvedPcmAudio>()
|
||||||
private readonly decoderRegistry = createDefaultAudioDecoderRegistry()
|
private readonly decoderRegistry = createDefaultAudioDecoderRegistry()
|
||||||
|
|
||||||
constructor(wcdb4Client: Wcdb4Client) {
|
constructor(wcdb4Client: Wcdb4Client, accountRoot?: string) {
|
||||||
this.wcdb4Client = wcdb4Client
|
this.wcdb4Client = wcdb4Client
|
||||||
|
const clientWithRoot = wcdb4Client as unknown as { getAccountRoot?: () => string }
|
||||||
|
const resolvedRoot = accountRoot || clientWithRoot.getAccountRoot?.() || 'unbound-account'
|
||||||
|
this.accountIdentity = voiceAccountIdentity(resolvedRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveVoice(
|
async resolveVoice(
|
||||||
@@ -50,7 +55,8 @@ export class VoiceService {
|
|||||||
createTime: number,
|
createTime: number,
|
||||||
svrId?: string | number
|
svrId?: string | number
|
||||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
): 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)
|
const cached = this.voiceCache.get(cacheKey)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -89,7 +95,7 @@ export class VoiceService {
|
|||||||
const missing: Array<{ index: number; reference: VoiceReference }> = []
|
const missing: Array<{ index: number; reference: VoiceReference }> = []
|
||||||
references.forEach((reference, index) => {
|
references.forEach((reference, index) => {
|
||||||
const cached = this.voiceCache.get(
|
const cached = this.voiceCache.get(
|
||||||
this.buildCacheKey(reference.sessionId, reference.localId, reference.createTime)
|
this.buildCacheKey(reference)
|
||||||
)
|
)
|
||||||
if (cached) results[index] = { success: true, data: cached }
|
if (cached) results[index] = { success: true, data: cached }
|
||||||
else missing.push({ index, reference })
|
else missing.push({ index, reference })
|
||||||
@@ -131,11 +137,7 @@ export class VoiceService {
|
|||||||
decoded.channels
|
decoded.channels
|
||||||
)
|
)
|
||||||
const data = wavData.toString('base64')
|
const data = wavData.toString('base64')
|
||||||
const cacheKey = this.buildCacheKey(
|
const cacheKey = this.buildCacheKey(reference)
|
||||||
reference.sessionId,
|
|
||||||
reference.localId,
|
|
||||||
reference.createTime
|
|
||||||
)
|
|
||||||
this.voiceCache.set(cacheKey, data)
|
this.voiceCache.set(cacheKey, data)
|
||||||
this.pcmCache.set(cacheKey, { ...decoded, codec: 'silk' })
|
this.pcmCache.set(cacheKey, { ...decoded, codec: 'silk' })
|
||||||
results[index] = { success: true, data }
|
results[index] = { success: true, data }
|
||||||
@@ -168,7 +170,7 @@ export class VoiceService {
|
|||||||
createTime: number,
|
createTime: number,
|
||||||
svrId?: string | number
|
svrId?: string | number
|
||||||
): Promise<ResolvePcmResult> {
|
): Promise<ResolvePcmResult> {
|
||||||
const cacheKey = this.buildCacheKey(sessionId, localId, createTime)
|
const cacheKey = this.buildCacheKey({ sessionId, localId, createTime, svrId })
|
||||||
const cached = this.pcmCache.get(cacheKey)
|
const cached = this.pcmCache.get(cacheKey)
|
||||||
if (cached) return { success: true, audio: cached }
|
if (cached) return { success: true, audio: cached }
|
||||||
|
|
||||||
@@ -217,8 +219,8 @@ export class VoiceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCacheKey(sessionId: string, localId: number, createTime: number): string {
|
private buildCacheKey(reference: VoiceReference): string {
|
||||||
return `${sessionId}-${localId}-${createTime}`
|
return `voice-cache-v2:${this.accountIdentity}:${voiceMessageIdentity(reference)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCandidates(sessionId: string): string[] {
|
private buildCandidates(sessionId: string): string[] {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
|
import { displayContactName } from '../../../../shared/contact-name'
|
||||||
|
|
||||||
interface ConversationItemProps {
|
interface ConversationItemProps {
|
||||||
contact: Contact
|
contact: Contact
|
||||||
@@ -12,10 +13,9 @@ export function ConversationItem({
|
|||||||
active,
|
active,
|
||||||
onSelect
|
onSelect
|
||||||
}: ConversationItemProps): React.ReactElement {
|
}: ConversationItemProps): React.ReactElement {
|
||||||
const nickname = contact.m_nsNickName?.trim()
|
|
||||||
const avatarUsername = contact.m_nsUsrName
|
const avatarUsername = contact.m_nsUsrName
|
||||||
const internalWxid = contact.wxid || avatarUsername
|
const internalWxid = contact.wxid || avatarUsername
|
||||||
const displayName = nickname || internalWxid || '未命名会话'
|
const displayName = displayContactName(contact, '未命名会话')
|
||||||
const initial = (displayName || internalWxid || '?').charAt(0)
|
const initial = (displayName || internalWxid || '?').charAt(0)
|
||||||
const [repairedAvatar, setRepairedAvatar] = useState<{
|
const [repairedAvatar, setRepairedAvatar] = useState<{
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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'
|
import { resolveMemberName } from '../../../../shared/member-names'
|
||||||
|
import { displayContactName } from '../../../../shared/contact-name'
|
||||||
|
|
||||||
export const messageKinds = [
|
export const messageKinds = [
|
||||||
['text', '文字'],
|
['text', '文字'],
|
||||||
@@ -24,7 +25,7 @@ export const formatLabels: Record<ExportFormat, { label: string; hint?: string }
|
|||||||
export const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
|
export const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
|
||||||
|
|
||||||
export function displayName(contact: Contact | null): string {
|
export function displayName(contact: Contact | null): string {
|
||||||
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
|
return displayContactName(contact, '未选择会话')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPreviewTime(message: Message): string {
|
export function formatPreviewTime(message: Message): string {
|
||||||
|
|||||||
@@ -598,6 +598,8 @@ export function ScheduledReportsWorkspace({
|
|||||||
const [editingTask, setEditingTask] = React.useState<ScheduledReportTask | null>(null)
|
const [editingTask, setEditingTask] = React.useState<ScheduledReportTask | null>(null)
|
||||||
const [saving, setSaving] = React.useState(false)
|
const [saving, setSaving] = React.useState(false)
|
||||||
const [deletingTask, setDeletingTask] = React.useState<ScheduledReportTask | null>(null)
|
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 [busyTaskId, setBusyTaskId] = React.useState<string | null>(null)
|
||||||
|
|
||||||
const load = React.useCallback(async (): Promise<void> => {
|
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> => {
|
const testErrorNotification = async (task: ScheduledReportTask): Promise<void> => {
|
||||||
setBusyTaskId(task.id)
|
setBusyTaskId(task.id)
|
||||||
try {
|
try {
|
||||||
@@ -1090,7 +1099,7 @@ export function ScheduledReportsWorkspace({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void retrySend(execution)}
|
onClick={() => setRetryingExecution(execution)}
|
||||||
>
|
>
|
||||||
{busy ? '发送中…' : '重新发送'}
|
{busy ? '发送中…' : '重新发送'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1177,6 +1186,44 @@ export function ScheduledReportsWorkspace({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
|
import { displayContactName } from '../../../../shared/contact-name'
|
||||||
export type {
|
export type {
|
||||||
GeneratedReportRecord,
|
GeneratedReportRecord,
|
||||||
ReportAssetStatus,
|
ReportAssetStatus,
|
||||||
@@ -12,4 +13,4 @@ export type {
|
|||||||
export type ReportWorkspaceView = 'configure' | 'result'
|
export type ReportWorkspaceView = 'configure' | 'result'
|
||||||
|
|
||||||
export const contactDisplayName = (contact: Contact | null): string =>
|
export const contactDisplayName = (contact: Contact | null): string =>
|
||||||
contact?.m_nsNickName?.trim() || contact?.m_nsUsrName || '未命名群聊'
|
displayContactName(contact, '未命名群聊')
|
||||||
|
|||||||
@@ -1000,6 +1000,10 @@ export function AISearchWorkspace({
|
|||||||
<div className="ai-search-insufficient-icon">!</div>
|
<div className="ai-search-insufficient-icon">!</div>
|
||||||
<span className="ai-search-kicker">检索反馈</span>
|
<span className="ai-search-kicker">检索反馈</span>
|
||||||
<h2>{analysisError || '当前范围没有足够证据'}</h2>
|
<h2>{analysisError || '当前范围没有足够证据'}</h2>
|
||||||
|
{queryAgentEnabled ? (
|
||||||
|
<p>可以切换聊天范围,或换一个更具体的问题。</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<p>可以扩大时间范围、切换群聊,或换一个更具体的问题。</p>
|
<p>可以扩大时间范围、切换群聊,或换一个更具体的问题。</p>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1031,6 +1035,8 @@ export function AISearchWorkspace({
|
|||||||
>
|
>
|
||||||
{range === '30d' || range === 'all' ? '搜索全部历史' : '扩大到近 30 天'}
|
{range === '30d' || range === 'all' ? '搜索全部历史' : '扩大到近 30 天'}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1400,7 +1406,7 @@ export function AISearchWorkspace({
|
|||||||
<AISearchComposer
|
<AISearchComposer
|
||||||
query={query}
|
query={query}
|
||||||
sourceLabel={sourceLabel}
|
sourceLabel={sourceLabel}
|
||||||
rangeLabel={RANGE_LABELS[range]}
|
rangeLabel={queryAgentEnabled ? '时间由问题决定' : RANGE_LABELS[range]}
|
||||||
history={history}
|
history={history}
|
||||||
historyOpen={historyOpen}
|
historyOpen={historyOpen}
|
||||||
loading={stage === 'loading'}
|
loading={stage === 'loading'}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
|
import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
|
||||||
import { isKnowledgeFresh } from '../../../../shared/knowledge'
|
import { isKnowledgeFresh } from '../../../../shared/knowledge'
|
||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
|
import { displayContactName } from '../../../../shared/contact-name'
|
||||||
import type { SearchTrace } from './searchTypes'
|
import type { SearchTrace } from './searchTypes'
|
||||||
|
|
||||||
export const formatBytes = (bytes: number): string => {
|
export const formatBytes = (bytes: number): string => {
|
||||||
@@ -83,11 +84,7 @@ export const formatKnowledgeProcessed = (status: KnowledgeRuntimeStatus): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const contactLabel = (contact: Contact | null | undefined): string =>
|
export const contactLabel = (contact: Contact | null | undefined): string =>
|
||||||
contact?.m_nsNickName ||
|
displayContactName(contact, '未选择会话')
|
||||||
contact?.remark ||
|
|
||||||
contact?.wechatNickname ||
|
|
||||||
contact?.m_nsUsrName ||
|
|
||||||
'未选择会话'
|
|
||||||
|
|
||||||
export const formatSearchTraceOverview = (
|
export const formatSearchTraceOverview = (
|
||||||
trace: SearchTrace
|
trace: SearchTrace
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import type { Contact } from '../../../../../shared/types'
|
import type { Contact } from '../../../../../shared/types'
|
||||||
|
import { displayContactName } from '../../../../../shared/contact-name'
|
||||||
import type {
|
import type {
|
||||||
VoiceBatchConversationSummary,
|
VoiceBatchConversationSummary,
|
||||||
VoiceBatchProgress,
|
VoiceBatchProgress,
|
||||||
@@ -68,7 +69,7 @@ const hasBatchApi = (): boolean =>
|
|||||||
const BATCH_PAGE_SIZE = 12
|
const BATCH_PAGE_SIZE = 12
|
||||||
|
|
||||||
function contactName(contact: Contact): string {
|
function contactName(contact: Contact): string {
|
||||||
return contact.m_nsNickName || contact.remark || contact.wechatNickname || contact.m_nsUsrName
|
return displayContactName(contact)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceRecognitionPage({
|
export function VoiceRecognitionPage({
|
||||||
@@ -640,7 +641,7 @@ export function VoiceRecognitionPage({
|
|||||||
{' · '}剩余 {formatTaskDuration(batchProgress.estimatedRemainingMs)}
|
{' · '}剩余 {formatTaskDuration(batchProgress.estimatedRemainingMs)}
|
||||||
</small>
|
</small>
|
||||||
{currentBatchConversation && (
|
{currentBatchConversation && (
|
||||||
<small>当前会话:{currentBatchConversation.m_nsNickName}</small>
|
<small>当前会话:{contactName(currentBatchConversation)}</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<progress value={batchProgress.processed} max={Math.max(1, batchProgress.total)} />
|
<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 type MemberNameMode = 'groupNickname' | 'wechatNickname' | 'remark'
|
||||||
|
|
||||||
export interface MemberNameFields {
|
export interface MemberNameFields {
|
||||||
@@ -9,7 +11,7 @@ export interface MemberNameFields {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstName = (...values: Array<string | undefined>): string =>
|
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 {
|
export function resolveMemberName(member: MemberNameFields, mode: MemberNameMode): string {
|
||||||
if (mode === 'groupNickname') {
|
if (mode === 'groupNickname') {
|
||||||
|
|||||||
@@ -222,6 +222,8 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据
|
|||||||
// 时间范围控件在 Query Agent 主路径下移除
|
// 时间范围控件在 Query Agent 主路径下移除
|
||||||
expect(screen.queryByText('时间范围')).toBeNull()
|
expect(screen.queryByText('时间范围')).toBeNull()
|
||||||
expect(screen.queryByText('不限时间')).toBeNull()
|
expect(screen.queryByText('不限时间')).toBeNull()
|
||||||
|
expect(screen.queryByText('近 30 天')).toBeNull()
|
||||||
|
expect(screen.getByText('时间由问题决定')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Query Agent 关闭时恢复 Legacy 的时间范围控件', async () => {
|
it('Query Agent 关闭时恢复 Legacy 的时间范围控件', async () => {
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ describe('ScheduledReportsWorkspace', () => {
|
|||||||
deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })),
|
deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })),
|
||||||
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
|
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
|
||||||
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } })),
|
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } })),
|
||||||
|
retryScheduledReportSend: vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
data: { status: 'success' }
|
||||||
|
})),
|
||||||
testScheduledReportErrorNotification: vi.fn(async () => ({
|
testScheduledReportErrorNotification: vi.fn(async () => ({
|
||||||
success: true,
|
success: true,
|
||||||
data: { status: 'failed', notificationStatus: 'sent' }
|
data: { status: 'failed', notificationStatus: 'sent' }
|
||||||
@@ -214,4 +218,42 @@ describe('ScheduledReportsWorkspace', () => {
|
|||||||
'success'
|
'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-'))
|
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-tab-position-e2e-'))
|
||||||
try {
|
try {
|
||||||
const dataPath = join(outputDir, 'data', 'messages.js')
|
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({
|
|||||||
avatarMap: {} as Record<string, string>,
|
avatarMap: {} as Record<string, string>,
|
||||||
messages: [] as Message[],
|
messages: [] as Message[],
|
||||||
messagesByUser: {} as Record<string, Message[]>,
|
messagesByUser: {} as Record<string, Message[]>,
|
||||||
|
exportErrors: {} as Record<string, string>,
|
||||||
exportReads: [] as string[],
|
exportReads: [] as string[],
|
||||||
selfInfoReads: 0,
|
selfInfoReads: 0,
|
||||||
groupSnapshotReads: [] as string[],
|
groupSnapshotReads: [] as string[],
|
||||||
@@ -97,6 +98,7 @@ vi.mock('../../src/main/services/chat-service', () => ({
|
|||||||
structuredClone(state.messagesByUser[userMd5] || state.messages),
|
structuredClone(state.messagesByUser[userMd5] || state.messages),
|
||||||
listMessagesForExport: async (userMd5: string) => {
|
listMessagesForExport: async (userMd5: string) => {
|
||||||
state.exportReads.push(userMd5)
|
state.exportReads.push(userMd5)
|
||||||
|
if (state.exportErrors[userMd5]) throw new Error(state.exportErrors[userMd5])
|
||||||
return structuredClone(state.messagesByUser[userMd5] || state.messages)
|
return structuredClone(state.messagesByUser[userMd5] || state.messages)
|
||||||
},
|
},
|
||||||
getChatDb: () => ({
|
getChatDb: () => ({
|
||||||
@@ -279,6 +281,7 @@ describe('media export flow', () => {
|
|||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
|
||||||
state.videoLookups = []
|
state.videoLookups = []
|
||||||
state.messagesByUser = {}
|
state.messagesByUser = {}
|
||||||
|
state.exportErrors = {}
|
||||||
state.exportReads = []
|
state.exportReads = []
|
||||||
state.selfInfoReads = 0
|
state.selfInfoReads = 0
|
||||||
state.groupSnapshotReads = []
|
state.groupSnapshotReads = []
|
||||||
@@ -1233,7 +1236,9 @@ describe('media export flow', () => {
|
|||||||
target('csv-user', '测试联系人')
|
target('csv-user', '测试联系人')
|
||||||
]
|
]
|
||||||
state.messagesByUser = {
|
state.messagesByUser = {
|
||||||
'csv-group': [message({ id: 'group-text', content: '群聊消息' })],
|
'csv-group': [
|
||||||
|
message({ id: 'group-text', content: '群聊消息, 引号 \" 和换行\\n😀' })
|
||||||
|
],
|
||||||
'csv-user': [message({ id: 'user-text', content: '联系人消息' })]
|
'csv-user': [message({ id: 'user-text', content: '联系人消息' })]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1261,11 +1266,71 @@ describe('media export flow', () => {
|
|||||||
expect(userFiles).toHaveLength(1)
|
expect(userFiles).toHaveLength(1)
|
||||||
expect(groupFiles[0]).toMatch(/^测试群聊_\d{8}_\d{6}\.csv$/)
|
expect(groupFiles[0]).toMatch(/^测试群聊_\d{8}_\d{6}\.csv$/)
|
||||||
expect(userFiles[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(readFileSync(join(userDir, userFiles[0]), 'utf8')).toContain('联系人消息')
|
||||||
expect(existsSync(join(outputDir, 'index.html'))).toBe(false)
|
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 () => {
|
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 { cancelExport, runExport } = await import('../../src/main/export-service')
|
||||||
const jobId = 'all-export-cancel'
|
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(1, 'session', 10, 100, '1000')
|
||||||
expect(resolveVoice).toHaveBeenNthCalledWith(2, 'session', 11, 101, '1001')
|
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