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, buildContactSearchIndex,
filterContactSearchIndex filterContactSearchIndex
} from '../../../../shared/contact-search' } from '../../../../shared/contact-search'
import { loadExportPreferences, saveExportPreferences } from './exportPreferences'
const ALL_CONTACT_TYPES: ExportContactType[] = ['group', 'user'] const ALL_CONTACT_TYPES: ExportContactType[] = ['group', 'user']
const contactTypeKey = (types: ExportContactType[] | undefined): string => const contactTypeKey = (types: ExportContactType[] | undefined): string =>
@@ -44,6 +45,8 @@ export function ExportWorkspace({
onCancelExport onCancelExport
}: ExportWorkspaceProps): React.ReactElement { }: ExportWorkspaceProps): React.ReactElement {
const initialSelection = initialContact || contacts[0] || null const initialSelection = initialContact || contacts[0] || null
const defaultNameMode = initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
const [savedPreferences] = useState(() => loadExportPreferences(defaultNameMode))
const runningAllTask = exportTasks.find( const runningAllTask = exportTasks.find(
(task) => task.scope === 'all' && task.status === 'running' (task) => task.scope === 'all' && task.status === 'running'
) )
@@ -64,24 +67,30 @@ export function ExportWorkspace({
const contactSearchIndex = useMemo(() => buildContactSearchIndex(contacts), [contacts]) const contactSearchIndex = useMemo(() => buildContactSearchIndex(contacts), [contacts])
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '') const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({}) const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
const [range, setRange] = useState<ExportRange>(() => (runningAllTask ? 'all' : 'today')) const [range, setRange] = useState<ExportRange>(() => savedPreferences.range)
const [startDate, setStartDate] = useState('') const [startDate, setStartDate] = useState(() => savedPreferences.startDate)
const [endDate, setEndDate] = useState('') const [endDate, setEndDate] = useState(() => savedPreferences.endDate)
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text'])) const [selectedKinds, setSelectedKinds] = useState<Set<string>>(
const [nameMode, setNameMode] = useState<ExportNameMode>( () => new Set(savedPreferences.selectedKinds)
initialSelection?.type === 'group' ? 'groupNickname' : 'remark' )
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 [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
const [includeAvatars, setIncludeAvatars] = useState(true) const [includeAvatars, setIncludeAvatars] = useState(() => savedPreferences.includeAvatars)
const [preferOriginal, setPreferOriginal] = useState(true) const [preferOriginal, setPreferOriginal] = useState(() => savedPreferences.preferOriginal)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true) const [fallbackThumbnail, setFallbackThumbnail] = useState(
const [keepMissing, setKeepMissing] = useState(true) () => savedPreferences.fallbackThumbnail
const [format, setFormat] = useState<ExportFormat>(() => runningAllTask?.format || 'csv') )
const [zip, setZip] = useState(() => runningAllTask?.zip === true) const [keepMissing, setKeepMissing] = useState(() => savedPreferences.keepMissing)
const [fileName, setFileName] = useState('') const [format, setFormat] = useState<ExportFormat>(
const [outputDirectory, setOutputDirectory] = useState('') () => 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 [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('') const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null) const [progress, setProgress] = useState<ExportJobProgress | null>(null)
@@ -92,6 +101,42 @@ export function ExportWorkspace({
const [taskCenterOpen, setTaskCenterOpen] = useState(false) const [taskCenterOpen, setTaskCenterOpen] = useState(false)
const selectionLimit = 5 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(() => { React.useEffect(() => {
if (selectedContacts.length > 0) return if (selectedContacts.length > 0) return
const candidate = initialContact || contacts[0] const candidate = initialContact || contacts[0]
@@ -206,7 +251,6 @@ export function ExportWorkspace({
const handleSelectContact = (contact: Contact): void => { const handleSelectContact = (contact: Contact): void => {
if (exportAll) { if (exportAll) {
setExportAll(false) setExportAll(false)
setRange('today')
} }
if (!selectionMode) { if (!selectionMode) {
setSelectedContacts([contact]) setSelectedContacts([contact])
@@ -236,7 +280,6 @@ export function ExportWorkspace({
setExportAll(true) setExportAll(true)
setAllContactTypes([...ALL_CONTACT_TYPES]) setAllContactTypes([...ALL_CONTACT_TYPES])
setSelectionMode(false) setSelectionMode(false)
setRange('all')
setStatus('idle') setStatus('idle')
} }
@@ -417,6 +460,7 @@ export function ExportWorkspace({
setSelectedKinds(new Set(['text'])) setSelectedKinds(new Set(['text']))
setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark') setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark')
setIncludeMedia(true) setIncludeMedia(true)
setIncludeVoiceTranscripts(true)
setIncludeAvatars(true) setIncludeAvatars(true)
setPreferOriginal(true) setPreferOriginal(true)
setFallbackThumbnail(true) setFallbackThumbnail(true)
@@ -424,6 +468,7 @@ export function ExportWorkspace({
setFormat('csv') setFormat('csv')
setZip(false) setZip(false)
setFileName('') setFileName('')
setOutputDirectory('')
setStatus('idle') setStatus('idle')
setJobId('') setJobId('')
setProgress(null) setProgress(null)
@@ -492,7 +537,7 @@ export function ExportWorkspace({
selectionMode={selectionMode} selectionMode={selectionMode}
exportContactCount={exportContacts.length} exportContactCount={exportContacts.length}
format={format} format={format}
range={range} range={exportAll ? 'all' : range}
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
selectedKinds={selectedKinds} 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', () => { describe('ExportWorkspace multi-chat selection', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear()
Object.defineProperty(window, 'api', { Object.defineProperty(window, 'api', {
configurable: true, configurable: true,
value: { value: {
@@ -41,9 +42,9 @@ describe('ExportWorkspace multi-chat selection', () => {
const renderWorkspace = ( const renderWorkspace = (
onStartExport = vi.fn(async () => ({ success: false })), onStartExport = vi.fn(async () => ({ success: false })),
exportTasks: ExportTaskRecord[] = [] exportTasks: ExportTaskRecord[] = []
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => { ): { loadPreviewMessages: ReturnType<typeof vi.fn>; unmount: () => void } => {
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)]) const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
render( const workspace = render(
<ExportWorkspace <ExportWorkspace
contacts={contacts} contacts={contacts}
initialContact={contacts[0]} initialContact={contacts[0]}
@@ -56,7 +57,7 @@ describe('ExportWorkspace multi-chat selection', () => {
onCancelExport={vi.fn(async () => undefined)} 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 () => { 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) 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', () => { describe('ExportTaskCenter details', () => {