feat: 实现聊天记录合并功能

This commit is contained in:
digua
2025-12-01 00:20:56 +08:00
parent 479d81960e
commit e7de9cc57d
18 changed files with 1606 additions and 94 deletions
+55
View File
@@ -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
+406
View File
@@ -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 : '合并失败',
}
}
}
+84
View File
@@ -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 -4
View File
@@ -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'
+25
View File
@@ -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),
+2
View File
@@ -31,4 +31,6 @@ export {
getAllSessions,
getSession,
closeDatabase,
// 文件解析 API(异步,用于合并功能)
parseFileInfo,
} from './workerManager'
+7
View File
@@ -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 })
}
/**
* 获取数据库目录(供外部使用)
*/
+15 -1
View File
@@ -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 }
+43 -2
View File
@@ -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
}
-1
View File
@@ -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']
+153
View File
@@ -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>
+1
View File
@@ -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'
+20
View File
@@ -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 -->
+553
View File
@@ -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 JSONTXT 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
View File
@@ -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微信DiscordSnapchatRedditTikTok 等聊天记录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微信DiscordSnapchatRedditTikTok 等聊天记录JSON/TXT 格式
</p>
</template>
</div>
</div>
</template>
</FileDropZone>
<!-- Error Message -->
<div
+44
View File
@@ -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>
+5
View File
@@ -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(),
})
+124
View File
@@ -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
}