From ca1a386146533553847487b888c0fc0d31237fc4 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 6 Feb 2026 23:01:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96html=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportHtml.css | 156 ++++++++++---------------- electron/services/exportHtmlStyles.ts | 149 ++++++++++++------------ src/pages/ExportPage.tsx | 153 +++++++++++++++++++++++-- 3 files changed, 276 insertions(+), 182 deletions(-) diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css index c7898d2..c03d459 100644 --- a/electron/services/exportHtml.css +++ b/electron/services/exportHtml.css @@ -25,83 +25,87 @@ body { .page { max-width: 1080px; - margin: 32px auto 60px; - padding: 0 20px; + margin: 0 auto; + padding: 8px 20px; + height: 100vh; + display: flex; + flex-direction: column; } .header { background: var(--card); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 24px; - margin-bottom: 24px; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); + padding: 12px 20px; + flex-shrink: 0; } .title { - font-size: 24px; + font-size: 16px; font-weight: 600; - margin: 0 0 8px; + margin: 0; + display: inline; } .meta { color: var(--muted); - font-size: 14px; - display: flex; - flex-wrap: wrap; - gap: 12px; + font-size: 13px; + display: inline; + margin-left: 12px; +} + +.meta span { + margin-right: 10px; } .controls { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 20px; -} - -.control { display: flex; - flex-direction: column; - gap: 6px; + align-items: center; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; } -.control label { - font-size: 13px; - color: var(--muted); -} - -.control input, -.control select, -.control button { - border-radius: 12px; +.controls input, +.controls button { + border-radius: 8px; border: 1px solid var(--border); - padding: 10px 12px; - font-size: 14px; + padding: 6px 10px; + font-size: 13px; font-family: inherit; } -.control button { +.controls input[type="search"] { + width: 200px; +} + +.controls input[type="datetime-local"] { + width: 200px; +} + +.controls button { background: var(--accent); color: #fff; border: none; cursor: pointer; - transition: transform 0.1s ease; + padding: 6px 14px; } -.control button:active { +.controls button:active { transform: scale(0.98); } .stats { font-size: 13px; color: var(--muted); - display: flex; - align-items: flex-end; + margin-left: auto; } .message-list { display: flex; flex-direction: column; - gap: 18px; + gap: 12px; + padding: 4px 0; } .message { @@ -248,50 +252,11 @@ body { cursor: zoom-out; } -body[data-theme="cloud-dancer"] { - --accent: #6b8cff; - --sent: #e0e7ff; - --received: #ffffff; - --border: #d8e0f7; - --bg: #f6f7fb; -} - -body[data-theme="corundum-blue"] { - --accent: #2563eb; - --sent: #dbeafe; - --received: #ffffff; - --border: #c7d2fe; - --bg: #eef2ff; -} - -body[data-theme="kiwi-green"] { - --accent: #16a34a; - --sent: #dcfce7; - --received: #ffffff; - --border: #bbf7d0; - --bg: #f0fdf4; -} - -body[data-theme="spicy-red"] { - --accent: #e11d48; - --sent: #ffe4e6; - --received: #ffffff; - --border: #fecdd3; - --bg: #fff1f2; -} - -body[data-theme="teal-water"] { - --accent: #0f766e; - --sent: #ccfbf1; - --received: #ffffff; - --border: #99f6e4; - --bg: #f0fdfa; -} - .highlight { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 18px; + transition: outline-color 0.3s; } .empty { @@ -300,32 +265,29 @@ body[data-theme="teal-water"] { padding: 40px; } -/* Virtual Scroll */ -.virtual-scroll-container { - height: calc(100vh - 180px); - /* Adjust based on header height */ +/* Scroll Container */ +.scroll-container { + flex: 1; + min-height: 0; overflow-y: auto; - position: relative; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); - margin-top: 20px; + margin-top: 8px; + margin-bottom: 8px; + padding: 12px; + -webkit-overflow-scrolling: touch; } -.virtual-scroll-spacer { - opacity: 0; - pointer-events: none; - width: 1px; +.scroll-container::-webkit-scrollbar { + width: 6px; } -.virtual-scroll-content { - position: absolute; - top: 0; - left: 0; - width: 100%; +.scroll-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; } -.message-list { - /* Override message-list to be inside virtual scroll */ - display: block; -} \ No newline at end of file +.load-sentinel { + height: 1px; +} diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts index adb3e61..42d4e07 100644 --- a/electron/services/exportHtmlStyles.ts +++ b/electron/services/exportHtmlStyles.ts @@ -25,83 +25,87 @@ body { .page { max-width: 1080px; - margin: 32px auto 60px; - padding: 0 20px; + margin: 0 auto; + padding: 8px 20px; + height: 100vh; + display: flex; + flex-direction: column; } .header { background: var(--card); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 24px; - margin-bottom: 24px; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); + padding: 12px 20px; + flex-shrink: 0; } .title { - font-size: 24px; + font-size: 16px; font-weight: 600; - margin: 0 0 8px; + margin: 0; + display: inline; } .meta { color: var(--muted); - font-size: 14px; - display: flex; - flex-wrap: wrap; - gap: 12px; + font-size: 13px; + display: inline; + margin-left: 12px; +} + +.meta span { + margin-right: 10px; } .controls { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-top: 20px; -} - -.control { display: flex; - flex-direction: column; - gap: 6px; + align-items: center; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; } -.control label { - font-size: 13px; - color: var(--muted); -} - -.control input, -.control select, -.control button { - border-radius: 12px; +.controls input, +.controls button { + border-radius: 8px; border: 1px solid var(--border); - padding: 10px 12px; - font-size: 14px; + padding: 6px 10px; + font-size: 13px; font-family: inherit; } -.control button { +.controls input[type="search"] { + width: 200px; +} + +.controls input[type="datetime-local"] { + width: 200px; +} + +.controls button { background: var(--accent); color: #fff; border: none; cursor: pointer; - transition: transform 0.1s ease; + padding: 6px 14px; } -.control button:active { +.controls button:active { transform: scale(0.98); } .stats { font-size: 13px; color: var(--muted); - display: flex; - align-items: flex-end; + margin-left: auto; } .message-list { display: flex; flex-direction: column; - gap: 18px; + gap: 12px; + padding: 4px 0; } .message { @@ -248,50 +252,11 @@ body { cursor: zoom-out; } -body[data-theme="cloud-dancer"] { - --accent: #6b8cff; - --sent: #e0e7ff; - --received: #ffffff; - --border: #d8e0f7; - --bg: #f6f7fb; -} - -body[data-theme="corundum-blue"] { - --accent: #2563eb; - --sent: #dbeafe; - --received: #ffffff; - --border: #c7d2fe; - --bg: #eef2ff; -} - -body[data-theme="kiwi-green"] { - --accent: #16a34a; - --sent: #dcfce7; - --received: #ffffff; - --border: #bbf7d0; - --bg: #f0fdf4; -} - -body[data-theme="spicy-red"] { - --accent: #e11d48; - --sent: #ffe4e6; - --received: #ffffff; - --border: #fecdd3; - --bg: #fff1f2; -} - -body[data-theme="teal-water"] { - --accent: #0f766e; - --sent: #ccfbf1; - --received: #ffffff; - --border: #99f6e4; - --bg: #f0fdfa; -} - .highlight { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 18px; + transition: outline-color 0.3s; } .empty { @@ -299,4 +264,32 @@ body[data-theme="teal-water"] { color: var(--muted); padding: 40px; } + +/* Scroll Container */ +.scroll-container { + flex: 1; + min-height: 0; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + margin-top: 8px; + margin-bottom: 8px; + padding: 12px; + -webkit-overflow-scrolling: touch; +} + +.scroll-container::-webkit-scrollbar { + width: 6px; +} + +.scroll-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.load-sentinel { + height: 1px; +} `; + diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c94f12f..93ebdfc 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -46,13 +46,22 @@ function ExportPage() { const [searchKeyword, setSearchKeyword] = useState('') const [exportFolder, setExportFolder] = useState('') const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' }) + const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) const [exportResult, setExportResult] = useState(null) const [showDatePicker, setShowDatePicker] = useState(false) const [calendarDate, setCalendarDate] = useState(new Date()) const [selectingStart, setSelectingStart] = useState(true) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) + const [showPreExportDialog, setShowPreExportDialog] = useState(false) + const [preExportStats, setPreExportStats] = useState<{ + totalMessages: number; voiceMessages: number; cachedVoiceCount: number; + needTranscribeCount: number; mediaMessages: number; estimatedSeconds: number + } | null>(null) + const [isLoadingStats, setIsLoadingStats] = useState(false) + const [pendingLayout, setPendingLayout] = useState('shared') + const exportStartTime = useRef(0) + const [elapsedSeconds, setElapsedSeconds] = useState(0) const displayNameDropdownRef = useRef(null) const [options, setOptions] = useState({ @@ -189,17 +198,30 @@ function ExportPage() { }, [loadSessions]) useEffect(() => { - const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => { + const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string; phaseProgress?: number; phaseTotal?: number; phaseLabel?: string }) => { setExportProgress({ current: payload.current, total: payload.total, - currentName: payload.currentSession + currentName: payload.currentSession, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0 }) }) return () => { removeListener?.() } }, []) + + // 导出计时器 + useEffect(() => { + if (!isExporting) return + const timer = setInterval(() => { + setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000)) + }, 1000) + return () => clearInterval(timer) + }, [isExporting]) + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node @@ -278,8 +300,10 @@ function ExportPage() { if (selectedSessions.size === 0 || !exportFolder) return setIsExporting(true) - setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' }) + setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) setExportResult(null) + exportStartTime.current = Date.now() + setElapsedSeconds(0) try { const sessionList = Array.from(selectedSessions) @@ -322,9 +346,41 @@ function ExportPage() { } } - const startExport = () => { + const startExport = async () => { if (selectedSessions.size === 0 || !exportFolder) return + // 先获取预估统计 + setIsLoadingStats(true) + setShowPreExportDialog(true) + try { + const sessionList = Array.from(selectedSessions) + const exportOptions = { + format: options.format, + exportVoiceAsText: options.exportVoiceAsText, + exportMedia: options.exportMedia, + exportImages: options.exportMedia && options.exportImages, + exportVoices: options.exportMedia && options.exportVoices, + exportVideos: options.exportMedia && options.exportVideos, + exportEmojis: options.exportMedia && options.exportEmojis, + dateRange: options.useAllTime ? null : options.dateRange ? { + start: Math.floor(options.dateRange.start.getTime() / 1000), + end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) + } : null + } + const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions) + setPreExportStats(stats) + } catch (e) { + console.error('获取导出统计失败:', e) + setPreExportStats(null) + } finally { + setIsLoadingStats(false) + } + } + + const confirmExport = () => { + setShowPreExportDialog(false) + setPreExportStats(null) + if (options.exportMedia && selectedSessions.size > 1) { setShowMediaLayoutPrompt(true) return @@ -814,6 +870,71 @@ function ExportPage() { )} + {/* 导出前预估弹窗 */} + {showPreExportDialog && ( +
+
e.stopPropagation()}> +

导出预估

+ {isLoadingStats ? ( +
+ + 正在统计消息... +
+ ) : preExportStats ? ( +
+
+
+ 会话数 +
{selectedSessions.size}
+
+
+ 总消息 +
{preExportStats.totalMessages.toLocaleString()}
+
+ {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && ( + <> +
+ 语音消息 +
{preExportStats.voiceMessages}
+
+
+ 已有缓存 +
{preExportStats.cachedVoiceCount}
+
+ + )} +
+ {options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && ( +
+ + {' '}需要转写 {preExportStats.needTranscribeCount} 条语音,预计耗时约 {preExportStats.estimatedSeconds > 60 + ? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟` + : `${preExportStats.estimatedSeconds} 秒` + } +
+ )} + {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && ( +
+ + {' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写 +
+ )} +
+ ) : ( +

统计信息获取失败,仍可继续导出

+ )} +
+ + +
+
+
+ )} + {/* 导出进度弹窗 */} {isExporting && (
@@ -823,13 +944,31 @@ function ExportPage() {

正在导出

{exportProgress.currentName}

+ {exportProgress.phaseLabel && ( +

+ {exportProgress.phaseLabel} +

+ )} + {exportProgress.phaseTotal > 0 && ( +
+
+
+ )}
0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }} />
-

{exportProgress.current} / {exportProgress.total}

+

+ {exportProgress.current} / {exportProgress.total} 个会话 + + {elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`} + +

)}