feat(export): 多会话导出布局选择与无媒体直出

- 多会话媒体导出支持共享/分会话目录
- 无媒体导出时直接输出到目标目录
This commit is contained in:
xuncha
2026-01-21 19:37:05 +08:00
parent 076c008329
commit 97c1aa582d
4 changed files with 203 additions and 39 deletions

View File

@@ -72,6 +72,7 @@ export interface ExportOptions {
exportEmojis?: boolean exportEmojis?: boolean
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
sessionLayout?: 'shared' | 'per-session'
} }
interface MediaExportItem { interface MediaExportItem {
@@ -408,14 +409,15 @@ class ExportService {
private async exportMediaForMessage( private async exportMediaForMessage(
msg: any, msg: any,
sessionId: string, sessionId: string,
mediaDir: string, mediaRootDir: string,
mediaRelativePrefix: string,
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
const localType = msg.localType const localType = msg.localType
// 图片消息 // 图片消息
if (localType === 3 && options.exportImages) { if (localType === 3 && options.exportImages) {
const result = await this.exportImage(msg, sessionId, mediaDir) const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) { if (result) {
} }
return result return result
@@ -429,13 +431,13 @@ class ExportService {
} }
// 否则导出语音文件 // 否则导出语音文件
if (options.exportVoices) { if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaDir) return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
} }
} }
// 动画表情 // 动画表情
if (localType === 47 && options.exportEmojis) { if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaDir) const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix)
if (result) { if (result) {
} }
return result return result
@@ -447,9 +449,14 @@ class ExportService {
/** /**
* 导出图片文件 * 导出图片文件
*/ */
private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> { private async exportImage(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try { try {
const imagesDir = path.join(mediaDir, 'media', 'images') const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
if (!fs.existsSync(imagesDir)) { if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true }) fs.mkdirSync(imagesDir, { recursive: true })
} }
@@ -494,7 +501,7 @@ class ExportService {
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
return { return {
relativePath: `media/images/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image' kind: 'image'
} }
} else if (sourcePath.startsWith('file://')) { } else if (sourcePath.startsWith('file://')) {
@@ -512,7 +519,7 @@ class ExportService {
} }
return { return {
relativePath: `media/images/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image' kind: 'image'
} }
} }
@@ -526,9 +533,14 @@ class ExportService {
/** /**
* 导出语音文件 * 导出语音文件
*/ */
private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> { private async exportVoice(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try { try {
const voicesDir = path.join(mediaDir, 'media', 'voices') const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
if (!fs.existsSync(voicesDir)) { if (!fs.existsSync(voicesDir)) {
fs.mkdirSync(voicesDir, { recursive: true }) fs.mkdirSync(voicesDir, { recursive: true })
} }
@@ -540,7 +552,7 @@ class ExportService {
// 如果已存在则跳过 // 如果已存在则跳过
if (fs.existsSync(destPath)) { if (fs.existsSync(destPath)) {
return { return {
relativePath: `media/voices/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice' kind: 'voice'
} }
} }
@@ -556,7 +568,7 @@ class ExportService {
fs.writeFileSync(destPath, wavBuffer) fs.writeFileSync(destPath, wavBuffer)
return { return {
relativePath: `media/voices/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice' kind: 'voice'
} }
} catch (e) { } catch (e) {
@@ -582,9 +594,14 @@ class ExportService {
/** /**
* 导出表情文件 * 导出表情文件
*/ */
private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> { private async exportEmoji(
msg: any,
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string
): Promise<MediaExportItem | null> {
try { try {
const emojisDir = path.join(mediaDir, 'media', 'emojis') const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
if (!fs.existsSync(emojisDir)) { if (!fs.existsSync(emojisDir)) {
fs.mkdirSync(emojisDir, { recursive: true }) fs.mkdirSync(emojisDir, { recursive: true })
} }
@@ -613,7 +630,7 @@ class ExportService {
// 如果已存在则跳过 // 如果已存在则跳过
if (fs.existsSync(destPath)) { if (fs.existsSync(destPath)) {
return { return {
relativePath: `media/emojis/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji' kind: 'emoji'
} }
} }
@@ -623,7 +640,7 @@ class ExportService {
const downloaded = await this.downloadFile(emojiUrl, destPath) const downloaded = await this.downloadFile(emojiUrl, destPath)
if (downloaded) { if (downloaded) {
return { return {
relativePath: `media/emojis/${fileName}`, relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
kind: 'emoji' kind: 'emoji'
} }
} else { } else {
@@ -1483,7 +1500,13 @@ class ExportService {
// 媒体导出设置 // 媒体导出设置
const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis 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<string, MediaExportItem | null>() const mediaCache = new Map<string, MediaExportItem | null>()
@@ -1498,7 +1521,7 @@ class ExportService {
if (mediaCache.has(mediaKey)) { if (mediaCache.has(mediaKey)) {
mediaItem = mediaCache.get(mediaKey) || null mediaItem = mediaCache.get(mediaKey) || null
} else { } else {
mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, { mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages, exportImages: options.exportImages,
exportVoices: options.exportVoices, exportVoices: options.exportVoices,
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
@@ -1656,6 +1679,12 @@ class ExportService {
fs.mkdirSync(outputDir, { recursive: true }) fs.mkdirSync(outputDir, { recursive: true })
} }
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++) { for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i] const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
@@ -1668,10 +1697,10 @@ class ExportService {
}) })
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
// 为每个会话创建单独的文件夹 if (useSessionFolder && !fs.existsSync(sessionDir)) {
const sessionDir = path.join(outputDir, safeName)
if (!fs.existsSync(sessionDir)) {
fs.mkdirSync(sessionDir, { recursive: true }) fs.mkdirSync(sessionDir, { recursive: true })
} }

View File

@@ -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 { .export-result-modal {
background: var(--card-bg); background: var(--card-bg);
padding: 32px 40px; padding: 32px 40px;

View File

@@ -31,6 +31,8 @@ interface ExportResult {
error?: string error?: string
} }
type SessionLayout = 'shared' | 'per-session'
function ExportPage() { function ExportPage() {
const [sessions, setSessions] = useState<ChatSession[]>([]) const [sessions, setSessions] = useState<ChatSession[]>([])
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]) const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
@@ -44,6 +46,7 @@ function ExportPage() {
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 [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'excel', format: 'excel',
@@ -212,7 +215,7 @@ function ExportPage() {
} }
} }
const startExport = async () => { const runExport = async (sessionLayout: SessionLayout) => {
if (selectedSessions.size === 0 || !exportFolder) return if (selectedSessions.size === 0 || !exportFolder) return
setIsExporting(true) setIsExporting(true)
@@ -228,11 +231,12 @@ function ExportPage() {
exportImages: options.exportMedia && options.exportImages, exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices, exportVoices: options.exportMedia && options.exportVoices,
exportEmojis: options.exportMedia && options.exportEmojis, exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? { dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), 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) end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null } : null
} }
@@ -245,16 +249,28 @@ function ExportPage() {
) )
setExportResult(result) setExportResult(result)
} else { } else {
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` }) setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` })
} }
} catch (e) { } catch (e) {
console.error('导出失败:', e) console.error('????????????:', e)
setExportResult({ success: false, error: String(e) }) setExportResult({ success: false, error: String(e) })
} finally { } finally {
setIsExporting(false) 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 getDaysInMonth = (date: Date) => {
const year = date.getFullYear() const year = date.getFullYear()
const month = date.getMonth() const month = date.getMonth()
@@ -613,6 +629,43 @@ function ExportPage() {
</div> </div>
</div> </div>
{/* 媒体导出布局选择弹窗 */}
{showMediaLayoutPrompt && (
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<p className="layout-subtitle"></p>
<div className="layout-options">
<button
className="layout-option-btn primary"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('shared')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"> media </span>
</button>
<button
className="layout-option-btn"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('per-session')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"></span>
</button>
</div>
<div className="layout-actions">
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */} {/* 导出进度弹窗 */}
{isExporting && ( {isExporting && (
<div className="export-overlay"> <div className="export-overlay">

View File

@@ -333,6 +333,7 @@ export interface ExportOptions {
exportEmojis?: boolean exportEmojis?: boolean
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
sessionLayout?: 'shared' | 'per-session'
} }
export interface ExportProgress { export interface ExportProgress {