mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
Implement HTML chat export
This commit is contained in:
@@ -8,6 +8,7 @@ import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
import { chatService } from './chatService'
|
||||
import { videoService } from './videoService'
|
||||
|
||||
// ChatLab 格式类型定义
|
||||
interface ChatLabHeader {
|
||||
@@ -89,7 +90,8 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
||||
|
||||
interface MediaExportItem {
|
||||
relativePath: string
|
||||
kind: 'image' | 'voice' | 'emoji'
|
||||
kind: 'image' | 'voice' | 'emoji' | 'video'
|
||||
posterDataUrl?: string
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
@@ -451,6 +453,28 @@ class ExportService {
|
||||
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 />')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出媒体文件到指定目录
|
||||
*/
|
||||
@@ -459,7 +483,14 @@ class ExportService {
|
||||
sessionId: string,
|
||||
mediaRootDir: 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> {
|
||||
const localType = msg.localType
|
||||
|
||||
@@ -473,14 +504,13 @@ class ExportService {
|
||||
|
||||
// 语音消息
|
||||
if (localType === 34) {
|
||||
// 如果开启了语音转文字,优先转文字(不导出语音文件)
|
||||
if (options.exportVoiceAsText) {
|
||||
return null // 转文字逻辑在消息内容处理中完成
|
||||
}
|
||||
// 否则导出语音文件
|
||||
if (options.exportVoices) {
|
||||
const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText
|
||||
if (shouldKeepVoiceFile && options.exportVoices) {
|
||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
}
|
||||
if (options.exportVoiceAsText) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 动画表情
|
||||
@@ -491,6 +521,10 @@ class ExportService {
|
||||
return result
|
||||
}
|
||||
|
||||
if (localType === 43 && options.exportVideos) {
|
||||
return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -701,6 +735,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
|
||||
*/
|
||||
@@ -759,6 +834,16 @@ class ExportService {
|
||||
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 获取扩展名
|
||||
*/
|
||||
@@ -881,6 +966,7 @@ class ExportService {
|
||||
let imageDatName: string | undefined
|
||||
let emojiCdnUrl: string | undefined
|
||||
let emojiMd5: string | undefined
|
||||
let videoMd5: string | undefined
|
||||
|
||||
if (localType === 3 && content) {
|
||||
// 图片消息
|
||||
@@ -890,6 +976,9 @@ class ExportService {
|
||||
// 动画表情
|
||||
emojiCdnUrl = this.extractEmojiUrl(content)
|
||||
emojiMd5 = this.extractEmojiMd5(content)
|
||||
} else if (localType === 43 && content) {
|
||||
// 视频消息
|
||||
videoMd5 = this.extractVideoMd5(content)
|
||||
}
|
||||
|
||||
rows.push({
|
||||
@@ -902,7 +991,8 @@ class ExportService {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
emojiCdnUrl,
|
||||
emojiMd5
|
||||
emojiMd5,
|
||||
videoMd5
|
||||
})
|
||||
|
||||
if (firstTime === null || createTime < firstTime) firstTime = createTime
|
||||
@@ -2094,6 +2184,502 @@ 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.parseMessageContent(msg.content, msg.localType) || ''
|
||||
if (msg.localType === 34 && useVoiceTranscript) {
|
||||
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
}
|
||||
|
||||
let mediaHtml = ''
|
||||
if (mediaItem?.kind === 'image') {
|
||||
mediaHtml = `<img class="message-media image" src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}" alt="${this.escapeAttribute(typeName)}" />`
|
||||
} else if (mediaItem?.kind === 'emoji') {
|
||||
mediaHtml = `<img class="message-media emoji" src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}" 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.renderMultilineText(textContent)}</div>`
|
||||
: ''
|
||||
const senderHtml = isGroup
|
||||
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
|
||||
: ''
|
||||
const messageBody = `
|
||||
${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-time">${this.escapeHtml(timeText)}</div>
|
||||
<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 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 {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.message-media {
|
||||
border-radius: 14px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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> </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>
|
||||
<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 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)
|
||||
})
|
||||
|
||||
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 +2731,7 @@ class ExportService {
|
||||
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
||||
else if (options.format === 'excel') ext = '.xlsx'
|
||||
else if (options.format === 'txt') ext = '.txt'
|
||||
else if (options.format === 'html') ext = '.html'
|
||||
const outputPath = path.join(sessionDir, `${safeName}${ext}`)
|
||||
|
||||
let result: { success: boolean; error?: string }
|
||||
@@ -2156,6 +2743,8 @@ class ExportService {
|
||||
result = await this.exportSessionToExcel(sessionId, outputPath, options)
|
||||
} else if (options.format === 'txt') {
|
||||
result = await this.exportSessionToTxt(sessionId, outputPath, options)
|
||||
} else if (options.format === 'html') {
|
||||
result = await this.exportSessionToHtml(sessionId, outputPath, options)
|
||||
} else {
|
||||
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' })
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (exportFolder) {
|
||||
await window.electronAPI.shell.openPath(exportFolder)
|
||||
@@ -249,7 +266,7 @@ function ExportPage() {
|
||||
} : 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(
|
||||
sessionList,
|
||||
exportFolder,
|
||||
@@ -455,7 +472,7 @@ function ExportPage() {
|
||||
<div
|
||||
key={fmt.value}
|
||||
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} />
|
||||
<span className="format-label">{fmt.label}</span>
|
||||
|
||||
Reference in New Issue
Block a user