mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
实现 HTML 导出功能
This commit is contained in:
@@ -4,10 +4,12 @@ import * as http from 'http'
|
|||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import ExcelJS from 'exceljs'
|
import ExcelJS from 'exceljs'
|
||||||
|
import { getEmojiPath } from 'wechat-emojis'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { imageDecryptService } from './imageDecryptService'
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
import { chatService } from './chatService'
|
import { chatService } from './chatService'
|
||||||
|
import { videoService } from './videoService'
|
||||||
|
|
||||||
// ChatLab 格式类型定义
|
// ChatLab 格式类型定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -89,7 +91,8 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
|||||||
|
|
||||||
interface MediaExportItem {
|
interface MediaExportItem {
|
||||||
relativePath: string
|
relativePath: string
|
||||||
kind: 'image' | 'voice' | 'emoji'
|
kind: 'image' | 'voice' | 'emoji' | 'video'
|
||||||
|
posterDataUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportProgress {
|
export interface ExportProgress {
|
||||||
@@ -127,6 +130,7 @@ async function parallelLimit<T, R>(
|
|||||||
class ExportService {
|
class ExportService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private contactCache: Map<string, { displayName: string; avatarUrl?: string }> = new Map()
|
private contactCache: Map<string, { displayName: string; avatarUrl?: string }> = new Map()
|
||||||
|
private inlineEmojiCache: Map<string, string> = new Map()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -216,6 +220,9 @@ class ExportService {
|
|||||||
if (!raw) return ''
|
if (!raw) return ''
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
if (raw.length === 0) return ''
|
if (raw.length === 0) return ''
|
||||||
|
if (/^[0-9]+$/.test(raw)) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
if (this.looksLikeHex(raw)) {
|
if (this.looksLikeHex(raw)) {
|
||||||
const bytes = Buffer.from(raw, 'hex')
|
const bytes = Buffer.from(raw, 'hex')
|
||||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||||
@@ -451,6 +458,106 @@ class ExportService {
|
|||||||
return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim()
|
return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeAttribute(value: string): string {
|
||||||
|
return this.escapeHtml(value).replace(/`/g, '`')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAvatarFallback(name: string): string {
|
||||||
|
if (!name) return '?'
|
||||||
|
return [...name][0] || '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMultilineText(value: string): string {
|
||||||
|
return this.escapeHtml(value).replace(/\r?\n/g, '<br />')
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAppMessageContent(content: string): string {
|
||||||
|
if (!content) return ''
|
||||||
|
if (content.includes('<') && content.includes('>')) {
|
||||||
|
return content
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInlineEmojiDataUrl(name: string): string | null {
|
||||||
|
if (!name) return null
|
||||||
|
const cached = this.inlineEmojiCache.get(name)
|
||||||
|
if (cached) return cached
|
||||||
|
const emojiPath = getEmojiPath(name as any)
|
||||||
|
if (!emojiPath) return null
|
||||||
|
const baseDir = path.dirname(require.resolve('wechat-emojis'))
|
||||||
|
const absolutePath = path.join(baseDir, emojiPath)
|
||||||
|
if (!fs.existsSync(absolutePath)) return null
|
||||||
|
try {
|
||||||
|
const buffer = fs.readFileSync(absolutePath)
|
||||||
|
const dataUrl = `data:image/png;base64,${buffer.toString('base64')}`
|
||||||
|
this.inlineEmojiCache.set(name, dataUrl)
|
||||||
|
return dataUrl
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTextWithEmoji(text: string): string {
|
||||||
|
if (!text) return ''
|
||||||
|
const parts = text.split(/\[(.*?)\]/g)
|
||||||
|
const rendered = parts.map((part, index) => {
|
||||||
|
if (index % 2 === 1) {
|
||||||
|
const emojiDataUrl = this.getInlineEmojiDataUrl(part)
|
||||||
|
if (emojiDataUrl) {
|
||||||
|
return `<img class="inline-emoji" src="${this.escapeAttribute(emojiDataUrl)}" alt="[${this.escapeAttribute(part)}]" />`
|
||||||
|
}
|
||||||
|
return this.escapeHtml(`[${part}]`)
|
||||||
|
}
|
||||||
|
return this.escapeHtml(part)
|
||||||
|
})
|
||||||
|
return rendered.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatHtmlMessageText(content: string, localType: number): string {
|
||||||
|
if (!content) return ''
|
||||||
|
|
||||||
|
const normalized = this.normalizeAppMessageContent(content)
|
||||||
|
const isAppMessage = normalized.includes('<appmsg') || normalized.includes('<msg>')
|
||||||
|
|
||||||
|
if (localType === 49 || isAppMessage) {
|
||||||
|
const typeMatch = /<type>(\d+)<\/type>/i.exec(normalized)
|
||||||
|
const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0
|
||||||
|
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname')
|
||||||
|
if (subType === 6) {
|
||||||
|
const fileName = this.extractXmlValue(normalized, 'filename') || title || '文件'
|
||||||
|
return `[文件] ${fileName}`.trim()
|
||||||
|
}
|
||||||
|
if (subType === 33 || subType === 36) {
|
||||||
|
const appName = this.extractXmlValue(normalized, 'appname')
|
||||||
|
const miniTitle = title || appName || '小程序'
|
||||||
|
return `[小程序] ${miniTitle}`.trim()
|
||||||
|
}
|
||||||
|
return title || '[链接]'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localType === 42) {
|
||||||
|
const nickname = this.extractXmlValue(normalized, 'nickname')
|
||||||
|
return nickname ? `[名片] ${nickname}` : '[名片]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parseMessageContent(content, localType) || ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出媒体文件到指定目录
|
* 导出媒体文件到指定目录
|
||||||
*/
|
*/
|
||||||
@@ -459,7 +566,14 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
|
options: {
|
||||||
|
exportImages?: boolean
|
||||||
|
exportVoices?: boolean
|
||||||
|
exportEmojis?: boolean
|
||||||
|
exportVoiceAsText?: boolean
|
||||||
|
includeVoiceWithTranscript?: boolean
|
||||||
|
exportVideos?: boolean
|
||||||
|
}
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
const localType = msg.localType
|
const localType = msg.localType
|
||||||
|
|
||||||
@@ -473,14 +587,13 @@ class ExportService {
|
|||||||
|
|
||||||
// 语音消息
|
// 语音消息
|
||||||
if (localType === 34) {
|
if (localType === 34) {
|
||||||
// 如果开启了语音转文字,优先转文字(不导出语音文件)
|
const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText
|
||||||
if (options.exportVoiceAsText) {
|
if (shouldKeepVoiceFile && options.exportVoices) {
|
||||||
return null // 转文字逻辑在消息内容处理中完成
|
|
||||||
}
|
|
||||||
// 否则导出语音文件
|
|
||||||
if (options.exportVoices) {
|
|
||||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||||
}
|
}
|
||||||
|
if (options.exportVoiceAsText) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动画表情
|
// 动画表情
|
||||||
@@ -491,6 +604,10 @@ class ExportService {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (localType === 43 && options.exportVideos) {
|
||||||
|
return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,6 +818,47 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出视频文件
|
||||||
|
*/
|
||||||
|
private async exportVideo(
|
||||||
|
msg: any,
|
||||||
|
sessionId: string,
|
||||||
|
mediaRootDir: string,
|
||||||
|
mediaRelativePrefix: string
|
||||||
|
): Promise<MediaExportItem | null> {
|
||||||
|
try {
|
||||||
|
const videoMd5 = msg.videoMd5
|
||||||
|
if (!videoMd5) return null
|
||||||
|
|
||||||
|
const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos')
|
||||||
|
if (!fs.existsSync(videosDir)) {
|
||||||
|
fs.mkdirSync(videosDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoInfo = await videoService.getVideoInfo(videoMd5)
|
||||||
|
if (!videoInfo.exists || !videoInfo.videoUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = videoInfo.videoUrl
|
||||||
|
const fileName = path.basename(sourcePath)
|
||||||
|
const destPath = path.join(videosDir, fileName)
|
||||||
|
|
||||||
|
if (!fs.existsSync(destPath)) {
|
||||||
|
fs.copyFileSync(sourcePath, destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName),
|
||||||
|
kind: 'video',
|
||||||
|
posterDataUrl: videoInfo.coverUrl || videoInfo.thumbUrl
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从消息内容提取图片 MD5
|
* 从消息内容提取图片 MD5
|
||||||
*/
|
*/
|
||||||
@@ -759,6 +917,16 @@ class ExportService {
|
|||||||
return match?.[1]
|
return match?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractVideoMd5(content: string): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
const attrMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (attrMatch) {
|
||||||
|
return attrMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
const tagMatch = /<md5>([^<]+)<\/md5>/i.exec(content)
|
||||||
|
return tagMatch?.[1]?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 data URL 获取扩展名
|
* 从 data URL 获取扩展名
|
||||||
*/
|
*/
|
||||||
@@ -881,6 +1049,7 @@ class ExportService {
|
|||||||
let imageDatName: string | undefined
|
let imageDatName: string | undefined
|
||||||
let emojiCdnUrl: string | undefined
|
let emojiCdnUrl: string | undefined
|
||||||
let emojiMd5: string | undefined
|
let emojiMd5: string | undefined
|
||||||
|
let videoMd5: string | undefined
|
||||||
|
|
||||||
if (localType === 3 && content) {
|
if (localType === 3 && content) {
|
||||||
// 图片消息
|
// 图片消息
|
||||||
@@ -890,6 +1059,9 @@ class ExportService {
|
|||||||
// 动画表情
|
// 动画表情
|
||||||
emojiCdnUrl = this.extractEmojiUrl(content)
|
emojiCdnUrl = this.extractEmojiUrl(content)
|
||||||
emojiMd5 = this.extractEmojiMd5(content)
|
emojiMd5 = this.extractEmojiMd5(content)
|
||||||
|
} else if (localType === 43 && content) {
|
||||||
|
// 视频消息
|
||||||
|
videoMd5 = this.extractVideoMd5(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
@@ -902,7 +1074,8 @@ class ExportService {
|
|||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
emojiCdnUrl,
|
emojiCdnUrl,
|
||||||
emojiMd5
|
emojiMd5,
|
||||||
|
videoMd5
|
||||||
})
|
})
|
||||||
|
|
||||||
if (firstTime === null || createTime < firstTime) firstTime = createTime
|
if (firstTime === null || createTime < firstTime) firstTime = createTime
|
||||||
@@ -2094,6 +2267,653 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出单个会话为 HTML 格式
|
||||||
|
*/
|
||||||
|
async exportSessionToHtml(
|
||||||
|
sessionId: string,
|
||||||
|
outputPath: string,
|
||||||
|
options: ExportOptions,
|
||||||
|
onProgress?: (progress: ExportProgress) => void
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
current: 0,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'preparing'
|
||||||
|
})
|
||||||
|
|
||||||
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||||
|
if (isGroup) {
|
||||||
|
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||||
|
}
|
||||||
|
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|
||||||
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
|
const mediaMessages = exportMediaEnabled
|
||||||
|
? sortedMessages.filter(msg => {
|
||||||
|
const t = msg.localType
|
||||||
|
return (t === 3 && options.exportImages) ||
|
||||||
|
(t === 47 && options.exportEmojis) ||
|
||||||
|
(t === 34 && options.exportVoices) ||
|
||||||
|
t === 43
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
|
|
||||||
|
if (mediaMessages.length > 0) {
|
||||||
|
onProgress?.({
|
||||||
|
current: 20,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'exporting-media'
|
||||||
|
})
|
||||||
|
|
||||||
|
const MEDIA_CONCURRENCY = 6
|
||||||
|
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||||
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
|
if (!mediaCache.has(mediaKey)) {
|
||||||
|
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||||
|
exportImages: options.exportImages,
|
||||||
|
exportVoices: options.exportVoices,
|
||||||
|
exportEmojis: options.exportEmojis,
|
||||||
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
|
includeVoiceWithTranscript: true,
|
||||||
|
exportVideos: true
|
||||||
|
})
|
||||||
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const useVoiceTranscript = options.exportVoiceAsText !== false
|
||||||
|
const voiceMessages = useVoiceTranscript
|
||||||
|
? sortedMessages.filter(msg => msg.localType === 34)
|
||||||
|
: []
|
||||||
|
const voiceTranscriptMap = new Map<number, string>()
|
||||||
|
|
||||||
|
if (voiceMessages.length > 0) {
|
||||||
|
onProgress?.({
|
||||||
|
current: 40,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'exporting-voice'
|
||||||
|
})
|
||||||
|
|
||||||
|
const VOICE_CONCURRENCY = 4
|
||||||
|
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||||
|
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||||
|
voiceTranscriptMap.set(msg.localId, transcript)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarMap = options.exportAvatars
|
||||||
|
? await this.exportAvatars(
|
||||||
|
[
|
||||||
|
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
||||||
|
username,
|
||||||
|
avatarUrl: info.avatarUrl
|
||||||
|
})),
|
||||||
|
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
||||||
|
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
||||||
|
]
|
||||||
|
)
|
||||||
|
: new Map<string, string>()
|
||||||
|
|
||||||
|
const renderedMessages = sortedMessages.map((msg, index) => {
|
||||||
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
|
const mediaItem = mediaCache.get(mediaKey) || null
|
||||||
|
|
||||||
|
const isSenderMe = msg.isSend
|
||||||
|
const senderInfo = collected.memberSet.get(msg.senderUsername)?.member
|
||||||
|
const senderName = isSenderMe
|
||||||
|
? (myInfo.displayName || '我')
|
||||||
|
: (isGroup
|
||||||
|
? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername)
|
||||||
|
: (sessionInfo.displayName || sessionId))
|
||||||
|
const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername)
|
||||||
|
const avatarHtml = avatarData
|
||||||
|
? `<img src="${this.escapeAttribute(avatarData)}" alt="${this.escapeAttribute(senderName)}" />`
|
||||||
|
: `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>`
|
||||||
|
|
||||||
|
const timeText = this.formatTimestamp(msg.createTime)
|
||||||
|
const typeName = this.getMessageTypeName(msg.localType)
|
||||||
|
|
||||||
|
let textContent = this.formatHtmlMessageText(msg.content, msg.localType)
|
||||||
|
if (msg.localType === 34 && useVoiceTranscript) {
|
||||||
|
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
|
}
|
||||||
|
if (mediaItem && (msg.localType === 3 || msg.localType === 43 || msg.localType === 47)) {
|
||||||
|
textContent = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaHtml = ''
|
||||||
|
if (mediaItem?.kind === 'image') {
|
||||||
|
const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath))
|
||||||
|
mediaHtml = `<img class="message-media image previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${this.escapeAttribute(typeName)}" />`
|
||||||
|
} else if (mediaItem?.kind === 'emoji') {
|
||||||
|
const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath))
|
||||||
|
mediaHtml = `<img class="message-media emoji previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${this.escapeAttribute(typeName)}" />`
|
||||||
|
} else if (mediaItem?.kind === 'voice') {
|
||||||
|
mediaHtml = `<audio class="message-media audio" controls src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></audio>`
|
||||||
|
} else if (mediaItem?.kind === 'video') {
|
||||||
|
const posterAttr = mediaItem.posterDataUrl ? ` poster="${this.escapeAttribute(mediaItem.posterDataUrl)}"` : ''
|
||||||
|
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const textHtml = textContent
|
||||||
|
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
||||||
|
: ''
|
||||||
|
const senderHtml = isGroup
|
||||||
|
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
|
||||||
|
: ''
|
||||||
|
const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>`
|
||||||
|
const messageBody = `
|
||||||
|
${timeHtml}
|
||||||
|
${senderHtml}
|
||||||
|
<div class="message-content">
|
||||||
|
${mediaHtml}
|
||||||
|
${textHtml}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="message ${isSenderMe ? 'sent' : 'received'}" data-timestamp="${msg.createTime}" data-index="${index + 1}">
|
||||||
|
<div class="message-row">
|
||||||
|
<div class="avatar">${avatarHtml}</div>
|
||||||
|
<div class="bubble">
|
||||||
|
${messageBody}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
current: 85,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'writing'
|
||||||
|
})
|
||||||
|
|
||||||
|
const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>${this.escapeHtml(sessionInfo.displayName)} - 聊天记录</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1f2a37;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--sent: #dbeafe;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 32px auto 60px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control input,
|
||||||
|
.control select,
|
||||||
|
.control button {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .message-row {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #eef2ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: min(70%, 720px);
|
||||||
|
background: var(--received);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .bubble {
|
||||||
|
background: var(--sent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-emoji {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media {
|
||||||
|
border-radius: 14px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewable {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.image,
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 260px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 160px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.video {
|
||||||
|
max-height: 360px;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.audio {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: min(90vw, 1200px);
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
background: #0f172a;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="title">${this.escapeHtml(sessionInfo.displayName)} 的聊天记录</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span>导出时间:${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))}</span>
|
||||||
|
<span>消息数量:${sortedMessages.length}</span>
|
||||||
|
<span>会话类型:${isGroup ? '群聊' : '私聊'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<div class="control">
|
||||||
|
<label for="searchInput">搜索内容 / 发送者</label>
|
||||||
|
<input id="searchInput" type="search" placeholder="输入关键词实时过滤" />
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<label for="timeInput">按时间跳转</label>
|
||||||
|
<input id="timeInput" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<label for="themeSelect">主题配色</label>
|
||||||
|
<select id="themeSelect">
|
||||||
|
<option value="cloud-dancer">云舞蓝</option>
|
||||||
|
<option value="corundum-blue">珊瑚蓝</option>
|
||||||
|
<option value="kiwi-green">奇异绿</option>
|
||||||
|
<option value="spicy-red">热辣红</option>
|
||||||
|
<option value="teal-water">蓝绿水</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<label> </label>
|
||||||
|
<button id="jumpBtn" type="button">跳转到时间</button>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<span id="resultCount">共 ${sortedMessages.length} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-list" id="messageList">
|
||||||
|
${renderedMessages || '<div class="empty">暂无消息</div>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-preview" id="imagePreview">
|
||||||
|
<img id="imagePreviewTarget" alt="预览" />
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const messages = Array.from(document.querySelectorAll('.message'))
|
||||||
|
const searchInput = document.getElementById('searchInput')
|
||||||
|
const timeInput = document.getElementById('timeInput')
|
||||||
|
const jumpBtn = document.getElementById('jumpBtn')
|
||||||
|
const resultCount = document.getElementById('resultCount')
|
||||||
|
const themeSelect = document.getElementById('themeSelect')
|
||||||
|
const imagePreview = document.getElementById('imagePreview')
|
||||||
|
const imagePreviewTarget = document.getElementById('imagePreviewTarget')
|
||||||
|
let imageZoom = 1
|
||||||
|
|
||||||
|
const updateCount = () => {
|
||||||
|
const visible = messages.filter((msg) => !msg.classList.contains('hidden'))
|
||||||
|
resultCount.textContent = \`共 \${visible.length} 条\`
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
const keyword = searchInput.value.trim().toLowerCase()
|
||||||
|
messages.forEach((msg) => {
|
||||||
|
const text = msg.textContent ? msg.textContent.toLowerCase() : ''
|
||||||
|
const match = !keyword || text.includes(keyword)
|
||||||
|
msg.classList.toggle('hidden', !match)
|
||||||
|
})
|
||||||
|
updateCount()
|
||||||
|
})
|
||||||
|
|
||||||
|
jumpBtn.addEventListener('click', () => {
|
||||||
|
const value = timeInput.value
|
||||||
|
if (!value) return
|
||||||
|
const target = Math.floor(new Date(value).getTime() / 1000)
|
||||||
|
const visibleMessages = messages.filter((msg) => !msg.classList.contains('hidden'))
|
||||||
|
if (visibleMessages.length === 0) return
|
||||||
|
let targetMessage = visibleMessages.find((msg) => {
|
||||||
|
const time = Number(msg.dataset.timestamp || 0)
|
||||||
|
return time >= target
|
||||||
|
})
|
||||||
|
if (!targetMessage) {
|
||||||
|
targetMessage = visibleMessages[visibleMessages.length - 1]
|
||||||
|
}
|
||||||
|
visibleMessages.forEach((msg) => msg.classList.remove('highlight'))
|
||||||
|
targetMessage.classList.add('highlight')
|
||||||
|
targetMessage.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
setTimeout(() => targetMessage.classList.remove('highlight'), 2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyTheme = (value) => {
|
||||||
|
document.body.setAttribute('data-theme', value)
|
||||||
|
localStorage.setItem('weflow-export-theme', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedTheme = localStorage.getItem('weflow-export-theme') || 'cloud-dancer'
|
||||||
|
themeSelect.value = storedTheme
|
||||||
|
applyTheme(storedTheme)
|
||||||
|
|
||||||
|
themeSelect.addEventListener('change', (event) => {
|
||||||
|
applyTheme(event.target.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.querySelectorAll('.previewable').forEach((img) => {
|
||||||
|
img.addEventListener('click', () => {
|
||||||
|
const full = img.getAttribute('data-full')
|
||||||
|
if (!full) return
|
||||||
|
imagePreviewTarget.src = full
|
||||||
|
imageZoom = 1
|
||||||
|
imagePreviewTarget.style.transform = 'scale(1)'
|
||||||
|
imagePreview.classList.add('active')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
imagePreviewTarget.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
})
|
||||||
|
|
||||||
|
imagePreviewTarget.addEventListener('dblclick', (event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
imageZoom = 1
|
||||||
|
imagePreviewTarget.style.transform = 'scale(1)'
|
||||||
|
})
|
||||||
|
|
||||||
|
imagePreviewTarget.addEventListener('wheel', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const delta = event.deltaY > 0 ? -0.1 : 0.1
|
||||||
|
imageZoom = Math.min(3, Math.max(0.5, imageZoom + delta))
|
||||||
|
imagePreviewTarget.style.transform = \`scale(\${imageZoom})\`
|
||||||
|
}, { passive: false })
|
||||||
|
|
||||||
|
imagePreview.addEventListener('click', () => {
|
||||||
|
imagePreview.classList.remove('active')
|
||||||
|
imagePreviewTarget.src = ''
|
||||||
|
imageZoom = 1
|
||||||
|
imagePreviewTarget.style.transform = 'scale(1)'
|
||||||
|
})
|
||||||
|
|
||||||
|
updateCount()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, html, 'utf-8')
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
current: 100,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'complete'
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量导出多个会话
|
* 批量导出多个会话
|
||||||
*/
|
*/
|
||||||
@@ -2145,6 +2965,7 @@ class ExportService {
|
|||||||
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
||||||
else if (options.format === 'excel') ext = '.xlsx'
|
else if (options.format === 'excel') ext = '.xlsx'
|
||||||
else if (options.format === 'txt') ext = '.txt'
|
else if (options.format === 'txt') ext = '.txt'
|
||||||
|
else if (options.format === 'html') ext = '.html'
|
||||||
const outputPath = path.join(sessionDir, `${safeName}${ext}`)
|
const outputPath = path.join(sessionDir, `${safeName}${ext}`)
|
||||||
|
|
||||||
let result: { success: boolean; error?: string }
|
let result: { success: boolean; error?: string }
|
||||||
@@ -2156,6 +2977,8 @@ class ExportService {
|
|||||||
result = await this.exportSessionToExcel(sessionId, outputPath, options)
|
result = await this.exportSessionToExcel(sessionId, outputPath, options)
|
||||||
} else if (options.format === 'txt') {
|
} else if (options.format === 'txt') {
|
||||||
result = await this.exportSessionToTxt(sessionId, outputPath, options)
|
result = await this.exportSessionToTxt(sessionId, outputPath, options)
|
||||||
|
} else if (options.format === 'html') {
|
||||||
|
result = await this.exportSessionToHtml(sessionId, outputPath, options)
|
||||||
} else {
|
} else {
|
||||||
result = { success: false, error: `不支持的格式: ${options.format}` }
|
result = { success: false, error: `不支持的格式: ${options.format}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,23 @@ function ExportPage() {
|
|||||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFormatChange = (format: ExportOptions['format']) => {
|
||||||
|
setOptions((prev) => {
|
||||||
|
const next = { ...prev, format }
|
||||||
|
if (format === 'html') {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
exportMedia: true,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportEmojis: true,
|
||||||
|
exportVoiceAsText: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openExportFolder = async () => {
|
const openExportFolder = async () => {
|
||||||
if (exportFolder) {
|
if (exportFolder) {
|
||||||
await window.electronAPI.shell.openPath(exportFolder)
|
await window.electronAPI.shell.openPath(exportFolder)
|
||||||
@@ -249,7 +266,7 @@ function ExportPage() {
|
|||||||
} : null
|
} : null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt') {
|
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') {
|
||||||
const result = await window.electronAPI.export.exportSessions(
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
sessionList,
|
sessionList,
|
||||||
exportFolder,
|
exportFolder,
|
||||||
@@ -455,7 +472,7 @@ function ExportPage() {
|
|||||||
<div
|
<div
|
||||||
key={fmt.value}
|
key={fmt.value}
|
||||||
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
|
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
|
||||||
onClick={() => setOptions({ ...options, format: fmt.value as any })}
|
onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])}
|
||||||
>
|
>
|
||||||
<fmt.icon size={24} />
|
<fmt.icon size={24} />
|
||||||
<span className="format-label">{fmt.label}</span>
|
<span className="format-label">{fmt.label}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user