diff --git a/src/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss new file mode 100644 index 0000000..24f4621 --- /dev/null +++ b/src/components/Export/ExportDefaultsSettingsForm.scss @@ -0,0 +1,230 @@ +.export-defaults-settings-form { + .form-group { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + label { + display: block; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + } + + .form-hint { + display: block; + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 8px; + } + + .select-field { + position: relative; + margin-bottom: 10px; + } + + .select-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--text-tertiary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .select-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .select-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: var(--shadow-md); + z-index: 120; + max-height: 320px; + overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + + .select-option { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-size: 14px; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } + } + + .option-label { + font-weight: 500; + } + + .option-desc { + font-size: 12px; + color: var(--text-tertiary); + } + + .select-option.active .option-desc { + color: var(--primary); + } + + .settings-time-range-field { + margin-bottom: 10px; + } + + .settings-time-range-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: rgba(var(--primary-rgb), 0.45); + color: var(--primary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .settings-time-range-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .settings-time-range-arrow { + color: var(--text-tertiary); + font-weight: 700; + line-height: 1; + } + + .log-toggle-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: 14px; + background: var(--bg-primary); + } + + .log-status { + font-size: 13px; + color: var(--text-secondary); + } + + .switch { + position: relative; + display: inline-flex; + width: 48px; + height: 28px; + cursor: pointer; + flex-shrink: 0; + } + + .switch-input { + opacity: 0; + width: 0; + height: 0; + position: absolute; + + &:checked + .switch-slider { + background: var(--primary); + } + + &:checked + .switch-slider::before { + transform: translateX(20px); + } + + &:focus + .switch-slider { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); + } + } + + .switch-slider { + position: absolute; + inset: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 999px; + transition: all 0.2s ease; + + &::before { + content: ''; + position: absolute; + width: 20px; + height: 20px; + left: 3px; + top: 3px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + transition: transform 0.2s ease; + } + } +} diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx new file mode 100644 index 0000000..b164ad5 --- /dev/null +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -0,0 +1,332 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import * as configService from '../../services/config' +import { ExportDateRangeDialog } from './ExportDateRangeDialog' +import { + createDefaultExportDateRangeSelection, + getExportDateRangeLabel, + resolveExportDateRangeConfig, + serializeExportDateRangeConfig, + type ExportDateRangeSelection +} from '../../utils/exportDateRange' +import './ExportDefaultsSettingsForm.scss' + +export interface ExportDefaultsSettingsPatch { + format?: string + dateRange?: ExportDateRangeSelection + media?: boolean + voiceAsText?: boolean + excelCompactColumns?: boolean + concurrency?: number +} + +interface ExportDefaultsSettingsFormProps { + onNotify?: (text: string, success: boolean) => void + onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void +} + +const exportFormatOptions = [ + { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, + { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' }, + { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, + { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, + { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } +] as const + +const exportExcelColumnOptions = [ + { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, + { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } +] as const + +const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const + +const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => { + return options.find((option) => option.value === value)?.label ?? value +} + +export function ExportDefaultsSettingsForm({ + onNotify, + onDefaultsChanged +}: ExportDefaultsSettingsFormProps) { + const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) + const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) + const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false) + const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) + const exportFormatDropdownRef = useRef(null) + const exportExcelColumnsDropdownRef = useRef(null) + const exportConcurrencyDropdownRef = useRef(null) + + const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') + const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultMedia, setExportDefaultMedia] = useState(false) + const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) + const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) + + useEffect(() => { + let cancelled = false + void (async () => { + const [savedFormat, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ + configService.getExportDefaultFormat(), + configService.getExportDefaultDateRange(), + configService.getExportDefaultMedia(), + configService.getExportDefaultVoiceAsText(), + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultConcurrency() + ]) + + if (cancelled) return + + setExportDefaultFormat(savedFormat || 'excel') + setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange)) + setExportDefaultMedia(savedMedia ?? false) + setExportDefaultVoiceAsText(savedVoiceAsText ?? false) + setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) + setExportDefaultConcurrency(savedConcurrency ?? 2) + })() + + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node + if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { + setShowExportFormatSelect(false) + } + if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { + setShowExportExcelColumnsSelect(false) + } + if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) { + setShowExportConcurrencySelect(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showExportFormatSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) + + const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' + const exportFormatLabel = useMemo(() => getOptionLabel(exportFormatOptions, exportDefaultFormat), [exportDefaultFormat]) + const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) + const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) + const exportConcurrencyLabel = String(exportDefaultConcurrency) + + const notify = (text: string, success = true) => { + onNotify?.(text, success) + } + + return ( +
+
+ + 导出页面默认选中的格式 +
+ + {showExportFormatSelect && ( +
+ {exportFormatOptions.map((option) => ( + + ))} +
+ )} +
+
+ +
+ + 控制导出页面的默认时间选择 +
+ +
+
+ + setIsExportDateRangeDialogOpen(false)} + onConfirm={async (nextSelection) => { + setExportDefaultDateRange(nextSelection) + await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection)) + onDefaultsChanged?.({ dateRange: nextSelection }) + notify('已更新默认导出时间范围', true) + setIsExportDateRangeDialogOpen(false) + }} + /> + +
+ + 控制图片/语音/表情的默认导出开关 +
+ {exportDefaultMedia ? '已开启' : '已关闭'} + +
+
+ +
+ + 导出时默认将语音转写为文字 +
+ {exportDefaultVoiceAsText ? '已开启' : '已关闭'} + +
+
+ +
+ + 控制 Excel 导出的列字段 +
+ + {showExportExcelColumnsSelect && ( +
+ {exportExcelColumnOptions.map((option) => ( + + ))} +
+ )} +
+
+ +
+ + 导出多个会话时的最大并发(1~6) +
+ + {showExportConcurrencySelect && ( +
+ {exportConcurrencyOptions.map((option) => ( + + ))} +
+ )} +
+
+
+ ) +} diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 4f4234d..81fd177 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -261,14 +261,14 @@ } .global-export-controls { - flex: 0 1 820px; - width: min(820px, 100%); + flex: 0 1 980px; + width: min(980px, 100%); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; padding: 12px; display: grid; - grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr); + grid-template-columns: minmax(0, 1.55fr) minmax(240px, 1fr) auto; gap: 10px; align-items: stretch; @@ -359,6 +359,32 @@ z-index: 40; } + .more-export-settings-control { + display: flex; + align-items: center; + justify-content: flex-end; + } + + .more-export-settings-btn { + min-height: 38px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0 14px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease; + + &:hover { + border-color: var(--primary); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-secondary)); + } + } + .layout-trigger { width: 100%; padding: 8px 10px; @@ -551,6 +577,62 @@ } } +.export-defaults-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + z-index: 2300; + padding: 20px; +} + +.export-defaults-modal { + width: min(720px, 100%); + max-height: min(80vh, 860px); + overflow: hidden; + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; +} + +.export-defaults-modal-header { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + h3 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + } +} + +.export-defaults-modal-body { + padding: 16px; + overflow: auto; +} + +.export-defaults-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 0 16px 16px; +} + .task-center-card-label { line-height: 1; white-space: nowrap; @@ -3285,9 +3367,9 @@ .global-export-controls { padding: 10px; gap: 8px; - flex-basis: 760px; - width: min(760px, 100%); - grid-template-columns: minmax(0, 1.5fr) minmax(240px, 1fr); + flex-basis: 920px; + width: min(920px, 100%); + grid-template-columns: minmax(0, 1.35fr) minmax(220px, 1fr) auto; } .format-grid { @@ -3333,6 +3415,10 @@ width: 100%; justify-content: space-between; } + + .export-defaults-modal { + width: min(92vw, 720px); + } } @media (max-width: 720px) { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 8c659b5..65c03df 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -41,6 +41,7 @@ import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' +import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm' import type { SnsPost } from '../types/sns' import { cloneExportDateRange, @@ -1282,8 +1283,14 @@ function ExportPage() { const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false) const [snsExportVideos, setSnsExportVideos] = useState(false) const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false) + const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false) const [timeRangeSelection, setTimeRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultMedia, setExportDefaultMedia] = useState(false) + const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) + const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) const [options, setOptions] = useState({ format: 'json', @@ -1785,8 +1792,9 @@ function ExportPage() { setIsBaseConfigLoading(true) let isReady = true try { - const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ configService.getExportPath(), + configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), configService.getExportDefaultExcelCompactColumns(), @@ -1817,10 +1825,14 @@ function ExportPage() { setLastExportByContent(savedContentMap) setExportRecordsBySession(savedSessionRecordMap) setLastSnsExportPostCount(savedSnsPostCount) + setExportDefaultFormat((savedFormat as TextExportFormat) || 'excel') + setExportDefaultMedia(savedMedia ?? false) + setExportDefaultVoiceAsText(savedVoiceAsText ?? false) + setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) + setExportDefaultConcurrency(savedConcurrency ?? 2) const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange) - await configService.setExportDefaultFormat('json') if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { setSnsStats({ @@ -1835,7 +1847,7 @@ function ExportPage() { const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions(prev => ({ ...prev, - format: 'json', + format: ((savedFormat as TextExportFormat) || 'excel'), exportMedia: savedMedia ?? prev.exportMedia, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, @@ -3192,8 +3204,13 @@ function ExportPage() { const next: ExportOptions = { ...prev, + format: exportDefaultFormat, useAllTime: exportDefaultDateRangeSelection.useAllTime, - dateRange: nextDateRange + dateRange: nextDateRange, + exportMedia: exportDefaultMedia, + exportVoiceAsText: exportDefaultVoiceAsText, + excelCompactColumns: exportDefaultExcelCompactColumns, + exportConcurrency: exportDefaultConcurrency } if (payload.scope === 'sns') { @@ -3220,7 +3237,14 @@ function ExportPage() { return next }) - }, [exportDefaultDateRangeSelection]) + }, [ + exportDefaultDateRangeSelection, + exportDefaultExcelCompactColumns, + exportDefaultFormat, + exportDefaultMedia, + exportDefaultVoiceAsText, + exportDefaultConcurrency + ]) const closeExportDialog = useCallback(() => { setExportDialog(prev => ({ ...prev, open: false })) @@ -5147,6 +5171,27 @@ function ExportPage() { } }, []) + const handleExportDefaultsChanged = useCallback((patch: ExportDefaultsSettingsPatch) => { + if (patch.format) { + setExportDefaultFormat(patch.format as TextExportFormat) + } + if (patch.dateRange) { + setExportDefaultDateRangeSelection(patch.dateRange) + } + if (typeof patch.media === 'boolean') { + setExportDefaultMedia(patch.media) + } + if (typeof patch.voiceAsText === 'boolean') { + setExportDefaultVoiceAsText(patch.voiceAsText) + } + if (typeof patch.excelCompactColumns === 'boolean') { + setExportDefaultExcelCompactColumns(patch.excelCompactColumns) + } + if (typeof patch.concurrency === 'number') { + setExportDefaultConcurrency(patch.concurrency) + } + }, []) + return (
@@ -5186,6 +5231,16 @@ function ExportPage() { await configService.setExportSessionNamePrefixEnabled(enabled) }} /> + +
+ +
+
+
+ +
+
+ +
+ + + )} +

按类型批量导出

{ - const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' - const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat) - const exportDateRangeLabel = getExportDateRangeLabel(exportDefaultDateRange) - const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) - const exportConcurrencyLabel = String(exportDefaultConcurrency) - return (
-
- - 导出页面默认选中的格式 -
- - {showExportFormatSelect && ( -
- {exportFormatOptions.map((option) => ( - - ))} -
- )} -
-
- -
- - 控制导出页面的默认时间选择 -
- -
-
- - setIsExportDateRangeDialogOpen(false)} - onConfirm={async (nextSelection) => { - setExportDefaultDateRange(nextSelection) - await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection)) - showMessage('已更新默认导出时间范围', true) - setIsExportDateRangeDialogOpen(false) - }} - /> - -
- - 控制图片/语音/表情的默认导出开关 -
- {exportDefaultMedia ? '已开启' : '已关闭'} - -
-
- -
- - 导出时默认将语音转写为文字 -
- {exportDefaultVoiceAsText ? '已开启' : '已关闭'} - -
-
- -
- - 控制 Excel 导出的列字段 -
- - {showExportExcelColumnsSelect && ( -
- {exportExcelColumnOptions.map((option) => ( - - ))} -
- )} -
-
- -
- - 导出多个会话时的最大并发(1~6) -
- - {showExportConcurrencySelect && ( -
- {exportConcurrencyOptions.map((option) => ( - - ))} -
- )} -
-
- +
) }