优化html导出

This commit is contained in:
xuncha
2026-02-06 23:01:31 +08:00
parent 7c9d0a39c3
commit ca1a386146
3 changed files with 276 additions and 182 deletions

View File

@@ -25,83 +25,87 @@ body {
.page { .page {
max-width: 1080px; max-width: 1080px;
margin: 32px auto 60px; margin: 0 auto;
padding: 0 20px; padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
} }
.header { .header {
background: var(--card); background: var(--card);
border-radius: var(--radius); border-radius: 12px;
box-shadow: var(--shadow); box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 24px; padding: 12px 20px;
margin-bottom: 24px; flex-shrink: 0;
} }
.title { .title {
font-size: 24px; font-size: 16px;
font-weight: 600; font-weight: 600;
margin: 0 0 8px; margin: 0;
display: inline;
} }
.meta { .meta {
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 13px;
display: flex; display: inline;
flex-wrap: wrap; margin-left: 12px;
gap: 12px; }
.meta span {
margin-right: 10px;
} }
.controls { .controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 20px;
}
.control {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 6px; gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
} }
.control label { .controls input,
font-size: 13px; .controls button {
color: var(--muted); border-radius: 8px;
}
.control input,
.control select,
.control button {
border-radius: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 10px 12px; padding: 6px 10px;
font-size: 14px; font-size: 13px;
font-family: inherit; font-family: inherit;
} }
.control button { .controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: transform 0.1s ease; padding: 6px 14px;
} }
.control button:active { .controls button:active {
transform: scale(0.98); transform: scale(0.98);
} }
.stats { .stats {
font-size: 13px; font-size: 13px;
color: var(--muted); color: var(--muted);
display: flex; margin-left: auto;
align-items: flex-end;
} }
.message-list { .message-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 12px;
padding: 4px 0;
} }
.message { .message {
@@ -248,50 +252,11 @@ body {
cursor: zoom-out; 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 { .highlight {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 4px; outline-offset: 4px;
border-radius: 18px; border-radius: 18px;
transition: outline-color 0.3s;
} }
.empty { .empty {
@@ -300,32 +265,29 @@ body[data-theme="teal-water"] {
padding: 40px; padding: 40px;
} }
/* Virtual Scroll */ /* Scroll Container */
.virtual-scroll-container { .scroll-container {
height: calc(100vh - 180px); flex: 1;
/* Adjust based on header height */ min-height: 0;
overflow-y: auto; overflow-y: auto;
position: relative;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg); background: var(--bg);
margin-top: 20px; margin-top: 8px;
margin-bottom: 8px;
padding: 12px;
-webkit-overflow-scrolling: touch;
} }
.virtual-scroll-spacer { .scroll-container::-webkit-scrollbar {
opacity: 0; width: 6px;
pointer-events: none;
width: 1px;
} }
.virtual-scroll-content { .scroll-container::-webkit-scrollbar-thumb {
position: absolute; background: #c1c1c1;
top: 0; border-radius: 3px;
left: 0;
width: 100%;
} }
.message-list { .load-sentinel {
/* Override message-list to be inside virtual scroll */ height: 1px;
display: block;
} }

View File

@@ -25,83 +25,87 @@ body {
.page { .page {
max-width: 1080px; max-width: 1080px;
margin: 32px auto 60px; margin: 0 auto;
padding: 0 20px; padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
} }
.header { .header {
background: var(--card); background: var(--card);
border-radius: var(--radius); border-radius: 12px;
box-shadow: var(--shadow); box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 24px; padding: 12px 20px;
margin-bottom: 24px; flex-shrink: 0;
} }
.title { .title {
font-size: 24px; font-size: 16px;
font-weight: 600; font-weight: 600;
margin: 0 0 8px; margin: 0;
display: inline;
} }
.meta { .meta {
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 13px;
display: flex; display: inline;
flex-wrap: wrap; margin-left: 12px;
gap: 12px; }
.meta span {
margin-right: 10px;
} }
.controls { .controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 20px;
}
.control {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 6px; gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
} }
.control label { .controls input,
font-size: 13px; .controls button {
color: var(--muted); border-radius: 8px;
}
.control input,
.control select,
.control button {
border-radius: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 10px 12px; padding: 6px 10px;
font-size: 14px; font-size: 13px;
font-family: inherit; font-family: inherit;
} }
.control button { .controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: transform 0.1s ease; padding: 6px 14px;
} }
.control button:active { .controls button:active {
transform: scale(0.98); transform: scale(0.98);
} }
.stats { .stats {
font-size: 13px; font-size: 13px;
color: var(--muted); color: var(--muted);
display: flex; margin-left: auto;
align-items: flex-end;
} }
.message-list { .message-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 12px;
padding: 4px 0;
} }
.message { .message {
@@ -248,50 +252,11 @@ body {
cursor: zoom-out; 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 { .highlight {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 4px; outline-offset: 4px;
border-radius: 18px; border-radius: 18px;
transition: outline-color 0.3s;
} }
.empty { .empty {
@@ -299,4 +264,32 @@ body[data-theme="teal-water"] {
color: var(--muted); color: var(--muted);
padding: 40px; 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;
}
`; `;

View File

@@ -46,13 +46,22 @@ function ExportPage() {
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [exportFolder, setExportFolder] = useState<string>('') const [exportFolder, setExportFolder] = useState<string>('')
const [isExporting, setIsExporting] = useState(false) 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<ExportResult | null>(null) const [exportResult, setExportResult] = useState<ExportResult | null>(null)
const [showDatePicker, setShowDatePicker] = useState(false) const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date()) const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = 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<SessionLayout>('shared')
const exportStartTime = useRef<number>(0)
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const displayNameDropdownRef = useRef<HTMLDivElement>(null) const displayNameDropdownRef = useRef<HTMLDivElement>(null)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
@@ -189,17 +198,30 @@ function ExportPage() {
}, [loadSessions]) }, [loadSessions])
useEffect(() => { 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({ setExportProgress({
current: payload.current, current: payload.current,
total: payload.total, total: payload.total,
currentName: payload.currentSession currentName: payload.currentSession,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
}) })
}) })
return () => { return () => {
removeListener?.() removeListener?.()
} }
}, []) }, [])
// 导出计时器
useEffect(() => {
if (!isExporting) return
const timer = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000))
}, 1000)
return () => clearInterval(timer)
}, [isExporting])
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node const target = event.target as Node
@@ -278,8 +300,10 @@ function ExportPage() {
if (selectedSessions.size === 0 || !exportFolder) return if (selectedSessions.size === 0 || !exportFolder) return
setIsExporting(true) setIsExporting(true)
setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' }) setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
setExportResult(null) setExportResult(null)
exportStartTime.current = Date.now()
setElapsedSeconds(0)
try { try {
const sessionList = Array.from(selectedSessions) const sessionList = Array.from(selectedSessions)
@@ -322,9 +346,41 @@ function ExportPage() {
} }
} }
const startExport = () => { const startExport = async () => {
if (selectedSessions.size === 0 || !exportFolder) return 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) { if (options.exportMedia && selectedSessions.size > 1) {
setShowMediaLayoutPrompt(true) setShowMediaLayoutPrompt(true)
return return
@@ -814,6 +870,71 @@ function ExportPage() {
</div> </div>
)} )}
{/* 导出前预估弹窗 */}
{showPreExportDialog && (
<div className="export-overlay">
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
{isLoadingStats ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}>
<Loader2 size={20} className="spin" />
<span style={{ fontSize: 14, color: 'var(--text-secondary)' }}>...</span>
</div>
) : preExportStats ? (
<div style={{ padding: '12px 0' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', fontSize: 14 }}>
<div>
<span style={{ color: 'var(--text-secondary)' }}></span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{selectedSessions.size}</div>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}></span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.totalMessages.toLocaleString()}</div>
</div>
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && (
<>
<div>
<span style={{ color: 'var(--text-secondary)' }}></span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.voiceMessages}</div>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}></span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2, color: 'var(--primary)' }}>{preExportStats.cachedVoiceCount}</div>
</div>
</>
)}
</div>
{options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && (
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-warning, #e6a23c)' }}></span>
{' '} <b>{preExportStats.needTranscribeCount}</b> <b>{preExportStats.estimatedSeconds > 60
? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟`
: `${preExportStats.estimatedSeconds}`
}</b>
</div>
)}
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && (
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-success, #67c23a)' }}></span>
{' '} {preExportStats.voiceMessages}
</div>
)}
</div>
) : (
<p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}></p>
)}
<div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button className="layout-cancel-btn" onClick={() => { setShowPreExportDialog(false); setPreExportStats(null) }}>
</button>
<button className="layout-option-btn primary" onClick={confirmExport} disabled={isLoadingStats}>
<span className="layout-title"></span>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */} {/* 导出进度弹窗 */}
{isExporting && ( {isExporting && (
<div className="export-overlay"> <div className="export-overlay">
@@ -823,13 +944,31 @@ function ExportPage() {
</div> </div>
<h3></h3> <h3></h3>
<p className="progress-text">{exportProgress.currentName}</p> <p className="progress-text">{exportProgress.currentName}</p>
{exportProgress.phaseLabel && (
<p className="progress-phase-label" style={{ fontSize: 13, color: 'var(--text-secondary)', margin: '4px 0 8px' }}>
{exportProgress.phaseLabel}
</p>
)}
{exportProgress.phaseTotal > 0 && (
<div className="progress-bar" style={{ marginBottom: 8 }}>
<div
className="progress-fill"
style={{ width: `${(exportProgress.phaseProgress / exportProgress.phaseTotal) * 100}%`, background: 'var(--primary-light, #79bbff)' }}
/>
</div>
)}
<div className="progress-bar"> <div className="progress-bar">
<div <div
className="progress-fill" className="progress-fill"
style={{ width: `${(exportProgress.current / exportProgress.total) * 100}%` }} style={{ width: `${exportProgress.total > 0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }}
/> />
</div> </div>
<p className="progress-count">{exportProgress.current} / {exportProgress.total}</p> <p className="progress-count">
{exportProgress.current} / {exportProgress.total}
<span style={{ marginLeft: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
{elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}${elapsedSeconds % 60}` : `${elapsedSeconds}`}`}
</span>
</p>
</div> </div>
</div> </div>
)} )}