mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 19:43:43 +08:00
feat: 持久化聊天记录导出设置
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user