feat: 持久化聊天记录导出设置

This commit is contained in:
电摇小子
2026-10-02 19:37:09 +07:00
parent e5a1656644
commit e83e85fe64
3 changed files with 270 additions and 22 deletions
@@ -27,6 +27,7 @@ import {
buildContactSearchIndex,
filterContactSearchIndex
} from '../../../../shared/contact-search'
import { loadExportPreferences, saveExportPreferences } from './exportPreferences'
const ALL_CONTACT_TYPES: ExportContactType[] = ['group', 'user']
const contactTypeKey = (types: ExportContactType[] | undefined): string =>
@@ -44,6 +45,8 @@ export function ExportWorkspace({
onCancelExport
}: ExportWorkspaceProps): React.ReactElement {
const initialSelection = initialContact || contacts[0] || null
const defaultNameMode = initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
const [savedPreferences] = useState(() => loadExportPreferences(defaultNameMode))
const runningAllTask = exportTasks.find(
(task) => task.scope === 'all' && task.status === 'running'
)
@@ -64,24 +67,30 @@ export function ExportWorkspace({
const contactSearchIndex = useMemo(() => buildContactSearchIndex(contacts), [contacts])
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
const [range, setRange] = useState<ExportRange>(() => (runningAllTask ? 'all' : 'today'))
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
const [nameMode, setNameMode] = useState<ExportNameMode>(
initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
const [range, setRange] = useState<ExportRange>(() => savedPreferences.range)
const [startDate, setStartDate] = useState(() => savedPreferences.startDate)
const [endDate, setEndDate] = useState(() => savedPreferences.endDate)
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(
() => new Set(savedPreferences.selectedKinds)
)
const [nameMode, setNameMode] = useState<ExportNameMode>(() => savedPreferences.nameMode)
const [includeMedia, setIncludeMedia] = useState(() => savedPreferences.includeMedia)
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(
() => savedPreferences.includeVoiceTranscripts
)
const [includeMedia, setIncludeMedia] = useState(true)
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
const [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(true)
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv')
const [zip, setZip] = useState(() => runningAllTask?.zip === true)
const [fileName, setFileName] = useState('')
const [outputDirectory, setOutputDirectory] = useState('')
const [includeAvatars, setIncludeAvatars] = useState(() => savedPreferences.includeAvatars)
const [preferOriginal, setPreferOriginal] = useState(() => savedPreferences.preferOriginal)
const [fallbackThumbnail, setFallbackThumbnail] = useState(
() => savedPreferences.fallbackThumbnail
)
const [keepMissing, setKeepMissing] = useState(() => savedPreferences.keepMissing)
const [format, setFormat] = useState<ExportFormat>(
() => runningAllTask?.format || savedPreferences.format
)
const [zip, setZip] = useState(() => runningAllTask?.zip ?? savedPreferences.zip)
const [fileName, setFileName] = useState(() => savedPreferences.fileName)
const [outputDirectory, setOutputDirectory] = useState(() => savedPreferences.outputDirectory)
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
@@ -92,6 +101,42 @@ export function ExportWorkspace({
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
const selectionLimit = 5
React.useEffect(() => {
saveExportPreferences({
format,
range,
startDate,
endDate,
selectedKinds: Array.from(selectedKinds) as ExportRequest['kinds'],
nameMode,
includeMedia,
includeVoiceTranscripts,
includeAvatars,
preferOriginal,
fallbackThumbnail,
keepMissing,
zip,
fileName,
outputDirectory
})
}, [
endDate,
fallbackThumbnail,
fileName,
format,
includeAvatars,
includeMedia,
includeVoiceTranscripts,
keepMissing,
nameMode,
outputDirectory,
preferOriginal,
range,
selectedKinds,
startDate,
zip
])
React.useEffect(() => {
if (selectedContacts.length > 0) return
const candidate = initialContact || contacts[0]
@@ -206,7 +251,6 @@ export function ExportWorkspace({
const handleSelectContact = (contact: Contact): void => {
if (exportAll) {
setExportAll(false)
setRange('today')
}
if (!selectionMode) {
setSelectedContacts([contact])
@@ -236,7 +280,6 @@ export function ExportWorkspace({
setExportAll(true)
setAllContactTypes([...ALL_CONTACT_TYPES])
setSelectionMode(false)
setRange('all')
setStatus('idle')
}
@@ -417,6 +460,7 @@ export function ExportWorkspace({
setSelectedKinds(new Set(['text']))
setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark')
setIncludeMedia(true)
setIncludeVoiceTranscripts(true)
setIncludeAvatars(true)
setPreferOriginal(true)
setFallbackThumbnail(true)
@@ -424,6 +468,7 @@ export function ExportWorkspace({
setFormat('csv')
setZip(false)
setFileName('')
setOutputDirectory('')
setStatus('idle')
setJobId('')
setProgress(null)
@@ -492,7 +537,7 @@ export function ExportWorkspace({
selectionMode={selectionMode}
exportContactCount={exportContacts.length}
format={format}
range={range}
range={exportAll ? 'all' : range}
startDate={startDate}
endDate={endDate}
selectedKinds={selectedKinds}
@@ -0,0 +1,115 @@
import type { ExportFormat, ExportMessageKind, ExportNameMode } from '../../../../shared/export'
import type { ExportRange } from './exportTypes'
export interface ExportPreferences {
format: ExportFormat
range: ExportRange
startDate: string
endDate: string
selectedKinds: ExportMessageKind[]
nameMode: ExportNameMode
includeMedia: boolean
includeVoiceTranscripts: boolean
includeAvatars: boolean
preferOriginal: boolean
fallbackThumbnail: boolean
keepMissing: boolean
zip: boolean
fileName: string
outputDirectory: string
}
export const EXPORT_PREFERENCES_STORAGE_KEY = 'tracememo_export_preferences'
const formats: ExportFormat[] = ['html', 'csv', 'json', 'markdown']
const ranges: ExportRange[] = ['all', 'today', 'threeDays', 'sevenDays', 'custom']
const messageKinds: ExportMessageKind[] = [
'text',
'image',
'video',
'voice',
'sticker',
'file',
'share',
'location',
'system'
]
const nameModes: ExportNameMode[] = ['groupNickname', 'remark', 'wechatNickname']
export function loadExportPreferences(defaultNameMode: ExportNameMode): ExportPreferences {
const defaults: ExportPreferences = {
format: 'csv',
range: 'today',
startDate: '',
endDate: '',
selectedKinds: ['text'],
nameMode: defaultNameMode,
includeMedia: true,
includeVoiceTranscripts: true,
includeAvatars: true,
preferOriginal: true,
fallbackThumbnail: true,
keepMissing: true,
zip: false,
fileName: '',
outputDirectory: ''
}
try {
const stored = window.localStorage.getItem(EXPORT_PREFERENCES_STORAGE_KEY)
if (!stored) return defaults
const value = JSON.parse(stored) as Partial<ExportPreferences>
return {
format: formats.includes(value.format as ExportFormat)
? (value.format as ExportFormat)
: defaults.format,
range: ranges.includes(value.range as ExportRange)
? (value.range as ExportRange)
: defaults.range,
startDate: typeof value.startDate === 'string' ? value.startDate : defaults.startDate,
endDate: typeof value.endDate === 'string' ? value.endDate : defaults.endDate,
selectedKinds: Array.isArray(value.selectedKinds)
? [
...new Set(
value.selectedKinds.filter((kind): kind is ExportMessageKind =>
messageKinds.includes(kind as ExportMessageKind)
)
)
]
: defaults.selectedKinds,
nameMode: nameModes.includes(value.nameMode as ExportNameMode)
? (value.nameMode as ExportNameMode)
: defaults.nameMode,
includeMedia:
typeof value.includeMedia === 'boolean' ? value.includeMedia : defaults.includeMedia,
includeVoiceTranscripts:
typeof value.includeVoiceTranscripts === 'boolean'
? value.includeVoiceTranscripts
: defaults.includeVoiceTranscripts,
includeAvatars:
typeof value.includeAvatars === 'boolean' ? value.includeAvatars : defaults.includeAvatars,
preferOriginal:
typeof value.preferOriginal === 'boolean' ? value.preferOriginal : defaults.preferOriginal,
fallbackThumbnail:
typeof value.fallbackThumbnail === 'boolean'
? value.fallbackThumbnail
: defaults.fallbackThumbnail,
keepMissing:
typeof value.keepMissing === 'boolean' ? value.keepMissing : defaults.keepMissing,
zip: typeof value.zip === 'boolean' ? value.zip : defaults.zip,
fileName: typeof value.fileName === 'string' ? value.fileName : defaults.fileName,
outputDirectory:
typeof value.outputDirectory === 'string' ? value.outputDirectory : defaults.outputDirectory
}
} catch {
return defaults
}
}
export function saveExportPreferences(preferences: ExportPreferences): void {
try {
window.localStorage.setItem(EXPORT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences))
} catch {
// Export remains usable when browser storage is unavailable.
}
}
+91 -3
View File
@@ -25,6 +25,7 @@ const previewMessage = (contact: Contact): Message => ({
describe('ExportWorkspace multi-chat selection', () => {
beforeEach(() => {
localStorage.clear()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
@@ -41,9 +42,9 @@ describe('ExportWorkspace multi-chat selection', () => {
const renderWorkspace = (
onStartExport = vi.fn(async () => ({ success: false })),
exportTasks: ExportTaskRecord[] = []
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => {
): { loadPreviewMessages: ReturnType<typeof vi.fn>; unmount: () => void } => {
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
render(
const workspace = render(
<ExportWorkspace
contacts={contacts}
initialContact={contacts[0]}
@@ -56,7 +57,7 @@ describe('ExportWorkspace multi-chat selection', () => {
onCancelExport={vi.fn(async () => undefined)}
/>
)
return { loadPreviewMessages }
return { loadPreviewMessages, unmount: workspace.unmount }
}
it('defaults to one chat, forces HTML after adding another, merges the preview, and resets locally', async () => {
@@ -221,6 +222,93 @@ describe('ExportWorkspace multi-chat selection', () => {
endTime: Math.floor(new Date('2026-08-02T18:45').getTime() / 1000)
})
})
it('restores export preferences after leaving and reopening the workspace', async () => {
const { unmount } = renderWorkspace()
await userEvent.click(
within(screen.getByRole('radiogroup', { name: '导出格式' })).getByRole('radio', {
name: /HTML/
})
)
await userEvent.click(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '自定义时间'
})
)
await userEvent.type(screen.getByLabelText('开始时间'), '2026-08-01T09:30')
await userEvent.type(screen.getByLabelText('结束时间'), '2026-08-02T18:45')
await userEvent.click(screen.getByRole('checkbox', { name: '文字' }))
await userEvent.click(screen.getByRole('checkbox', { name: '语音' }))
await userEvent.click(
within(screen.getByRole('radiogroup', { name: '消息显示名称' })).getByRole('radio', {
name: '微信名'
})
)
await userEvent.click(
within(screen.getByRole('radiogroup', { name: 'HTML 打包方式' })).getByRole('radio', {
name: 'HTML 资源包并压缩为 ZIP'
})
)
await userEvent.click(screen.getByRole('checkbox', { name: '优先导出原图' }))
await userEvent.type(screen.getByLabelText('文件名称'), '持续更新的聊天档案')
await userEvent.click(screen.getByRole('button', { name: '选择位置' }))
expect(await screen.findByText('/fixture/export/持续更新的聊天档案.zip')).toBeVisible()
unmount()
renderWorkspace()
expect(
within(screen.getByRole('radiogroup', { name: '导出格式' })).getByRole('radio', {
name: /HTML/
})
).toBeChecked()
expect(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '自定义时间'
})
).toBeChecked()
expect(screen.getByLabelText('开始时间')).toHaveValue('2026-08-01T09:30')
expect(screen.getByLabelText('结束时间')).toHaveValue('2026-08-02T18:45')
expect(screen.getByRole('checkbox', { name: '文字' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: '语音' })).toBeChecked()
expect(
within(screen.getByRole('radiogroup', { name: '消息显示名称' })).getByRole('radio', {
name: '微信名'
})
).toBeChecked()
expect(
within(screen.getByRole('radiogroup', { name: 'HTML 打包方式' })).getByRole('radio', {
name: 'HTML 资源包并压缩为 ZIP'
})
).toBeChecked()
expect(screen.getByRole('checkbox', { name: '优先导出原图' })).not.toBeChecked()
expect(screen.getByLabelText('文件名称')).toHaveValue('持续更新的聊天档案')
expect(screen.getByText('/fixture/export/持续更新的聊天档案.zip')).toBeVisible()
})
it('preserves the selected range while switching to and from all export', async () => {
renderWorkspace()
await userEvent.click(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '最近 7 天'
})
)
await userEvent.click(screen.getByRole('button', { name: /全部导出/ }))
expect(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '全部时间'
})
).toBeChecked()
await userEvent.click(screen.getByRole('button', { name: /聊天 A/ }))
expect(
within(screen.getByRole('radiogroup', { name: '时间范围' })).getByRole('radio', {
name: '最近 7 天'
})
).toBeChecked()
})
})
describe('ExportTaskCenter details', () => {