mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-15 19:09:24 +08:00
feat: 实现聊天记录合并功能
This commit is contained in:
@@ -8,6 +8,9 @@ import * as databaseCore from './database/core'
|
||||
import * as worker from './worker'
|
||||
// 导入解析器模块
|
||||
import * as parser from './parser'
|
||||
// 导入合并模块
|
||||
import * as merger from './merger'
|
||||
import type { MergeParams } from '../../src/types/chat'
|
||||
|
||||
console.log('[IpcMain] Database, Worker and Parser modules imported')
|
||||
|
||||
@@ -575,6 +578,58 @@ const mainIpcMain = (win: BrowserWindow) => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== 合并功能 ====================
|
||||
|
||||
/**
|
||||
* 解析文件获取基本信息(用于合并预览)
|
||||
* 使用 Worker 线程异步执行,不阻塞主进程
|
||||
*/
|
||||
ipcMain.handle('merge:parseFileInfo', async (_, filePath: string) => {
|
||||
try {
|
||||
// 使用 Worker 线程解析,避免阻塞 UI
|
||||
return await worker.parseFileInfo(filePath)
|
||||
} catch (error) {
|
||||
console.error('解析文件信息失败:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 检测合并冲突
|
||||
*/
|
||||
ipcMain.handle('merge:checkConflicts', async (_, filePaths: string[]) => {
|
||||
try {
|
||||
return merger.checkConflicts(filePaths)
|
||||
} catch (error) {
|
||||
console.error('检测冲突失败:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 执行合并
|
||||
*/
|
||||
ipcMain.handle('merge:mergeFiles', async (_, params: MergeParams) => {
|
||||
try {
|
||||
return merger.mergeFiles(params)
|
||||
} catch (error) {
|
||||
console.error('合并失败:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 显示打开对话框(通用)
|
||||
*/
|
||||
ipcMain.handle('dialog:showOpenDialog', async (_, options) => {
|
||||
try {
|
||||
return await dialog.showOpenDialog(options)
|
||||
} catch (error) {
|
||||
console.error('显示对话框失败:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default mainIpcMain
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* 聊天记录合并模块
|
||||
* 支持多个聊天记录文件合并为 ChatLab 专属格式
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { app } from 'electron'
|
||||
import { parseFile, detectFormat } from '../parser'
|
||||
import { importData } from '../database/core'
|
||||
import type {
|
||||
ParseResult,
|
||||
ParsedMessage,
|
||||
ChatLabFormat,
|
||||
ChatLabMember,
|
||||
ChatLabMessage,
|
||||
FileParseInfo,
|
||||
MergeConflict,
|
||||
ConflictCheckResult,
|
||||
ConflictResolution,
|
||||
MergeParams,
|
||||
MergeResult,
|
||||
ChatPlatform,
|
||||
ChatType,
|
||||
MergeSource,
|
||||
} from '../../../src/types/chat'
|
||||
|
||||
/**
|
||||
* 获取默认输出目录
|
||||
*/
|
||||
function getDefaultOutputDir(): string {
|
||||
try {
|
||||
const docPath = app.getPath('documents')
|
||||
return path.join(docPath, 'ChatLab', 'merged')
|
||||
} catch {
|
||||
return path.join(process.cwd(), 'merged')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保输出目录存在
|
||||
*/
|
||||
function ensureOutputDir(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成输出文件名
|
||||
*/
|
||||
function generateOutputFilename(name: string): string {
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||
const safeName = name.replace(/[/\\?%*:|"<>]/g, '_')
|
||||
return `${safeName}_merged_${date}.chatlab.json`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文件获取基本信息(用于预览)
|
||||
*/
|
||||
export function parseFileInfo(filePath: string): FileParseInfo {
|
||||
const format = detectFormat(filePath)
|
||||
if (!format) {
|
||||
throw new Error('无法识别文件格式')
|
||||
}
|
||||
|
||||
const result = parseFile(filePath)
|
||||
|
||||
return {
|
||||
name: result.meta.name,
|
||||
format,
|
||||
platform: result.meta.platform,
|
||||
messageCount: result.messages.length,
|
||||
memberCount: result.members.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成消息的唯一标识(用于去重和冲突检测)
|
||||
*/
|
||||
function getMessageKey(msg: ParsedMessage): string {
|
||||
return `${msg.timestamp}_${msg.senderPlatformId}_${(msg.content || '').length}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测合并冲突
|
||||
* 规则:时间戳 + 用户名 + 字符长度,当两项相同但另一项不同时报告冲突
|
||||
*/
|
||||
export function checkConflicts(filePaths: string[]): ConflictCheckResult {
|
||||
const allMessages: Array<{ msg: ParsedMessage; source: string }> = []
|
||||
const conflicts: MergeConflict[] = []
|
||||
|
||||
console.log('[Merger] checkConflicts: 开始检测冲突')
|
||||
console.log(
|
||||
'[Merger] 文件列表:',
|
||||
filePaths.map((p) => path.basename(p))
|
||||
)
|
||||
|
||||
// 先检查格式一致性
|
||||
const formats: string[] = []
|
||||
for (const filePath of filePaths) {
|
||||
const format = detectFormat(filePath)
|
||||
if (format) {
|
||||
formats.push(format)
|
||||
} else {
|
||||
throw new Error(`无法识别文件格式: ${path.basename(filePath)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否所有文件格式一致
|
||||
const uniqueFormats = [...new Set(formats)]
|
||||
if (uniqueFormats.length > 1) {
|
||||
throw new Error(
|
||||
`不支持合并不同格式的聊天记录。\n检测到的格式:${uniqueFormats.join('、')}\n请确保所有文件使用相同的导出工具和格式。`
|
||||
)
|
||||
}
|
||||
console.log('[Merger] 格式检查通过:', uniqueFormats[0])
|
||||
|
||||
// 解析所有文件
|
||||
for (const filePath of filePaths) {
|
||||
const result = parseFile(filePath)
|
||||
const sourceName = path.basename(filePath)
|
||||
console.log(`[Merger] 解析 ${sourceName}: ${result.messages.length} 条消息`)
|
||||
for (const msg of result.messages) {
|
||||
allMessages.push({ msg, source: sourceName })
|
||||
}
|
||||
}
|
||||
console.log(`[Merger] 总消息数: ${allMessages.length}`)
|
||||
|
||||
// 按时间戳分组检测冲突
|
||||
const timeGroups = new Map<number, Array<{ msg: ParsedMessage; source: string }>>()
|
||||
for (const item of allMessages) {
|
||||
const ts = item.msg.timestamp
|
||||
if (!timeGroups.has(ts)) {
|
||||
timeGroups.set(ts, [])
|
||||
}
|
||||
timeGroups.get(ts)!.push(item)
|
||||
}
|
||||
console.log(`[Merger] 唯一时间戳数: ${timeGroups.size}`)
|
||||
|
||||
// 统计有多条消息的时间戳
|
||||
let multiMsgTsCount = 0
|
||||
for (const [, items] of timeGroups) {
|
||||
if (items.length > 1) multiMsgTsCount++
|
||||
}
|
||||
console.log(`[Merger] 有多条消息的时间戳数: ${multiMsgTsCount}`)
|
||||
|
||||
// 检测每个时间戳内的冲突
|
||||
for (const [ts, items] of timeGroups) {
|
||||
if (items.length < 2) continue
|
||||
|
||||
// 按发送者分组
|
||||
const senderGroups = new Map<string, Array<{ msg: ParsedMessage; source: string }>>()
|
||||
for (const item of items) {
|
||||
const sender = item.msg.senderPlatformId
|
||||
if (!senderGroups.has(sender)) {
|
||||
senderGroups.set(sender, [])
|
||||
}
|
||||
senderGroups.get(sender)!.push(item)
|
||||
}
|
||||
|
||||
// 检测同一时间戳同一发送者的不同内容
|
||||
for (const [sender, senderItems] of senderGroups) {
|
||||
if (senderItems.length < 2) continue
|
||||
|
||||
// 检查是否来自不同文件
|
||||
const sources = new Set(senderItems.map((it) => it.source))
|
||||
if (sources.size < 2) {
|
||||
// 所有消息来自同一个文件,跳过(这是同一文件内同一秒内多条消息的情况)
|
||||
continue
|
||||
}
|
||||
|
||||
// 按内容长度分组
|
||||
const lengthGroups = new Map<number, Array<{ msg: ParsedMessage; source: string }>>()
|
||||
for (const item of senderItems) {
|
||||
const len = (item.msg.content || '').length
|
||||
if (!lengthGroups.has(len)) {
|
||||
lengthGroups.set(len, [])
|
||||
}
|
||||
lengthGroups.get(len)!.push(item)
|
||||
}
|
||||
|
||||
// 如果有多个不同长度的消息,说明可能是冲突
|
||||
if (lengthGroups.size > 1) {
|
||||
const lengthEntries = Array.from(lengthGroups.entries())
|
||||
for (let i = 0; i < lengthEntries.length - 1; i++) {
|
||||
for (let j = i + 1; j < lengthEntries.length; j++) {
|
||||
const [len1, items1] = lengthEntries[i]
|
||||
const [len2, items2] = lengthEntries[j]
|
||||
|
||||
// 找到两个来源不同的消息
|
||||
const item1 = items1[0]
|
||||
const item2 = items2.find((it) => it.source !== item1.source)
|
||||
|
||||
// 如果找不到来自不同文件的消息,跳过
|
||||
if (!item2) continue
|
||||
|
||||
// 打印冲突详情
|
||||
if (conflicts.length < 5) {
|
||||
console.log(`[Merger] 冲突 #${conflicts.length + 1}:`)
|
||||
console.log(` 时间戳: ${ts} (${new Date(ts * 1000).toLocaleString()})`)
|
||||
console.log(` 发送者: ${sender} (${item1.msg.senderName})`)
|
||||
console.log(
|
||||
` 文件1: ${item1.source}, 长度: ${len1}, 内容: "${(item1.msg.content || '').slice(0, 50)}..."`
|
||||
)
|
||||
console.log(
|
||||
` 文件2: ${item2.source}, 长度: ${len2}, 内容: "${(item2.msg.content || '').slice(0, 50)}..."`
|
||||
)
|
||||
}
|
||||
|
||||
conflicts.push({
|
||||
id: `conflict_${ts}_${sender}_${conflicts.length}`,
|
||||
timestamp: ts,
|
||||
sender: item1.msg.senderName || sender,
|
||||
contentLength1: len1,
|
||||
contentLength2: len2,
|
||||
content1: item1.msg.content || '',
|
||||
content2: item2.msg.content || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Merger] 检测到冲突数: ${conflicts.length}`)
|
||||
|
||||
// 计算去重后的消息数
|
||||
const uniqueKeys = new Set<string>()
|
||||
for (const item of allMessages) {
|
||||
uniqueKeys.add(getMessageKey(item.msg))
|
||||
}
|
||||
console.log(`[Merger] 去重后消息数: ${uniqueKeys.size}`)
|
||||
|
||||
return {
|
||||
conflicts,
|
||||
totalMessages: uniqueKeys.size,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个聊天记录文件
|
||||
*/
|
||||
export function mergeFiles(params: MergeParams): MergeResult {
|
||||
try {
|
||||
const { filePaths, outputName, outputDir, conflictResolutions, andAnalyze } = params
|
||||
|
||||
// 解析所有文件
|
||||
const parseResults: Array<{ result: ParseResult; source: string }> = []
|
||||
for (const filePath of filePaths) {
|
||||
const result = parseFile(filePath)
|
||||
parseResults.push({ result, source: path.basename(filePath) })
|
||||
}
|
||||
|
||||
// 合并成员
|
||||
const memberMap = new Map<string, ChatLabMember>()
|
||||
for (const { result } of parseResults) {
|
||||
for (const member of result.members) {
|
||||
const existing = memberMap.get(member.platformId)
|
||||
if (existing) {
|
||||
// 如果昵称不同,添加到 aliases
|
||||
if (existing.name !== member.name && !existing.aliases?.includes(member.name)) {
|
||||
existing.aliases = existing.aliases || []
|
||||
existing.aliases.push(member.name)
|
||||
}
|
||||
} else {
|
||||
memberMap.set(member.platformId, {
|
||||
platformId: member.platformId,
|
||||
name: member.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 合并消息(带冲突解决和去重)
|
||||
const resolutionMap = new Map(conflictResolutions.map((r) => [r.id, r.resolution]))
|
||||
const allMessages: Array<{ msg: ParsedMessage; source: string }> = []
|
||||
|
||||
for (const { result, source } of parseResults) {
|
||||
for (const msg of result.messages) {
|
||||
allMessages.push({ msg, source })
|
||||
}
|
||||
}
|
||||
|
||||
// 去重逻辑
|
||||
const messageMap = new Map<string, ChatLabMessage[]>()
|
||||
const processedConflicts = new Set<string>()
|
||||
|
||||
for (const { msg } of allMessages) {
|
||||
const key = getMessageKey(msg)
|
||||
|
||||
// 检查是否是冲突消息
|
||||
const conflictId = conflictResolutions.find((c) => {
|
||||
return c.id.includes(`${msg.timestamp}_${msg.senderPlatformId}`)
|
||||
})?.id
|
||||
|
||||
if (conflictId && !processedConflicts.has(conflictId)) {
|
||||
processedConflicts.add(conflictId)
|
||||
const resolution = resolutionMap.get(conflictId)
|
||||
|
||||
// 根据解决方案处理
|
||||
if (resolution === 'keepBoth') {
|
||||
// 保留两者:不去重
|
||||
} else if (resolution === 'keep1' || resolution === 'keep2') {
|
||||
// 保留其中一个:跳过另一个(简化处理,保留第一个遇到的)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加消息
|
||||
if (!messageMap.has(key)) {
|
||||
messageMap.set(key, [])
|
||||
}
|
||||
|
||||
const chatLabMsg: ChatLabMessage = {
|
||||
sender: msg.senderPlatformId,
|
||||
name: msg.senderName,
|
||||
timestamp: msg.timestamp,
|
||||
type: msg.type,
|
||||
content: msg.content,
|
||||
}
|
||||
|
||||
// 只添加一次(去重)
|
||||
const existing = messageMap.get(key)!
|
||||
if (existing.length === 0) {
|
||||
existing.push(chatLabMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 扁平化并排序
|
||||
const mergedMessages = Array.from(messageMap.values())
|
||||
.flat()
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
// 确定平台
|
||||
const platforms = new Set(parseResults.map((r) => r.result.meta.platform))
|
||||
const platform = platforms.size === 1 ? parseResults[0].result.meta.platform : 'mixed'
|
||||
|
||||
// 构建来源信息
|
||||
const sources: MergeSource[] = parseResults.map(({ result, source }) => ({
|
||||
filename: source,
|
||||
platform: result.meta.platform,
|
||||
messageCount: result.messages.length,
|
||||
}))
|
||||
|
||||
// 构建 ChatLab 格式
|
||||
const chatLabData: ChatLabFormat = {
|
||||
chatlab: {
|
||||
version: '1.0.0',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'ChatLab Merge Tool',
|
||||
},
|
||||
meta: {
|
||||
name: outputName,
|
||||
platform: platform as ChatPlatform,
|
||||
type: parseResults[0].result.meta.type as ChatType,
|
||||
sources,
|
||||
},
|
||||
members: Array.from(memberMap.values()),
|
||||
messages: mergedMessages,
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
const targetDir = outputDir || getDefaultOutputDir()
|
||||
ensureOutputDir(targetDir)
|
||||
const filename = generateOutputFilename(outputName)
|
||||
const outputPath = path.join(targetDir, filename)
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(chatLabData, null, 2), 'utf-8')
|
||||
|
||||
// 如果需要分析,导入数据库
|
||||
let sessionId: string | undefined
|
||||
if (andAnalyze) {
|
||||
// 将 ChatLab 格式转换为 ParseResult
|
||||
const parseResult: ParseResult = {
|
||||
meta: {
|
||||
name: chatLabData.meta.name,
|
||||
platform: chatLabData.meta.platform,
|
||||
type: chatLabData.meta.type,
|
||||
},
|
||||
members: chatLabData.members.map((m) => ({
|
||||
platformId: m.platformId,
|
||||
name: m.name,
|
||||
})),
|
||||
messages: chatLabData.messages.map((msg) => ({
|
||||
senderPlatformId: msg.sender,
|
||||
senderName: msg.name,
|
||||
timestamp: msg.timestamp,
|
||||
type: msg.type,
|
||||
content: msg.content,
|
||||
})),
|
||||
}
|
||||
sessionId = importData(parseResult)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
outputPath,
|
||||
sessionId,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : '合并失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* ChatLab 专属 JSON 格式解析器
|
||||
* 支持 ChatLab 工具导出的统一格式
|
||||
*/
|
||||
|
||||
import type { ChatParser } from './types'
|
||||
import {
|
||||
ChatPlatform,
|
||||
ChatType,
|
||||
type ParseResult,
|
||||
type ParsedMember,
|
||||
type ParsedMessage,
|
||||
type ChatLabFormat,
|
||||
} from '../../../src/types/chat'
|
||||
|
||||
/**
|
||||
* ChatLab JSON 格式解析器
|
||||
*/
|
||||
export const chatlabJsonParser: ChatParser = {
|
||||
name: 'ChatLab JSON',
|
||||
platform: 'chatlab',
|
||||
|
||||
detect(content: string, filename: string): boolean {
|
||||
// 检查文件扩展名
|
||||
if (!filename.toLowerCase().endsWith('.json') && !filename.toLowerCase().endsWith('.chatlab.json')) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(content)
|
||||
// 检查是否有 ChatLab 格式特征
|
||||
return (
|
||||
data.chatlab &&
|
||||
typeof data.chatlab.version === 'string' &&
|
||||
data.meta &&
|
||||
Array.isArray(data.members) &&
|
||||
Array.isArray(data.messages)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
parse(content: string, _filename: string): ParseResult {
|
||||
let data: ChatLabFormat
|
||||
try {
|
||||
data = JSON.parse(content)
|
||||
} catch (e) {
|
||||
throw new Error(`JSON 解析失败: ${e}`)
|
||||
}
|
||||
|
||||
if (!data.chatlab || !data.meta || !Array.isArray(data.messages)) {
|
||||
throw new Error('无效的 ChatLab JSON 格式')
|
||||
}
|
||||
|
||||
// 解析元信息
|
||||
const meta = {
|
||||
name: data.meta.name,
|
||||
platform: (data.meta.platform as ChatPlatform) || ChatPlatform.UNKNOWN,
|
||||
type: (data.meta.type as ChatType) || ChatType.GROUP,
|
||||
}
|
||||
|
||||
// 解析成员
|
||||
const members: ParsedMember[] = data.members.map((m) => ({
|
||||
platformId: m.platformId,
|
||||
name: m.name,
|
||||
}))
|
||||
|
||||
// 解析消息
|
||||
const messages: ParsedMessage[] = data.messages.map((msg) => ({
|
||||
senderPlatformId: msg.sender,
|
||||
senderName: msg.name,
|
||||
timestamp: msg.timestamp,
|
||||
type: msg.type,
|
||||
content: msg.content,
|
||||
}))
|
||||
|
||||
return {
|
||||
meta,
|
||||
members,
|
||||
messages,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -5,14 +5,16 @@
|
||||
|
||||
import * as fs from 'fs'
|
||||
import type { ChatParser } from './types'
|
||||
import { chatlabJsonParser } from './chatlabJsonParser'
|
||||
import { qqJsonParser } from './qqJsonParser'
|
||||
import { qqTxtParser } from './qqTxtParser'
|
||||
import type { ParseResult } from '../../../src/types/chat'
|
||||
|
||||
// 注册所有解析器(按优先级排序)
|
||||
const parsers: ChatParser[] = [
|
||||
qqJsonParser, // JSON 格式优先
|
||||
qqTxtParser // TXT 格式兜底
|
||||
chatlabJsonParser, // ChatLab 格式最优先
|
||||
qqJsonParser, // QQ JSON 格式
|
||||
qqTxtParser, // TXT 格式兜底
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -64,10 +66,9 @@ export function detectFormat(filePath: string): string | null {
|
||||
export function getSupportedFormats(): Array<{ name: string; platform: string }> {
|
||||
return parsers.map((p) => ({
|
||||
name: p.name,
|
||||
platform: p.platform
|
||||
platform: p.platform,
|
||||
}))
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type { ChatParser, ParseError } from './types'
|
||||
|
||||
|
||||
@@ -36,6 +36,28 @@ import {
|
||||
getMemeBattleAnalysis,
|
||||
getCheckInAnalysis,
|
||||
} from './queryAdvanced'
|
||||
import { parseFile, detectFormat } from '../parser'
|
||||
import type { FileParseInfo } from '../../../src/types/chat'
|
||||
|
||||
/**
|
||||
* 解析文件获取基本信息(在 Worker 线程中执行,不阻塞主进程)
|
||||
*/
|
||||
function parseFileInfo(filePath: string): FileParseInfo {
|
||||
const format = detectFormat(filePath)
|
||||
if (!format) {
|
||||
throw new Error('无法识别文件格式')
|
||||
}
|
||||
|
||||
const result = parseFile(filePath)
|
||||
|
||||
return {
|
||||
name: result.meta.name,
|
||||
format,
|
||||
platform: result.meta.platform,
|
||||
messageCount: result.messages.length,
|
||||
memberCount: result.members.length,
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据库目录
|
||||
initDbDir(workerData.dbDir)
|
||||
@@ -50,6 +72,9 @@ interface WorkerMessage {
|
||||
|
||||
// 消息类型到处理函数的映射
|
||||
const handlers: Record<string, (payload: any) => any> = {
|
||||
// 文件解析(合并功能使用)
|
||||
parseFileInfo: (p) => parseFileInfo(p.filePath),
|
||||
|
||||
// 基础查询
|
||||
getAvailableYears: (p) => getAvailableYears(p.sessionId),
|
||||
getMemberActivity: (p) => getMemberActivity(p.sessionId, p.filter),
|
||||
|
||||
@@ -31,4 +31,6 @@ export {
|
||||
getAllSessions,
|
||||
getSession,
|
||||
closeDatabase,
|
||||
// 文件解析 API(异步,用于合并功能)
|
||||
parseFileInfo,
|
||||
} from './workerManager'
|
||||
|
||||
@@ -259,6 +259,13 @@ export async function closeDatabase(sessionId: string): Promise<void> {
|
||||
return sendToWorker('closeDatabase', { sessionId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文件获取基本信息(在 Worker 线程中执行)
|
||||
*/
|
||||
export async function parseFileInfo(filePath: string): Promise<any> {
|
||||
return sendToWorker('parseFileInfo', { filePath })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库目录(供外部使用)
|
||||
*/
|
||||
|
||||
Vendored
+15
-1
@@ -19,6 +19,10 @@ import type {
|
||||
LaughAnalysis,
|
||||
MemeBattleAnalysis,
|
||||
CheckInAnalysis,
|
||||
FileParseInfo,
|
||||
ConflictCheckResult,
|
||||
MergeParams,
|
||||
MergeResult,
|
||||
} from '../../src/types/chat'
|
||||
|
||||
interface TimeFilter {
|
||||
@@ -63,6 +67,15 @@ interface Api {
|
||||
send: (channel: string, data?: unknown) => void
|
||||
receive: (channel: string, func: (...args: unknown[]) => void) => void
|
||||
removeListener: (channel: string, func: (...args: unknown[]) => void) => void
|
||||
dialog: {
|
||||
showOpenDialog: (options: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
|
||||
}
|
||||
}
|
||||
|
||||
interface MergeApi {
|
||||
parseFileInfo: (filePath: string) => Promise<FileParseInfo>
|
||||
checkConflicts: (filePaths: string[]) => Promise<ConflictCheckResult>
|
||||
mergeFiles: (params: MergeParams) => Promise<MergeResult>
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -70,7 +83,8 @@ declare global {
|
||||
electron: ElectronAPI
|
||||
api: Api
|
||||
chatApi: ChatApi
|
||||
mergeApi: MergeApi
|
||||
}
|
||||
}
|
||||
|
||||
export { ChatApi, Api }
|
||||
export { ChatApi, Api, MergeApi }
|
||||
|
||||
@@ -20,6 +20,10 @@ import type {
|
||||
LaughAnalysis,
|
||||
CheckInAnalysis,
|
||||
MemeBattleAnalysis,
|
||||
FileParseInfo,
|
||||
ConflictCheckResult,
|
||||
MergeParams,
|
||||
MergeResult,
|
||||
} from '../../src/types/chat'
|
||||
|
||||
// Custom APIs for renderer
|
||||
@@ -275,14 +279,49 @@ const chatApi = {
|
||||
},
|
||||
}
|
||||
|
||||
// Merge API - 合并功能
|
||||
const mergeApi = {
|
||||
/**
|
||||
* 解析文件获取基本信息(用于合并预览)
|
||||
*/
|
||||
parseFileInfo: (filePath: string): Promise<FileParseInfo> => {
|
||||
return ipcRenderer.invoke('merge:parseFileInfo', filePath)
|
||||
},
|
||||
|
||||
/**
|
||||
* 检测合并冲突
|
||||
*/
|
||||
checkConflicts: (filePaths: string[]): Promise<ConflictCheckResult> => {
|
||||
return ipcRenderer.invoke('merge:checkConflicts', filePaths)
|
||||
},
|
||||
|
||||
/**
|
||||
* 执行合并
|
||||
*/
|
||||
mergeFiles: (params: MergeParams): Promise<MergeResult> => {
|
||||
return ipcRenderer.invoke('merge:mergeFiles', params)
|
||||
},
|
||||
}
|
||||
|
||||
// 扩展 api,添加 dialog 功能
|
||||
const extendedApi = {
|
||||
...api,
|
||||
dialog: {
|
||||
showOpenDialog: (options: Electron.OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> => {
|
||||
return ipcRenderer.invoke('dialog:showOpenDialog', options)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
contextBridge.exposeInMainWorld('api', extendedApi)
|
||||
contextBridge.exposeInMainWorld('chatApi', chatApi)
|
||||
contextBridge.exposeInMainWorld('mergeApi', mergeApi)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -290,7 +329,9 @@ if (process.contextIsolated) {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
window.api = extendedApi
|
||||
// @ts-ignore (define in dts)
|
||||
window.chatApi = chatApi
|
||||
// @ts-ignore (define in dts)
|
||||
window.mergeApi = mergeApi
|
||||
}
|
||||
|
||||
Vendored
-1
@@ -20,7 +20,6 @@ declare module 'vue' {
|
||||
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
|
||||
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
|
||||
UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
|
||||
UPageAnchors: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/PageAnchors.vue')['default']
|
||||
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
|
||||
UProgress: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Progress.vue')['default']
|
||||
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* FileDropZone - 纯行为的文件拖拽/选择组件
|
||||
* 不提供默认样式,通过插槽完全自定义 UI
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
/** 是否支持多文件选择 */
|
||||
multiple?: boolean
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 接受的文件扩展名,如 ['.json', '.txt'] */
|
||||
accept?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
accept: () => ['*'],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 选择文件后触发,返回文件列表和路径列表 */
|
||||
files: [payload: { files: File[]; paths: string[] }]
|
||||
}>()
|
||||
|
||||
// 拖拽状态
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// 隐藏的文件输入框引用
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 计算 accept 属性值
|
||||
const acceptAttr = computed(() => {
|
||||
if (props.accept.includes('*')) return '*'
|
||||
return props.accept.join(',')
|
||||
})
|
||||
|
||||
// 打开文件选择对话框
|
||||
function openFileDialog() {
|
||||
if (props.disabled) return
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.files || input.files.length === 0) return
|
||||
|
||||
processFiles(Array.from(input.files))
|
||||
|
||||
// 清空 input 以便再次选择同一文件
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// 处理拖拽进入
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (props.disabled) return
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
// 处理拖拽悬停
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (props.disabled) return
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
// 处理拖拽离开
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
// 处理拖拽放下
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isDragOver.value = false
|
||||
|
||||
if (props.disabled) return
|
||||
|
||||
const dataTransfer = e.dataTransfer
|
||||
if (!dataTransfer?.files || dataTransfer.files.length === 0) return
|
||||
|
||||
let files = Array.from(dataTransfer.files)
|
||||
|
||||
// 如果不支持多选,只取第一个
|
||||
if (!props.multiple) {
|
||||
files = [files[0]]
|
||||
}
|
||||
|
||||
// 过滤文件类型
|
||||
if (!props.accept.includes('*')) {
|
||||
files = files.filter((file) => {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||
return props.accept.some((a) => a.toLowerCase() === ext)
|
||||
})
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
processFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件并发送事件
|
||||
function processFiles(files: File[]) {
|
||||
const paths: string[] = []
|
||||
|
||||
// 尝试获取文件路径(Electron 环境)
|
||||
for (const file of files) {
|
||||
try {
|
||||
// @ts-ignore - Electron webUtils
|
||||
const path = window.electron?.webUtils?.getPathForFile?.(file)
|
||||
if (path) {
|
||||
paths.push(path)
|
||||
}
|
||||
} catch {
|
||||
// 非 Electron 环境或获取失败
|
||||
}
|
||||
}
|
||||
|
||||
emit('files', { files, paths })
|
||||
}
|
||||
|
||||
// 暴露给插槽的属性
|
||||
defineExpose({
|
||||
openFileDialog,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div @dragenter="handleDragEnter" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop">
|
||||
<!-- 隐藏的文件输入框 -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
:multiple="multiple"
|
||||
:accept="acceptAttr"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 插槽内容 -->
|
||||
<slot :is-drag-over="isDragOver" :open-file-dialog="openFileDialog" :disabled="disabled" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,3 +7,4 @@ export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as LoadingState } from './LoadingState.vue'
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as PageAnchorsNav } from './PageAnchorsNav.vue'
|
||||
export { default as FileDropZone } from './FileDropZone.vue'
|
||||
|
||||
@@ -84,6 +84,26 @@ function cancelDelete() {
|
||||
<span v-if="!isCollapsed" class="truncate">分析新聊天</span>
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
|
||||
<!-- Tools Button -->
|
||||
<UTooltip :text="isCollapsed ? '工具广场' : ''" :popper="{ placement: 'right' }">
|
||||
<UButton
|
||||
:block="!isCollapsed"
|
||||
class="transition-all rounded-full hover:bg-gray-200/60 dark:hover:bg-gray-800 h-12 cursor-pointer mt-2"
|
||||
:class="[
|
||||
isCollapsed ? 'flex w-12 items-center justify-center px-0' : 'justify-start pl-4',
|
||||
route.name === 'tools'
|
||||
? 'bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: '',
|
||||
]"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
@click="router.push({ name: 'tools' })"
|
||||
>
|
||||
<UIcon name="i-heroicons-wrench-screwdriver" class="h-5 w-5 shrink-0" :class="[isCollapsed ? '' : 'mr-2']" />
|
||||
<span v-if="!isCollapsed" class="truncate">工具广场</span>
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
<!-- Session List -->
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { FileDropZone } from '@/components/UI'
|
||||
|
||||
interface FileInfo {
|
||||
id: string
|
||||
path: string
|
||||
name: string
|
||||
format: string
|
||||
messageCount: number
|
||||
status: 'pending' | 'parsed' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface MergeConflict {
|
||||
id: string
|
||||
timestamp: number
|
||||
sender: string
|
||||
contentLength1: number
|
||||
contentLength2: number
|
||||
content1: string
|
||||
content2: string
|
||||
source1?: string
|
||||
source2?: string
|
||||
resolution?: 'keep1' | 'keep2' | 'keepBoth'
|
||||
}
|
||||
|
||||
const files = ref<FileInfo[]>([])
|
||||
const conflicts = ref<MergeConflict[]>([])
|
||||
const outputName = ref('')
|
||||
const outputDir = ref('')
|
||||
const isLoading = ref(false)
|
||||
const isMerging = ref(false)
|
||||
const mergeProgress = ref(0)
|
||||
const currentStep = ref<'select' | 'conflict' | 'done'>('select')
|
||||
const outputFilePath = ref('')
|
||||
|
||||
// 计算总消息数
|
||||
const totalMessages = computed(() => files.value.reduce((sum, f) => sum + (f.messageCount || 0), 0))
|
||||
|
||||
// 是否可以合并
|
||||
const canMerge = computed(() => files.value.length >= 2 && files.value.every((f) => f.status === 'parsed'))
|
||||
|
||||
// 添加文件(通过路径列表)
|
||||
async function addFilesByPaths(filePaths: string[]) {
|
||||
for (const filePath of filePaths) {
|
||||
// 检查是否已添加
|
||||
if (files.value.some((f) => f.path === filePath)) continue
|
||||
|
||||
const fileName = filePath.split(/[/\\]/).pop() || ''
|
||||
const fileId = `file_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
files.value.push({
|
||||
id: fileId,
|
||||
path: filePath,
|
||||
name: fileName,
|
||||
format: '检测中...',
|
||||
messageCount: 0,
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
// 解析文件获取信息
|
||||
try {
|
||||
const info = await window.mergeApi.parseFileInfo(filePath)
|
||||
const file = files.value.find((f) => f.id === fileId)
|
||||
if (file) {
|
||||
file.format = info.format
|
||||
file.messageCount = info.messageCount
|
||||
file.status = 'parsed'
|
||||
|
||||
// 设置默认输出名称(取第一个文件的群名)
|
||||
if (!outputName.value && info.name) {
|
||||
outputName.value = info.name
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const file = files.value.find((f) => f.id === fileId)
|
||||
if (file) {
|
||||
file.status = 'error'
|
||||
file.error = err instanceof Error ? err.message : '解析失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理拖拽上传
|
||||
async function handleFileDrop({ paths }: { files: File[]; paths: string[] }) {
|
||||
if (paths.length === 0) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
await addFilesByPaths(paths)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 点击选择文件
|
||||
async function handleClickSelect() {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const result = await window.api.dialog.showOpenDialog({
|
||||
title: '选择聊天记录文件',
|
||||
filters: [
|
||||
{ name: '聊天记录', extensions: ['json', 'txt'] },
|
||||
{ name: '所有文件', extensions: ['*'] },
|
||||
],
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) return
|
||||
|
||||
await addFilesByPaths(result.filePaths)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function removeFile(id: string) {
|
||||
files.value = files.value.filter((f) => f.id !== id)
|
||||
}
|
||||
|
||||
// 选择输出目录
|
||||
async function selectOutputDir() {
|
||||
const result = await window.api.dialog.showOpenDialog({
|
||||
title: '选择输出目录',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
})
|
||||
if (!result.canceled && result.filePaths.length) {
|
||||
outputDir.value = result.filePaths[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 错误弹窗状态
|
||||
const showErrorModal = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 执行合并
|
||||
async function doMerge() {
|
||||
if (!canMerge.value) return
|
||||
|
||||
try {
|
||||
isMerging.value = true
|
||||
mergeProgress.value = 0
|
||||
|
||||
const filePaths = files.value.map((f) => f.path)
|
||||
|
||||
// 检测冲突
|
||||
mergeProgress.value = 10
|
||||
const checkResult = await window.mergeApi.checkConflicts(filePaths)
|
||||
|
||||
if (checkResult.conflicts.length > 0) {
|
||||
conflicts.value = checkResult.conflicts
|
||||
currentStep.value = 'conflict'
|
||||
isMerging.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 无冲突,直接合并
|
||||
await executeMerge()
|
||||
} catch (err) {
|
||||
console.error('Merge failed:', err)
|
||||
errorMessage.value = err instanceof Error ? err.message : '未知错误'
|
||||
showErrorModal.value = true
|
||||
} finally {
|
||||
isMerging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 解决冲突后继续合并
|
||||
async function resolveConflictsAndMerge() {
|
||||
// 检查是否所有冲突都已解决
|
||||
if (conflicts.value.some((c) => !c.resolution)) {
|
||||
alert('请先解决所有冲突')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isMerging.value = true
|
||||
await executeMerge()
|
||||
} finally {
|
||||
isMerging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 执行合并操作
|
||||
async function executeMerge() {
|
||||
mergeProgress.value = 30
|
||||
|
||||
const filePaths = files.value.map((f) => f.path)
|
||||
const resolutions = conflicts.value.map((c) => ({
|
||||
id: c.id,
|
||||
resolution: c.resolution || 'keep1',
|
||||
}))
|
||||
|
||||
mergeProgress.value = 50
|
||||
|
||||
const result = await window.mergeApi.mergeFiles({
|
||||
filePaths,
|
||||
outputName: outputName.value || '合并的聊天记录',
|
||||
outputDir: outputDir.value || undefined,
|
||||
conflictResolutions: resolutions,
|
||||
andAnalyze: false,
|
||||
})
|
||||
|
||||
mergeProgress.value = 100
|
||||
|
||||
if (result.success) {
|
||||
outputFilePath.value = result.outputPath || ''
|
||||
currentStep.value = 'done'
|
||||
} else {
|
||||
throw new Error(result.error || '合并失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开输出文件夹
|
||||
async function openOutputFolder() {
|
||||
if (outputFilePath.value) {
|
||||
// 使用 electron 的 shell.showItemInFolder
|
||||
await window.electron.ipcRenderer.invoke('openInFolder', outputFilePath.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
function reset() {
|
||||
files.value = []
|
||||
conflicts.value = []
|
||||
outputName.value = ''
|
||||
outputDir.value = ''
|
||||
currentStep.value = 'select'
|
||||
mergeProgress.value = 0
|
||||
}
|
||||
|
||||
// 获取格式图标
|
||||
function getFormatIcon(format: string): string {
|
||||
if (format.includes('JSON')) return 'i-heroicons-code-bracket'
|
||||
if (format.includes('TXT')) return 'i-heroicons-document-text'
|
||||
if (format.includes('ChatLab')) return 'i-heroicons-sparkles'
|
||||
return 'i-heroicons-document'
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
function getStatusColor(status: FileInfo['status']): string {
|
||||
switch (status) {
|
||||
case 'parsed':
|
||||
return 'text-green-500'
|
||||
case 'error':
|
||||
return 'text-red-500'
|
||||
default:
|
||||
return 'text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
// 计算已解决的冲突数
|
||||
const resolvedCount = computed(() => conflicts.value.filter((c) => c.resolution).length)
|
||||
|
||||
// 批量选择所有冲突
|
||||
function batchSelectAll(resolution: 'keep1' | 'keep2' | 'keepBoth') {
|
||||
for (const conflict of conflicts.value) {
|
||||
conflict.resolution = resolution
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件1和文件2的名称
|
||||
const file1Name = computed(() => files.value[0]?.name || '文件 1')
|
||||
const file2Name = computed(() => files.value[1]?.name || '文件 2')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<!-- 选择文件阶段 -->
|
||||
<template v-if="currentStep === 'select'">
|
||||
<div class="rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<!-- 标题 -->
|
||||
<div class="border-b border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">合并聊天记录</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
将多个聊天记录文件合并为一个,支持 QQ JSON、TXT 和 ChatLab 格式
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="p-5">
|
||||
<!-- 已添加的文件列表 -->
|
||||
<div v-if="files.length > 0" class="mb-4 space-y-2">
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 px-4 py-3 dark:border-gray-700"
|
||||
>
|
||||
<!-- 格式图标 -->
|
||||
<UIcon :name="getFormatIcon(file.format)" class="h-5 w-5 shrink-0 text-gray-400" />
|
||||
|
||||
<!-- 文件信息 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-gray-900 dark:text-white">{{ file.name }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span :class="getStatusColor(file.status)">
|
||||
{{ file.status === 'pending' ? '解析中...' : file.status === 'error' ? file.error : file.format }}
|
||||
</span>
|
||||
<template v-if="file.status === 'parsed'">· {{ file.messageCount.toLocaleString() }} 条消息</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
@click="removeFile(file.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽上传区域 -->
|
||||
<FileDropZone multiple :accept="['.json', '.txt']" :disabled="isLoading" @files="handleFileDrop">
|
||||
<template #default="{ isDragOver, openFileDialog }">
|
||||
<div
|
||||
class="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 px-6 py-6 transition-all hover:border-primary-400 hover:bg-primary-50/50 dark:border-gray-600 dark:hover:border-primary-500 dark:hover:bg-primary-900/10"
|
||||
:class="{
|
||||
'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20': isDragOver,
|
||||
'opacity-60': isLoading,
|
||||
}"
|
||||
@click="!isLoading && handleClickSelect()"
|
||||
>
|
||||
<UIcon
|
||||
name="i-heroicons-arrow-up-tray"
|
||||
class="h-8 w-8 text-gray-400 transition-colors"
|
||||
:class="{ 'text-primary-500': isDragOver }"
|
||||
/>
|
||||
<p class="mt-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ isDragOver ? '松开以添加文件' : '拖拽文件到这里,或点击选择' }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">支持 .json 和 .txt 格式</p>
|
||||
</div>
|
||||
</template>
|
||||
</FileDropZone>
|
||||
</div>
|
||||
|
||||
<!-- 输出设置 -->
|
||||
<div v-if="files.length > 0" class="border-t border-gray-200 p-5 dark:border-gray-800">
|
||||
<h3 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">输出设置</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- 名称 -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">群聊名称</label>
|
||||
<UInput v-model="outputName" placeholder="合并的聊天记录" />
|
||||
</div>
|
||||
|
||||
<!-- 输出目录 -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">输出目录(可选)</label>
|
||||
<div class="flex gap-2">
|
||||
<UInput v-model="outputDir" placeholder="默认保存到文档/ChatLab/merged/" class="flex-1" readonly />
|
||||
<UButton icon="i-heroicons-folder" color="gray" variant="soft" @click="selectOutputDir">选择</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div
|
||||
v-if="files.length >= 2"
|
||||
class="border-t border-gray-200 bg-gray-50 px-5 py-3 dark:border-gray-800 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
共 {{ files.length }} 个文件,约 {{ totalMessages.toLocaleString() }} 条消息
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-end gap-3 border-t border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||
<UButton :disabled="!canMerge || isMerging" :loading="isMerging" color="primary" @click="doMerge">
|
||||
合并并导出
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 冲突解决阶段 -->
|
||||
<template v-else-if="currentStep === 'conflict'">
|
||||
<div class="rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<!-- 头部 -->
|
||||
<div class="border-b border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-5 w-5 text-amber-500" />
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">发现 {{ conflicts.length }} 个格式差异</h2>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
同一条消息在不同文件中的格式可能有差异,请选择保留哪个版本
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
已选择
|
||||
<span class="font-medium text-primary-600">{{ resolvedCount }}</span>
|
||||
/ {{ conflicts.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作区 -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 border-b border-gray-200 bg-gray-50 px-5 py-3 dark:border-gray-800 dark:bg-gray-800/50"
|
||||
>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">一键选择:</span>
|
||||
<UButton size="xs" color="gray" variant="soft" @click="batchSelectAll('keep1')">
|
||||
全部保留「{{ file1Name }}」
|
||||
</UButton>
|
||||
<UButton size="xs" color="gray" variant="soft" @click="batchSelectAll('keep2')">
|
||||
全部保留「{{ file2Name }}」
|
||||
</UButton>
|
||||
<UButton size="xs" color="gray" variant="soft" @click="batchSelectAll('keepBoth')">全部保留两者</UButton>
|
||||
</div>
|
||||
|
||||
<!-- 冲突列表 -->
|
||||
<div class="max-h-[400px] divide-y divide-gray-200 overflow-y-auto dark:divide-gray-800">
|
||||
<div v-for="(conflict, index) in conflicts" :key="conflict.id" class="p-4">
|
||||
<!-- 冲突信息 -->
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-medium dark:bg-gray-700"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ conflict.sender }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ new Date(conflict.timestamp * 1000).toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 文件1内容 -->
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border-2 p-3 transition-colors"
|
||||
:class="[
|
||||
conflict.resolution === 'keep1'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600',
|
||||
]"
|
||||
@click="conflict.resolution = 'keep1'"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-blue-600 dark:text-blue-400">{{ file1Name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ conflict.contentLength1 }} 字</span>
|
||||
</div>
|
||||
<p class="line-clamp-3 break-all text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ conflict.content1 || '(空)' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 文件2内容 -->
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border-2 p-3 transition-colors"
|
||||
:class="[
|
||||
conflict.resolution === 'keep2'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600',
|
||||
]"
|
||||
@click="conflict.resolution = 'keep2'"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-green-600 dark:text-green-400">{{ file2Name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ conflict.contentLength2 }} 字</span>
|
||||
</div>
|
||||
<p class="line-clamp-3 break-all text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ conflict.content2 || '(空)' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保留两者选项 -->
|
||||
<div
|
||||
class="mt-2 cursor-pointer rounded-lg border-2 p-2 text-center transition-colors"
|
||||
:class="[
|
||||
conflict.resolution === 'keepBoth'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600',
|
||||
]"
|
||||
@click="conflict.resolution = 'keepBoth'"
|
||||
>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">保留两者(作为两条独立消息)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="flex items-center justify-between border-t border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||
<UButton color="gray" variant="ghost" @click="currentStep = 'select'">
|
||||
<UIcon name="i-heroicons-arrow-left" class="mr-1 h-4 w-4" />
|
||||
返回
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
:disabled="resolvedCount < conflicts.length"
|
||||
:loading="isMerging"
|
||||
@click="resolveConflictsAndMerge"
|
||||
>
|
||||
合并并导出
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 完成阶段 -->
|
||||
<template v-else-if="currentStep === 'done'">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
|
||||
>
|
||||
<UIcon name="i-heroicons-check" class="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">合并完成!</h2>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">聊天记录已成功合并并导出</p>
|
||||
<div class="mt-6 flex justify-center gap-3">
|
||||
<UButton color="primary" @click="openOutputFolder">
|
||||
<UIcon name="i-heroicons-folder-open" class="mr-1 h-4 w-4" />
|
||||
打开文件夹
|
||||
</UButton>
|
||||
<UButton color="gray" @click="reset">继续合并</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 错误弹窗 -->
|
||||
<UModal v-model:open="showErrorModal">
|
||||
<template #content>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">合并失败</h3>
|
||||
<p class="mt-2 whitespace-pre-line text-sm text-gray-600 dark:text-gray-400">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<UButton color="gray" @click="showErrorModal = false">我知道了</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
+64
-86
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { FileDropZone } from '@/components/UI'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -8,7 +9,6 @@ const chatStore = useChatStore()
|
||||
const { isImporting, importProgress } = storeToRefs(chatStore)
|
||||
|
||||
const importError = ref<string | null>(null)
|
||||
const isDragOver = ref(false)
|
||||
|
||||
const features = [
|
||||
{
|
||||
@@ -39,7 +39,8 @@ const features = [
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
async function handleImport() {
|
||||
// 处理文件选择(点击选择)
|
||||
async function handleClickImport() {
|
||||
importError.value = null
|
||||
const result = await chatStore.importFile()
|
||||
if (!result.success && result.error && result.error !== '未选择文件') {
|
||||
@@ -49,48 +50,15 @@ async function handleImport() {
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽事件处理
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isDragOver.value = false
|
||||
|
||||
if (isImporting.value) return
|
||||
|
||||
const files = e.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const file = files[0]
|
||||
|
||||
// 使用 Electron 的 webUtils 获取文件真实路径
|
||||
let filePath: string
|
||||
try {
|
||||
filePath = window.electron.webUtils.getPathForFile(file)
|
||||
} catch (error) {
|
||||
console.error('获取文件路径失败:', error)
|
||||
importError.value = '无法读取文件路径'
|
||||
return
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
// 处理文件拖拽
|
||||
async function handleFileDrop({ paths }: { files: File[]; paths: string[] }) {
|
||||
if (paths.length === 0) {
|
||||
importError.value = '无法读取文件路径'
|
||||
return
|
||||
}
|
||||
|
||||
importError.value = null
|
||||
const result = await chatStore.importFileFromPath(filePath)
|
||||
const result = await chatStore.importFileFromPath(paths[0])
|
||||
if (!result.success && result.error) {
|
||||
importError.value = result.error
|
||||
} else if (result.success && chatStore.currentSessionId) {
|
||||
@@ -133,7 +101,7 @@ function getProgressText(): string {
|
||||
>
|
||||
ChatLab
|
||||
</h1>
|
||||
<p class="text-lg font-medium text-gray-500 dark:text-gray-400">你的AI聊天分析实验室</p>
|
||||
<p class="text-lg font-medium text-gray-500 dark:text-gray-400">你的本地聊天分析实验室</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature Cards -->
|
||||
@@ -155,54 +123,64 @@ function getProgressText(): string {
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col items-center space-y-6">
|
||||
<!-- Import Drop Zone -->
|
||||
<div
|
||||
class="group relative flex w-full max-w-2xl cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-pink-300 bg-white px-12 py-12 transition-all duration-300 hover:border-pink-400 hover:bg-pink-50/50 focus:outline-none focus:ring-4 focus:ring-pink-500/20 dark:border-pink-700 dark:bg-gray-900 dark:hover:border-pink-500 dark:hover:bg-pink-900/10"
|
||||
:class="{
|
||||
'border-pink-500 bg-pink-50 dark:border-pink-400 dark:bg-pink-900/20': isDragOver && !isImporting,
|
||||
'cursor-not-allowed opacity-70': isImporting,
|
||||
'hover:scale-[1.02] hover:shadow-xl hover:shadow-pink-500/10': !isImporting,
|
||||
}"
|
||||
@click="!isImporting && handleImport()"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
<FileDropZone
|
||||
:accept="['.json', '.txt']"
|
||||
:disabled="isImporting"
|
||||
class="w-full max-w-2xl"
|
||||
@files="handleFileDrop"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-linear-to-br from-pink-100 to-rose-100 transition-transform duration-300 dark:from-pink-900/30 dark:to-rose-900/30"
|
||||
:class="{ 'scale-110': isDragOver && !isImporting, 'animate-pulse': isImporting }"
|
||||
>
|
||||
<UIcon
|
||||
v-if="!isImporting"
|
||||
name="i-heroicons-arrow-up-tray"
|
||||
class="h-8 w-8 text-pink-600 transition-transform group-hover:-translate-y-1 dark:text-pink-400"
|
||||
/>
|
||||
<UIcon v-else name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-600 dark:text-pink-400" />
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="w-full text-center">
|
||||
<template v-if="isImporting && importProgress">
|
||||
<!-- 导入中显示进度 -->
|
||||
<p class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ getProgressText() }}</p>
|
||||
<div class="mx-auto w-full max-w-md">
|
||||
<UProgress v-model="importProgress.progress" size="md" />
|
||||
<template #default="{ isDragOver, openFileDialog }">
|
||||
<div
|
||||
class="group relative flex w-full cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-pink-300 bg-white px-12 py-12 transition-all duration-300 hover:border-pink-400 hover:bg-pink-50/50 focus:outline-none focus:ring-4 focus:ring-pink-500/20 dark:border-pink-700 dark:bg-gray-900 dark:hover:border-pink-500 dark:hover:bg-pink-900/10"
|
||||
:class="{
|
||||
'border-pink-500 bg-pink-50 dark:border-pink-400 dark:bg-pink-900/20': isDragOver && !isImporting,
|
||||
'cursor-not-allowed opacity-70': isImporting,
|
||||
'hover:scale-[1.02] hover:shadow-xl hover:shadow-pink-500/10': !isImporting,
|
||||
}"
|
||||
@click="!isImporting && handleClickImport()"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-linear-to-br from-pink-100 to-rose-100 transition-transform duration-300 dark:from-pink-900/30 dark:to-rose-900/30"
|
||||
:class="{ 'scale-110': isDragOver && !isImporting, 'animate-pulse': isImporting }"
|
||||
>
|
||||
<UIcon
|
||||
v-if="!isImporting"
|
||||
name="i-heroicons-arrow-up-tray"
|
||||
class="h-8 w-8 text-pink-600 transition-transform group-hover:-translate-y-1 dark:text-pink-400"
|
||||
/>
|
||||
<UIcon
|
||||
v-else
|
||||
name="i-heroicons-arrow-path"
|
||||
class="h-8 w-8 animate-spin text-pink-600 dark:text-pink-400"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ importProgress.message }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 默认状态 -->
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ isDragOver ? '松开鼠标导入文件' : '点击选择或拖拽文件到这里' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
支持 QQ、微信、Discord、Snapchat、Reddit、TikTok 等聊天记录(JSON/TXT 格式)
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="w-full text-center">
|
||||
<template v-if="isImporting && importProgress">
|
||||
<!-- 导入中显示进度 -->
|
||||
<p class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ getProgressText() }}</p>
|
||||
<div class="mx-auto w-full max-w-md">
|
||||
<UProgress v-model="importProgress.progress" size="md" />
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ importProgress.message }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 默认状态 -->
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ isDragOver ? '松开鼠标导入文件' : '点击选择或拖拽文件到这里' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
支持 QQ、微信、Discord、Snapchat、Reddit、TikTok 等聊天记录(JSON/TXT 格式)
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileDropZone>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import MergeTab from '@/components/tools/MergeTab.vue'
|
||||
|
||||
// Tab 配置
|
||||
const tabs = [{ id: 'merge', label: '合并聊天记录', icon: 'i-heroicons-document-duplicate' }]
|
||||
|
||||
const activeTab = ref('merge')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col bg-gray-50 dark:bg-gray-950">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">工具广场</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">提供聊天记录处理的实用工具</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 bg-white px-6 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300',
|
||||
]"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<UIcon :name="tab.icon" class="h-4 w-4" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<MergeTab v-if="activeTab === 'merge'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +12,11 @@ export const router = createRouter({
|
||||
name: 'chat',
|
||||
component: () => import('@/pages/chat.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tools',
|
||||
name: 'tools',
|
||||
component: () => import('@/pages/tools.vue'),
|
||||
},
|
||||
],
|
||||
history: createWebHashHistory(),
|
||||
})
|
||||
|
||||
@@ -49,6 +49,7 @@ export enum ChatPlatform {
|
||||
WECHAT = 'wechat',
|
||||
TELEGRAM = 'telegram',
|
||||
DISCORD = 'discord',
|
||||
MIXED = 'mixed', // 合并的多平台聊天记录
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
@@ -683,3 +684,126 @@ export interface CheckInAnalysis {
|
||||
loyaltyRank: LoyaltyRankItem[] // 忠臣榜 - 累计发言天数排名
|
||||
totalDays: number // 群聊总天数
|
||||
}
|
||||
|
||||
// ==================== ChatLab 专属格式类型 ====================
|
||||
|
||||
/**
|
||||
* ChatLab 格式版本信息
|
||||
*/
|
||||
export interface ChatLabHeader {
|
||||
version: string // 格式版本,如 "1.0.0"
|
||||
exportedAt: number // 导出时间戳(秒)
|
||||
generator: string // 生成工具名称
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并来源信息
|
||||
*/
|
||||
export interface MergeSource {
|
||||
filename: string // 原文件名
|
||||
platform?: string // 原平台
|
||||
messageCount: number // 消息数量
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatLab 格式的元信息
|
||||
*/
|
||||
export interface ChatLabMeta {
|
||||
name: string // 群名/对话名
|
||||
platform: ChatPlatform // 平台(合并时为 mixed)
|
||||
type: ChatType // 聊天类型
|
||||
sources?: MergeSource[] // 合并来源(可选)
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatLab 格式的成员
|
||||
*/
|
||||
export interface ChatLabMember {
|
||||
platformId: string // 平台标识
|
||||
name: string // 当前昵称
|
||||
aliases?: string[] // 历史昵称(可选)
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatLab 格式的消息
|
||||
*/
|
||||
export interface ChatLabMessage {
|
||||
sender: string // 发送者 platformId
|
||||
name: string // 发送时的昵称
|
||||
timestamp: number // 时间戳(秒)
|
||||
type: MessageType // 消息类型
|
||||
content: string | null // 内容
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatLab 专属格式文件结构
|
||||
*/
|
||||
export interface ChatLabFormat {
|
||||
chatlab: ChatLabHeader
|
||||
meta: ChatLabMeta
|
||||
members: ChatLabMember[]
|
||||
messages: ChatLabMessage[]
|
||||
}
|
||||
|
||||
// ==================== 合并相关类型 ====================
|
||||
|
||||
/**
|
||||
* 文件解析信息(用于合并前预览)
|
||||
*/
|
||||
export interface FileParseInfo {
|
||||
name: string // 群名
|
||||
format: string // 格式名称
|
||||
platform: string // 平台
|
||||
messageCount: number // 消息数量
|
||||
memberCount: number // 成员数量
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并冲突项
|
||||
*/
|
||||
export interface MergeConflict {
|
||||
id: string // 冲突ID
|
||||
timestamp: number // 时间戳
|
||||
sender: string // 发送者
|
||||
contentLength1: number // 内容1长度
|
||||
contentLength2: number // 内容2长度
|
||||
content1: string // 内容1
|
||||
content2: string // 内容2
|
||||
}
|
||||
|
||||
/**
|
||||
* 冲突检测结果
|
||||
*/
|
||||
export interface ConflictCheckResult {
|
||||
conflicts: MergeConflict[]
|
||||
totalMessages: number // 合并后预计消息数
|
||||
}
|
||||
|
||||
/**
|
||||
* 冲突解决方案
|
||||
*/
|
||||
export interface ConflictResolution {
|
||||
id: string
|
||||
resolution: 'keep1' | 'keep2' | 'keepBoth'
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并参数
|
||||
*/
|
||||
export interface MergeParams {
|
||||
filePaths: string[]
|
||||
outputName: string
|
||||
outputDir?: string
|
||||
conflictResolutions: ConflictResolution[]
|
||||
andAnalyze: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并结果
|
||||
*/
|
||||
export interface MergeResult {
|
||||
success: boolean
|
||||
outputPath?: string
|
||||
sessionId?: string // 如果选择了分析,返回会话ID
|
||||
error?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user