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