From e83e85fe64cbfdff547ebc1a7eb373617a85fccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <84499436+Wxw-Gu@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:37:09 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=81=E4=B9=85=E5=8C=96=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E8=AE=B0=E5=BD=95=E5=AF=BC=E5=87=BA=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/export/ExportWorkspace.tsx | 83 ++++++++++--- .../components/export/exportPreferences.ts | 115 ++++++++++++++++++ tests/component/export-workspace.test.tsx | 94 +++++++++++++- 3 files changed, 270 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/export/exportPreferences.ts diff --git a/src/renderer/src/components/export/ExportWorkspace.tsx b/src/renderer/src/components/export/ExportWorkspace.tsx index cb03865..0faf994 100644 --- a/src/renderer/src/components/export/ExportWorkspace.tsx +++ b/src/renderer/src/components/export/ExportWorkspace.tsx @@ -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>({}) - const [range, setRange] = useState(() => (runningAllTask ? 'all' : 'today')) - const [startDate, setStartDate] = useState('') - const [endDate, setEndDate] = useState('') - const [selectedKinds, setSelectedKinds] = useState>(() => new Set(['text'])) - const [nameMode, setNameMode] = useState( - initialSelection?.type === 'group' ? 'groupNickname' : 'remark' + const [range, setRange] = useState(() => savedPreferences.range) + const [startDate, setStartDate] = useState(() => savedPreferences.startDate) + const [endDate, setEndDate] = useState(() => savedPreferences.endDate) + const [selectedKinds, setSelectedKinds] = useState>( + () => new Set(savedPreferences.selectedKinds) + ) + const [nameMode, setNameMode] = useState(() => 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(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(() => 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( + () => 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('idle') const [jobId, setJobId] = useState('') const [progress, setProgress] = useState(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} diff --git a/src/renderer/src/components/export/exportPreferences.ts b/src/renderer/src/components/export/exportPreferences.ts new file mode 100644 index 0000000..a58d6d6 --- /dev/null +++ b/src/renderer/src/components/export/exportPreferences.ts @@ -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 + 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. + } +} diff --git a/tests/component/export-workspace.test.tsx b/tests/component/export-workspace.test.tsx index 79348df..b8d9d5d 100644 --- a/tests/component/export-workspace.test.tsx +++ b/tests/component/export-workspace.test.tsx @@ -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 } => { + ): { loadPreviewMessages: ReturnType; unmount: () => void } => { const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)]) - render( + const workspace = render( { 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', () => {