From 97c1aa582db97693eb141c42140622c3b48d52c5 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 21 Jan 2026 19:37:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(export):=20=E5=A4=9A=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=B8=83=E5=B1=80=E9=80=89=E6=8B=A9=E4=B8=8E?= =?UTF-8?q?=E6=97=A0=E5=AA=92=E4=BD=93=E7=9B=B4=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 多会话媒体导出支持共享/分会话目录 - 无媒体导出时直接输出到目标目录 --- electron/services/exportService.ts | 95 +++++++++++++++++++----------- src/pages/ExportPage.scss | 83 +++++++++++++++++++++++++- src/pages/ExportPage.tsx | 63 ++++++++++++++++++-- src/types/electron.d.ts | 1 + 4 files changed, 203 insertions(+), 39 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 0a3f1cb..1ea9929 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -72,6 +72,7 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + sessionLayout?: 'shared' | 'per-session' } interface MediaExportItem { @@ -408,14 +409,15 @@ class ExportService { private async exportMediaForMessage( msg: any, sessionId: string, - mediaDir: string, + mediaRootDir: string, + mediaRelativePrefix: string, options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } ): Promise { const localType = msg.localType // 图片消息 if (localType === 3 && options.exportImages) { - const result = await this.exportImage(msg, sessionId, mediaDir) + const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { } return result @@ -429,13 +431,13 @@ class ExportService { } // 否则导出语音文件 if (options.exportVoices) { - return this.exportVoice(msg, sessionId, mediaDir) + return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) } } // 动画表情 if (localType === 47 && options.exportEmojis) { - const result = await this.exportEmoji(msg, sessionId, mediaDir) + const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { } return result @@ -447,9 +449,14 @@ class ExportService { /** * 导出图片文件 */ - private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise { + private async exportImage( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { try { - const imagesDir = path.join(mediaDir, 'media', 'images') + const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') if (!fs.existsSync(imagesDir)) { fs.mkdirSync(imagesDir, { recursive: true }) } @@ -494,7 +501,7 @@ class ExportService { fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) return { - relativePath: `media/images/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), kind: 'image' } } else if (sourcePath.startsWith('file://')) { @@ -512,7 +519,7 @@ class ExportService { } return { - relativePath: `media/images/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), kind: 'image' } } @@ -526,9 +533,14 @@ class ExportService { /** * 导出语音文件 */ - private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise { + private async exportVoice( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { try { - const voicesDir = path.join(mediaDir, 'media', 'voices') + const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices') if (!fs.existsSync(voicesDir)) { fs.mkdirSync(voicesDir, { recursive: true }) } @@ -540,7 +552,7 @@ class ExportService { // 如果已存在则跳过 if (fs.existsSync(destPath)) { return { - relativePath: `media/voices/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), kind: 'voice' } } @@ -556,7 +568,7 @@ class ExportService { fs.writeFileSync(destPath, wavBuffer) return { - relativePath: `media/voices/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), kind: 'voice' } } catch (e) { @@ -582,9 +594,14 @@ class ExportService { /** * 导出表情文件 */ - private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise { + private async exportEmoji( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { try { - const emojisDir = path.join(mediaDir, 'media', 'emojis') + const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis') if (!fs.existsSync(emojisDir)) { fs.mkdirSync(emojisDir, { recursive: true }) } @@ -613,7 +630,7 @@ class ExportService { // 如果已存在则跳过 if (fs.existsSync(destPath)) { return { - relativePath: `media/emojis/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), kind: 'emoji' } } @@ -621,13 +638,13 @@ class ExportService { // 下载表情 if (emojiUrl) { const downloaded = await this.downloadFile(emojiUrl, destPath) - if (downloaded) { - return { - relativePath: `media/emojis/${fileName}`, - kind: 'emoji' - } - } else { - } + if (downloaded) { + return { + relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), + kind: 'emoji' + } + } else { + } } return null @@ -1483,7 +1500,13 @@ class ExportService { // 媒体导出设置 const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis - const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出 + const outputDir = path.dirname(outputPath) + const outputBaseName = path.basename(outputPath, path.extname(outputPath)) + const useSharedMediaLayout = options.sessionLayout === 'shared' + const mediaRelativePrefix = useSharedMediaLayout + ? path.posix.join('media', outputBaseName) + : 'media' + const mediaRootDir = outputDir // 媒体导出缓存 const mediaCache = new Map() @@ -1498,7 +1521,7 @@ class ExportService { if (mediaCache.has(mediaKey)) { mediaItem = mediaCache.get(mediaKey) || null } else { - mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, { + mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, @@ -1656,9 +1679,15 @@ class ExportService { fs.mkdirSync(outputDir, { recursive: true }) } - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i] - const sessionInfo = await this.getContactInfo(sessionId) + const exportMediaEnabled = options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportEmojis) + const sessionLayout = exportMediaEnabled + ? (options.sessionLayout ?? 'per-session') + : 'shared' + + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i] + const sessionInfo = await this.getContactInfo(sessionId) onProgress?.({ current: i + 1, @@ -1667,13 +1696,13 @@ class ExportService { phase: 'exporting' }) - const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + const useSessionFolder = sessionLayout === 'per-session' + const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir - // 为每个会话创建单独的文件夹 - const sessionDir = path.join(outputDir, safeName) - if (!fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }) - } + if (useSessionFolder && !fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }) + } let ext = '.json' if (options.format === 'chatlab-jsonl') ext = '.jsonl' diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 4aa5b35..ddedbd9 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -602,6 +602,87 @@ } } + .export-layout-modal { + background: var(--card-bg); + padding: 28px 32px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + text-align: center; + width: min(520px, 90vw); + + h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 8px; + } + + .layout-subtitle { + font-size: 14px; + color: var(--text-secondary); + margin: 0 0 20px; + } + + .layout-options { + display: grid; + gap: 12px; + } + + .layout-option-btn { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 18px; + border-radius: 12px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + text-align: left; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } + + &.primary { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } + + .layout-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .layout-desc { + font-size: 12px; + color: var(--text-tertiary); + } + } + + .layout-actions { + margin-top: 18px; + display: flex; + justify-content: center; + } + + .layout-cancel-btn { + padding: 8px 20px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + } + } + } + .export-result-modal { background: var(--card-bg); padding: 32px 40px; @@ -1056,4 +1137,4 @@ input:checked + .slider::before { transform: translateX(20px); } -} \ No newline at end of file +} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index ef0e68e..929c3a3 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -31,6 +31,8 @@ interface ExportResult { error?: string } +type SessionLayout = 'shared' | 'per-session' + function ExportPage() { const [sessions, setSessions] = useState([]) const [filteredSessions, setFilteredSessions] = useState([]) @@ -44,6 +46,7 @@ function ExportPage() { const [showDatePicker, setShowDatePicker] = useState(false) const [calendarDate, setCalendarDate] = useState(new Date()) const [selectingStart, setSelectingStart] = useState(true) + const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [options, setOptions] = useState({ format: 'excel', @@ -212,7 +215,7 @@ function ExportPage() { } } - const startExport = async () => { + const runExport = async (sessionLayout: SessionLayout) => { if (selectedSessions.size === 0 || !exportFolder) return setIsExporting(true) @@ -228,11 +231,12 @@ function ExportPage() { exportImages: options.exportMedia && options.exportImages, exportVoices: options.exportMedia && options.exportVoices, exportEmojis: options.exportMedia && options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia + exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia excelCompactColumns: options.excelCompactColumns, + sessionLayout, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), - // 将结束日期设置为当天的 23:59:59,以包含当天的所有消息 + // ?????????????????????????????????23:59:59,?????????????????????????????? end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) } : null } @@ -245,16 +249,28 @@ function ExportPage() { ) setExportResult(result) } else { - setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` }) + setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` }) } } catch (e) { - console.error('导出失败:', e) + console.error('????????????:', e) setExportResult({ success: false, error: String(e) }) } finally { setIsExporting(false) } } + const startExport = () => { + if (selectedSessions.size === 0 || !exportFolder) return + + if (options.exportMedia && selectedSessions.size > 1) { + setShowMediaLayoutPrompt(true) + return + } + + const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared' + runExport(layout) + } + const getDaysInMonth = (date: Date) => { const year = date.getFullYear() const month = date.getMonth() @@ -613,6 +629,43 @@ function ExportPage() { + {/* 媒体导出布局选择弹窗 */} + {showMediaLayoutPrompt && ( +
setShowMediaLayoutPrompt(false)}> +
e.stopPropagation()}> +

导出文件夹布局

+

检测到同时导出多个会话并包含媒体文件,请选择存放方式:

+
+ + +
+
+ +
+
+
+ )} + {/* 导出进度弹窗 */} {isExporting && (
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 45d134d..2298766 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -333,6 +333,7 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + sessionLayout?: 'shared' | 'per-session' } export interface ExportProgress {