mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-07 05:20:59 +08:00
feat: 新增批量管理,支持批量删除和合并
This commit is contained in:
@@ -9,6 +9,7 @@ import * as parser from '../parser'
|
||||
import { detectFormat, diagnoseFormat, type ParseProgress } from '../parser'
|
||||
import type { IpcContext } from './types'
|
||||
import { CURRENT_SCHEMA_VERSION, getPendingMigrationInfos, type MigrationInfo } from '../database/migrations'
|
||||
import { exportSessionToTempFile, cleanupTempExportFiles } from '../merger'
|
||||
|
||||
/**
|
||||
* 注册聊天记录相关 IPC 处理器
|
||||
@@ -978,4 +979,36 @@ export function registerChatHandlers(ctx: IpcContext): void {
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 批量管理:导出会话为临时文件 ====================
|
||||
|
||||
/**
|
||||
* 导出多个会话为临时文件(用于合并)
|
||||
*/
|
||||
ipcMain.handle('chat:exportSessionsToTempFiles', async (_, sessionIds: string[]) => {
|
||||
try {
|
||||
const tempFiles: string[] = []
|
||||
for (const sessionId of sessionIds) {
|
||||
const tempPath = await exportSessionToTempFile(sessionId)
|
||||
tempFiles.push(tempPath)
|
||||
}
|
||||
return { success: true, tempFiles }
|
||||
} catch (error) {
|
||||
console.error('[IpcMain] 导出会话失败:', error)
|
||||
return { success: false, error: String(error), tempFiles: [] }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 清理临时导出文件
|
||||
*/
|
||||
ipcMain.handle('chat:cleanupTempExportFiles', async (_, filePaths: string[]) => {
|
||||
try {
|
||||
cleanupTempExportFiles(filePaths)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('[IpcMain] 清理临时文件失败:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -599,3 +599,128 @@ export async function mergeFilesWithTempDb(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 从会话数据库导出 ====================
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import { getDbPath } from '../database/core'
|
||||
|
||||
/**
|
||||
* 从已导入的会话数据库导出为临时 JSON 文件
|
||||
* 用于批量管理中的合并功能
|
||||
*/
|
||||
export async function exportSessionToTempFile(sessionId: string): Promise<string> {
|
||||
const dbPath = getDbPath(sessionId)
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new Error(`会话数据库不存在: ${sessionId}`)
|
||||
}
|
||||
|
||||
const db = new Database(dbPath, { readonly: true })
|
||||
|
||||
try {
|
||||
// 读取 meta
|
||||
const meta = db.prepare('SELECT * FROM meta').get() as {
|
||||
name: string
|
||||
platform: string
|
||||
type: string
|
||||
group_id?: string
|
||||
group_avatar?: string
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
throw new Error('无法读取会话元信息')
|
||||
}
|
||||
|
||||
// 读取 members
|
||||
const members = db
|
||||
.prepare('SELECT platform_id, account_name, group_nickname, avatar FROM member')
|
||||
.all() as Array<{
|
||||
platform_id: string
|
||||
account_name?: string
|
||||
group_nickname?: string
|
||||
avatar?: string
|
||||
}>
|
||||
|
||||
// 读取 messages(通过 JOIN 获取发送者信息)
|
||||
const messages = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
m.platform_id as sender,
|
||||
msg.sender_account_name as accountName,
|
||||
msg.sender_group_nickname as groupNickname,
|
||||
msg.ts as timestamp,
|
||||
msg.type,
|
||||
msg.content
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
ORDER BY msg.ts`
|
||||
)
|
||||
.all() as Array<{
|
||||
sender: string
|
||||
accountName?: string
|
||||
groupNickname?: string
|
||||
timestamp: number
|
||||
type: number
|
||||
content?: string
|
||||
}>
|
||||
|
||||
// 构建 ChatLab 格式数据
|
||||
const chatLabData: ChatLabFormat = {
|
||||
chatlab: {
|
||||
version: '0.0.1',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'ChatLab Export',
|
||||
description: `导出自会话: ${meta.name}`,
|
||||
},
|
||||
meta: {
|
||||
name: meta.name,
|
||||
platform: meta.platform as ChatPlatform,
|
||||
type: meta.type as ChatType,
|
||||
groupId: meta.group_id,
|
||||
groupAvatar: meta.group_avatar,
|
||||
},
|
||||
members: members.map((m) => ({
|
||||
platformId: m.platform_id,
|
||||
accountName: m.account_name,
|
||||
groupNickname: m.group_nickname,
|
||||
avatar: m.avatar,
|
||||
})),
|
||||
messages: messages.map((msg) => ({
|
||||
sender: msg.sender,
|
||||
accountName: msg.accountName,
|
||||
groupNickname: msg.groupNickname,
|
||||
timestamp: msg.timestamp,
|
||||
type: msg.type,
|
||||
content: msg.content,
|
||||
})),
|
||||
}
|
||||
|
||||
// 写入临时文件
|
||||
const tempDir = path.join(getDefaultOutputDir(), '.chatlab_temp')
|
||||
ensureOutputDir(tempDir)
|
||||
const tempFilePath = path.join(tempDir, `export_${sessionId}_${Date.now()}.json`)
|
||||
fs.writeFileSync(tempFilePath, JSON.stringify(chatLabData, null, 2), 'utf-8')
|
||||
|
||||
console.log(`[Merger] 导出会话到临时文件: ${tempFilePath}, 消息数: ${messages.length}`)
|
||||
|
||||
return tempFilePath
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时导出文件
|
||||
*/
|
||||
export function cleanupTempExportFiles(filePaths: string[]): void {
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
console.log(`[Merger] 清理临时文件: ${filePath}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Merger] 清理临时文件失败: ${filePath}`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+9
-1
@@ -165,6 +165,15 @@ interface ChatApi {
|
||||
newMessageCount: number
|
||||
error?: string
|
||||
}>
|
||||
exportSessionsToTempFiles: (sessionIds: string[]) => Promise<{
|
||||
success: boolean
|
||||
tempFiles: string[]
|
||||
error?: string
|
||||
}>
|
||||
cleanupTempExportFiles: (filePaths: string[]) => Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface Api {
|
||||
@@ -193,7 +202,6 @@ interface MergeApi {
|
||||
checkConflicts: (filePaths: string[]) => Promise<ConflictCheckResult>
|
||||
mergeFiles: (params: MergeParams) => Promise<MergeResult>
|
||||
clearCache: (filePath?: string) => Promise<boolean>
|
||||
onParseProgress: (callback: (data: { filePath: string; progress: ImportProgress }) => void) => () => void
|
||||
}
|
||||
|
||||
// AI 相关类型
|
||||
|
||||
+25
-13
@@ -452,6 +452,31 @@ const chatApi = {
|
||||
}> => {
|
||||
return ipcRenderer.invoke('chat:incrementalImport', sessionId, filePath)
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出多个会话为临时文件(用于批量管理中的合并)
|
||||
*/
|
||||
exportSessionsToTempFiles: (
|
||||
sessionIds: string[]
|
||||
): Promise<{
|
||||
success: boolean
|
||||
tempFiles: string[]
|
||||
error?: string
|
||||
}> => {
|
||||
return ipcRenderer.invoke('chat:exportSessionsToTempFiles', sessionIds)
|
||||
},
|
||||
|
||||
/**
|
||||
* 清理临时导出文件
|
||||
*/
|
||||
cleanupTempExportFiles: (
|
||||
filePaths: string[]
|
||||
): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
}> => {
|
||||
return ipcRenderer.invoke('chat:cleanupTempExportFiles', filePaths)
|
||||
},
|
||||
}
|
||||
|
||||
// Merge API - 合并功能
|
||||
@@ -485,19 +510,6 @@ const mergeApi = {
|
||||
clearCache: (filePath?: string): Promise<boolean> => {
|
||||
return ipcRenderer.invoke('merge:clearCache', filePath)
|
||||
},
|
||||
|
||||
/**
|
||||
* 监听解析进度(用于大文件)
|
||||
*/
|
||||
onParseProgress: (callback: (data: { filePath: string; progress: ImportProgress }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; progress: ImportProgress }) => {
|
||||
callback(data)
|
||||
}
|
||||
ipcRenderer.on('merge:parseProgress', handler)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('merge:parseProgress', handler)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// AI API - AI 功能
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import SidebarButton from './SidebarButton.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
// 是否在管理页面
|
||||
const isManagePage = computed(() => route.name === 'manage')
|
||||
|
||||
// 注意:帮助和反馈功能已迁移到首页 Footer (HomeFooter.vue)
|
||||
// 如需恢复,请参考 git 历史记录
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-2 dark:border-gray-800 space-y-2 mb-2">
|
||||
<!-- 管理 -->
|
||||
<SidebarButton
|
||||
icon="i-heroicons-rectangle-stack"
|
||||
:title="t('tools.title')"
|
||||
:active="isManagePage"
|
||||
@click="router.push({ name: 'manage' })"
|
||||
/>
|
||||
<!-- 设置 -->
|
||||
<SidebarButton
|
||||
icon="i-heroicons-cog-6-tooth"
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
{
|
||||
"title": "Tools",
|
||||
"description": "Utilities for chat record processing",
|
||||
"title": "Manage",
|
||||
"description": "Chat record management",
|
||||
"tabs": {
|
||||
"merge": "Merge Chat Records"
|
||||
"batchManage": "Batch Manage"
|
||||
},
|
||||
"batchManage": {
|
||||
"title": "Batch Delete",
|
||||
"description": "Select chat records to delete",
|
||||
"searchPlaceholder": "Search conversation name",
|
||||
"searchResult": "Found {count} / {total}",
|
||||
"noSearchResult": "No matching chat records found",
|
||||
"selectAll": "Select All",
|
||||
"selected": "{count} selected",
|
||||
"empty": "No chat records",
|
||||
"delete": "Delete Selected",
|
||||
"confirmTitle": "Confirm Delete",
|
||||
"confirmMessage": "Are you sure you want to delete {count} selected chat record(s)? This action cannot be undone.",
|
||||
"deleting": "Deleting...",
|
||||
"success": "Successfully deleted {count} chat record(s)",
|
||||
"error": "Delete failed: {error}",
|
||||
"messageCount": "{count} messages",
|
||||
"importedAt": "Imported {time}",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"platform": "Platform",
|
||||
"messages": "Messages",
|
||||
"importedAt": "Imported"
|
||||
},
|
||||
"clickToEdit": "Click to edit name",
|
||||
"merge": "Merge Selected",
|
||||
"mergeHint": "Select 2 or more chat records from the same platform to merge",
|
||||
"mergeConfirmTitle": "Merge Chat Records",
|
||||
"mergeConfirmMessage": "This will merge {count} selected chat records. The original records will be deleted and a new merged record will be created.",
|
||||
"mergeSteps": {
|
||||
"exporting": "Exporting chat records...",
|
||||
"parsing": "Parsing files...",
|
||||
"checking": "Checking conflicts...",
|
||||
"merging": "Merging data...",
|
||||
"cleaning": "Cleaning up temp files..."
|
||||
},
|
||||
"mergeSuccess": "Successfully merged {count} chat records",
|
||||
"mergeError": "Merge failed: {error}",
|
||||
"mergedSuffix": "Merged"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
{
|
||||
"title": "实用工具",
|
||||
"description": "提供聊天记录处理的实用工具",
|
||||
"title": "管理",
|
||||
"description": "聊天记录管理",
|
||||
"tabs": {
|
||||
"merge": "合并聊天记录"
|
||||
"batchManage": "批量管理"
|
||||
},
|
||||
"batchManage": {
|
||||
"title": "批量删除",
|
||||
"description": "选择要删除的聊天记录",
|
||||
"searchPlaceholder": "搜索对话名称",
|
||||
"searchResult": "找到 {count} / {total} 个",
|
||||
"noSearchResult": "未找到匹配的聊天记录",
|
||||
"selectAll": "全选",
|
||||
"selected": "已选 {count} 个",
|
||||
"empty": "暂无聊天记录",
|
||||
"delete": "删除选中",
|
||||
"confirmTitle": "确认删除",
|
||||
"confirmMessage": "确定要删除选中的 {count} 个聊天记录吗?此操作不可撤销。",
|
||||
"deleting": "正在删除...",
|
||||
"success": "成功删除 {count} 个聊天记录",
|
||||
"error": "删除失败:{error}",
|
||||
"messageCount": "{count} 条消息",
|
||||
"importedAt": "导入于 {time}",
|
||||
"columns": {
|
||||
"name": "名称",
|
||||
"platform": "平台",
|
||||
"messages": "消息数",
|
||||
"importedAt": "导入时间"
|
||||
},
|
||||
"clickToEdit": "点击编辑名称",
|
||||
"merge": "合并选中",
|
||||
"mergeHint": "选择 2 个以上同平台的聊天记录才能合并",
|
||||
"mergeConfirmTitle": "合并聊天记录",
|
||||
"mergeConfirmMessage": "将合并选中的 {count} 个聊天记录。合并后原记录将被删除,生成一条新的合并记录。",
|
||||
"mergeSteps": {
|
||||
"exporting": "正在导出聊天记录...",
|
||||
"parsing": "正在解析文件...",
|
||||
"checking": "正在检测冲突...",
|
||||
"merging": "正在合并数据...",
|
||||
"cleaning": "正在清理临时文件..."
|
||||
},
|
||||
"mergeSuccess": "成功合并 {count} 个聊天记录",
|
||||
"mergeError": "合并失败:{error}",
|
||||
"mergedSuffix": "合并"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useToast } from '@nuxt/ui/runtime/composables/useToast.js'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { AnalysisSession } from '@/types/base'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
const toast = useToast()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const sessionStore = useSessionStore()
|
||||
const { sessions } = storeToRefs(sessionStore)
|
||||
|
||||
// 搜索关键词
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 过滤后的会话列表
|
||||
const filteredSessions = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return sessions.value
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return sessions.value.filter((s) =>
|
||||
s.name.toLowerCase().includes(query) ||
|
||||
s.platform.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// 选中的会话 ID 集合
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
|
||||
// 删除确认弹窗
|
||||
const showDeleteModal = ref(false)
|
||||
|
||||
// 删除中状态
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// 正在编辑的会话 ID
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
// 编辑中的名称
|
||||
const editingName = ref('')
|
||||
|
||||
// 是否可以合并(选中 2 个以上同平台会话)
|
||||
const canMerge = computed(() => {
|
||||
if (selectedIds.value.size < 2) return false
|
||||
const selectedSessions = sessions.value.filter((s) => selectedIds.value.has(s.id))
|
||||
const platforms = new Set(selectedSessions.map((s) => s.platform))
|
||||
return platforms.size === 1
|
||||
})
|
||||
|
||||
// 选中会话的平台
|
||||
const selectedPlatform = computed(() => {
|
||||
if (selectedIds.value.size === 0) return null
|
||||
const selectedSessions = sessions.value.filter((s) => selectedIds.value.has(s.id))
|
||||
return selectedSessions[0]?.platform || null
|
||||
})
|
||||
|
||||
// 全选状态(基于过滤后的列表)
|
||||
const isAllSelected = computed(() => {
|
||||
return filteredSessions.value.length > 0 &&
|
||||
filteredSessions.value.every((s) => selectedIds.value.has(s.id))
|
||||
})
|
||||
|
||||
// 部分选中状态(用于 indeterminate)
|
||||
const isPartialSelected = computed(() => {
|
||||
const selectedInFiltered = filteredSessions.value.filter((s) => selectedIds.value.has(s.id)).length
|
||||
return selectedInFiltered > 0 && selectedInFiltered < filteredSessions.value.length
|
||||
})
|
||||
|
||||
// 切换全选(只影响过滤后的列表)
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
// 取消选中过滤列表中的所有项
|
||||
const filteredIds = new Set(filteredSessions.value.map((s) => s.id))
|
||||
selectedIds.value = new Set([...selectedIds.value].filter((id) => !filteredIds.has(id)))
|
||||
} else {
|
||||
// 选中过滤列表中的所有项
|
||||
const newSet = new Set(selectedIds.value)
|
||||
for (const s of filteredSessions.value) {
|
||||
newSet.add(s.id)
|
||||
}
|
||||
selectedIds.value = newSet
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单个选择
|
||||
function toggleSelect(id: string) {
|
||||
const newSet = new Set(selectedIds.value)
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id)
|
||||
} else {
|
||||
newSet.add(id)
|
||||
}
|
||||
selectedIds.value = newSet
|
||||
}
|
||||
|
||||
// 判断是否选中
|
||||
function isSelected(id: string): boolean {
|
||||
return selectedIds.value.has(id)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timestamp: number): string {
|
||||
return dayjs.unix(timestamp).locale(locale.value === 'zh-CN' ? 'zh-cn' : 'en').fromNow()
|
||||
}
|
||||
|
||||
// 判断是否是私聊
|
||||
function isPrivateChat(session: AnalysisSession): boolean {
|
||||
return session.type === 'private'
|
||||
}
|
||||
|
||||
// 获取会话头像
|
||||
function getSessionAvatar(session: AnalysisSession): string | null {
|
||||
if (isPrivateChat(session)) {
|
||||
return session.memberAvatar || null
|
||||
}
|
||||
return session.groupAvatar || null
|
||||
}
|
||||
|
||||
// 获取会话头像文字
|
||||
function getSessionAvatarText(session: AnalysisSession): string {
|
||||
const name = session.name || ''
|
||||
if (!name) return '?'
|
||||
if (isPrivateChat(session)) {
|
||||
return name.length <= 2 ? name : name.slice(-2)
|
||||
} else {
|
||||
return name.length <= 2 ? name : name.slice(0, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 平台显示配置
|
||||
const PLATFORM_CONFIG: Record<string, { label: string; class: string }> = {
|
||||
qq: { label: 'QQ', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||
weixin: { label: '微信', class: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' },
|
||||
discord: { label: 'Discord', class: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300' },
|
||||
whatsapp: { label: 'WhatsApp', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' },
|
||||
instagram: { label: 'Instagram', class: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300' },
|
||||
line: { label: 'LINE', class: 'bg-lime-100 text-lime-700 dark:bg-lime-900/30 dark:text-lime-300' },
|
||||
unknown: { label: '未知', class: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' },
|
||||
}
|
||||
|
||||
// 获取平台标签
|
||||
function getPlatformLabel(platform: string): string {
|
||||
return PLATFORM_CONFIG[platform]?.label || platform
|
||||
}
|
||||
|
||||
// 获取平台样式类
|
||||
function getPlatformClass(platform: string): string {
|
||||
return PLATFORM_CONFIG[platform]?.class || PLATFORM_CONFIG.unknown.class
|
||||
}
|
||||
|
||||
// 开始编辑名称
|
||||
function startEdit(session: AnalysisSession, event: Event) {
|
||||
event.stopPropagation()
|
||||
editingId.value = session.id
|
||||
editingName.value = session.name
|
||||
}
|
||||
|
||||
// 保存编辑的名称
|
||||
async function saveEdit() {
|
||||
if (!editingId.value || !editingName.value.trim()) {
|
||||
editingId.value = null
|
||||
editingName.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const newName = editingName.value.trim()
|
||||
const session = sessions.value.find((s) => s.id === editingId.value)
|
||||
|
||||
// 如果名称没变,不保存
|
||||
if (session && session.name !== newName) {
|
||||
await sessionStore.renameSession(editingId.value, newName)
|
||||
}
|
||||
|
||||
editingId.value = null
|
||||
editingName.value = ''
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
editingName.value = ''
|
||||
}
|
||||
|
||||
// 打开删除确认弹窗
|
||||
function openDeleteModal() {
|
||||
if (selectedIds.value.size === 0) return
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
// 合并弹窗相关状态
|
||||
const showMergeModal = ref(false)
|
||||
const isMerging = ref(false)
|
||||
const mergeProgress = ref('')
|
||||
|
||||
// 合并选中的会话
|
||||
async function handleMerge() {
|
||||
if (!canMerge.value) return
|
||||
showMergeModal.value = true
|
||||
}
|
||||
|
||||
// 执行合并
|
||||
async function executeMerge() {
|
||||
if (!canMerge.value) return
|
||||
|
||||
isMerging.value = true
|
||||
mergeProgress.value = t('tools.batchManage.mergeSteps.exporting')
|
||||
|
||||
const selectedSessionIds = Array.from(selectedIds.value)
|
||||
let tempFiles: string[] = []
|
||||
|
||||
try {
|
||||
// 1. 导出选中的会话为临时文件
|
||||
const exportResult = await window.chatApi.exportSessionsToTempFiles(selectedSessionIds)
|
||||
if (!exportResult.success) {
|
||||
throw new Error(exportResult.error || '导出失败')
|
||||
}
|
||||
tempFiles = exportResult.tempFiles
|
||||
|
||||
// 2. 解析临时文件获取信息
|
||||
mergeProgress.value = t('tools.batchManage.mergeSteps.parsing')
|
||||
for (const filePath of tempFiles) {
|
||||
await window.mergeApi.parseFileInfo(filePath)
|
||||
}
|
||||
|
||||
// 3. 检测冲突
|
||||
mergeProgress.value = t('tools.batchManage.mergeSteps.checking')
|
||||
const conflictResult = await window.mergeApi.checkConflicts(tempFiles)
|
||||
|
||||
if (conflictResult.conflicts.length > 0) {
|
||||
// 有冲突,暂时跳过(后续可以添加冲突解决 UI)
|
||||
// 默认使用第一个文件的版本
|
||||
console.log(`[BatchDelete] 检测到 ${conflictResult.conflicts.length} 个冲突,使用默认解决方案`)
|
||||
}
|
||||
|
||||
// 4. 执行合并
|
||||
mergeProgress.value = t('tools.batchManage.mergeSteps.merging')
|
||||
const firstSession = sessions.value.find((s) => selectedIds.value.has(s.id))
|
||||
const baseName = firstSession?.name || '聊天记录'
|
||||
const mergedName = `${baseName}(${t('tools.batchManage.mergedSuffix')})`
|
||||
const mergeResult = await window.mergeApi.mergeFiles({
|
||||
filePaths: tempFiles,
|
||||
outputName: mergedName,
|
||||
outputFormat: 'json',
|
||||
conflictResolutions: conflictResult.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
resolution: 'keep1' as const,
|
||||
})),
|
||||
andAnalyze: true, // 直接导入分析
|
||||
})
|
||||
|
||||
if (!mergeResult.success) {
|
||||
throw new Error(mergeResult.error || '合并失败')
|
||||
}
|
||||
|
||||
// 5. 删除原会话
|
||||
mergeProgress.value = t('tools.batchManage.mergeSteps.cleaning')
|
||||
for (const sessionId of selectedSessionIds) {
|
||||
await sessionStore.deleteSession(sessionId)
|
||||
}
|
||||
|
||||
// 6. 清理临时文件
|
||||
await window.chatApi.cleanupTempExportFiles(tempFiles)
|
||||
|
||||
// 7. 刷新会话列表
|
||||
await sessionStore.loadSessions()
|
||||
|
||||
// 清空选择
|
||||
selectedIds.value = new Set()
|
||||
showMergeModal.value = false
|
||||
|
||||
// 提示成功
|
||||
toast.add({
|
||||
title: t('tools.batchManage.mergeSuccess', { count: selectedSessionIds.length }),
|
||||
icon: 'i-heroicons-check-circle',
|
||||
color: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[BatchDelete] 合并失败:', error)
|
||||
toast.add({
|
||||
title: t('tools.batchManage.mergeError', { error: String(error) }),
|
||||
icon: 'i-heroicons-exclamation-circle',
|
||||
color: 'error',
|
||||
})
|
||||
|
||||
// 清理临时文件
|
||||
if (tempFiles.length > 0) {
|
||||
await window.chatApi.cleanupTempExportFiles(tempFiles)
|
||||
}
|
||||
} finally {
|
||||
isMerging.value = false
|
||||
mergeProgress.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 确认批量删除
|
||||
async function confirmBatchDelete() {
|
||||
if (selectedIds.value.size === 0) return
|
||||
|
||||
isDeleting.value = true
|
||||
try {
|
||||
const idsToDelete = Array.from(selectedIds.value)
|
||||
|
||||
// 逐个删除
|
||||
for (const id of idsToDelete) {
|
||||
await sessionStore.deleteSession(id)
|
||||
}
|
||||
|
||||
// 清空选择
|
||||
selectedIds.value = new Set()
|
||||
showDeleteModal.value = false
|
||||
} catch (error) {
|
||||
console.error('Batch delete failed:', error)
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭删除确认弹窗
|
||||
function closeDeleteModal() {
|
||||
showDeleteModal.value = false
|
||||
}
|
||||
|
||||
// 加载会话列表
|
||||
onMounted(() => {
|
||||
sessionStore.loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="mb-4">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('tools.batchManage.searchPlaceholder')"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
size="md"
|
||||
class="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 全选复选框 -->
|
||||
<UCheckbox
|
||||
:model-value="isAllSelected"
|
||||
:indeterminate="isPartialSelected"
|
||||
:label="t('tools.batchManage.selectAll')"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
|
||||
<!-- 已选数量 -->
|
||||
<span v-if="selectedIds.size > 0" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('tools.batchManage.selected', { count: selectedIds.size }) }}
|
||||
</span>
|
||||
|
||||
<!-- 搜索结果数量 -->
|
||||
<span v-if="searchQuery.trim() && filteredSessions.length !== sessions.length" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('tools.batchManage.searchResult', { count: filteredSessions.length, total: sessions.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- 合并按钮 -->
|
||||
<UTooltip :text="canMerge ? '' : t('tools.batchManage.mergeHint')">
|
||||
<UButton
|
||||
color="primary"
|
||||
:disabled="!canMerge"
|
||||
icon="i-heroicons-document-duplicate"
|
||||
@click="handleMerge"
|
||||
>
|
||||
{{ t('tools.batchManage.merge') }}
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<UButton
|
||||
color="primary"
|
||||
:disabled="selectedIds.size === 0"
|
||||
icon="i-heroicons-trash"
|
||||
@click="openDeleteModal"
|
||||
>
|
||||
{{ t('tools.batchManage.delete') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<div v-if="sessions.length === 0" class="flex flex-1 items-center justify-center">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<UIcon name="i-heroicons-inbox" class="mb-2 h-12 w-12" />
|
||||
<p>{{ t('tools.batchManage.empty') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredSessions.length === 0" class="flex flex-1 items-center justify-center">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<UIcon name="i-heroicons-magnifying-glass" class="mb-2 h-12 w-12" />
|
||||
<p>{{ t('tools.batchManage.noSearchResult') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto rounded-lg border border-gray-200/50 dark:border-gray-700/50">
|
||||
<!-- 表头 -->
|
||||
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800/80 dark:text-gray-400">
|
||||
<div class="w-6" />
|
||||
<div class="w-8" />
|
||||
<div class="min-w-0 flex-1">{{ t('tools.batchManage.columns.name') }}</div>
|
||||
<div class="w-20 text-center">{{ t('tools.batchManage.columns.platform') }}</div>
|
||||
<div class="w-24 text-right">{{ t('tools.batchManage.columns.messages') }}</div>
|
||||
<div class="w-28 text-right">{{ t('tools.batchManage.columns.importedAt') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<div
|
||||
v-for="(session, index) in filteredSessions"
|
||||
:key="session.id"
|
||||
class="flex cursor-pointer items-center gap-3 px-3 py-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
:class="[
|
||||
isSelected(session.id) ? 'bg-pink-50 dark:bg-pink-900/20' : '',
|
||||
index !== filteredSessions.length - 1 ? 'border-b border-gray-100 dark:border-gray-800' : ''
|
||||
]"
|
||||
@click="toggleSelect(session.id)"
|
||||
>
|
||||
<!-- 复选框 -->
|
||||
<div class="w-6">
|
||||
<UCheckbox
|
||||
:model-value="isSelected(session.id)"
|
||||
@click.stop
|
||||
@update:model-value="toggleSelect(session.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 头像 -->
|
||||
<div class="w-8">
|
||||
<img
|
||||
v-if="getSessionAvatar(session)"
|
||||
:src="getSessionAvatar(session)!"
|
||||
:alt="session.name"
|
||||
class="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[10px] font-bold"
|
||||
:class="isPrivateChat(session) ? 'bg-pink-500 text-white' : 'bg-primary-500 text-white'"
|
||||
>
|
||||
{{ getSessionAvatarText(session) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
:name="isPrivateChat(session) ? 'i-heroicons-user' : 'i-heroicons-user-group'"
|
||||
class="h-3.5 w-3.5 shrink-0 text-gray-400"
|
||||
/>
|
||||
<!-- 编辑模式 -->
|
||||
<input
|
||||
v-if="editingId === session.id"
|
||||
v-model="editingName"
|
||||
type="text"
|
||||
class="w-full rounded border border-pink-300 bg-white px-2 py-0.5 text-sm font-medium text-gray-900 focus:border-pink-500 focus:outline-none focus:ring-1 focus:ring-pink-500 dark:border-pink-600 dark:bg-gray-800 dark:text-white"
|
||||
@blur="saveEdit"
|
||||
@keydown.enter="saveEdit"
|
||||
@keydown.escape="cancelEdit"
|
||||
@click.stop
|
||||
autofocus
|
||||
/>
|
||||
<!-- 显示模式 -->
|
||||
<p
|
||||
v-else
|
||||
class="cursor-text truncate rounded px-1 text-sm font-medium text-gray-900 hover:bg-gray-200 dark:text-white dark:hover:bg-gray-700"
|
||||
:title="t('tools.batchManage.clickToEdit')"
|
||||
@click="startEdit(session, $event)"
|
||||
>
|
||||
{{ session.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平台 -->
|
||||
<div class="w-20 text-center">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium" :class="getPlatformClass(session.platform)">
|
||||
{{ getPlatformLabel(session.platform) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息数 -->
|
||||
<div class="w-24 text-right text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ session.messageCount.toLocaleString() }}
|
||||
</div>
|
||||
|
||||
<!-- 导入时间 -->
|
||||
<div class="w-28 text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatTime(session.importedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 合并确认弹窗 -->
|
||||
<UModal v-model:open="showMergeModal">
|
||||
<template #content>
|
||||
<div class="p-4">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<UIcon name="i-heroicons-document-duplicate" class="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('tools.batchManage.mergeConfirmTitle') }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-gray-600 dark:text-gray-400">
|
||||
{{ t('tools.batchManage.mergeConfirmMessage', { count: selectedIds.size }) }}
|
||||
</p>
|
||||
|
||||
<!-- 选中的会话预览 -->
|
||||
<div class="mb-4 max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
v-for="session in sessions.filter(s => selectedIds.has(s.id))"
|
||||
:key="session.id"
|
||||
class="flex items-center gap-2 border-b border-gray-100 px-3 py-2 last:border-b-0 dark:border-gray-800"
|
||||
>
|
||||
<UIcon
|
||||
:name="isPrivateChat(session) ? 'i-heroicons-user' : 'i-heroicons-user-group'"
|
||||
class="h-4 w-4 text-gray-400"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ session.name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ session.messageCount.toLocaleString() }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度显示 -->
|
||||
<div v-if="isMerging" class="mb-4 rounded-lg bg-blue-50 px-4 py-3 dark:bg-blue-900/20">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-4 w-4 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span class="text-sm text-blue-700 dark:text-blue-300">{{ mergeProgress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton variant="soft" :disabled="isMerging" @click="showMergeModal = false">
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
<UButton color="primary" :loading="isMerging" @click="executeMerge">
|
||||
{{ isMerging ? mergeProgress : t('tools.batchManage.merge') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<UModal v-model:open="showDeleteModal">
|
||||
<template #content>
|
||||
<div class="p-4">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 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>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('tools.batchManage.confirmTitle') }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 text-gray-600 dark:text-gray-400">
|
||||
{{ t('tools.batchManage.confirmMessage', { count: selectedIds.size }) }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton variant="soft" :disabled="isDeleting" @click="closeDeleteModal">
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
<UButton color="error" :loading="isDeleting" @click="confirmBatchDelete">
|
||||
{{ isDeleting ? t('tools.batchManage.deleting') : t('common.delete') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BatchManageTab from './components/BatchManageTab.vue'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col bg-white pt-8 dark:bg-[var(--color-page-dark)]">
|
||||
<!-- Header -->
|
||||
<PageHeader
|
||||
:title="t('tools.title')"
|
||||
:description="t('tools.description')"
|
||||
icon="i-heroicons-rectangle-stack"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<BatchManageTab />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,790 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { FileDropZone } from '@/components/UI'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface FileInfo {
|
||||
id: string
|
||||
path: string
|
||||
name: string
|
||||
format: string
|
||||
messageCount: number
|
||||
fileSize?: number // 文件大小(字节)
|
||||
status: 'pending' | 'parsed' | 'error'
|
||||
error?: string
|
||||
// 解析进度(用于大文件)
|
||||
progress?: number
|
||||
progressMessage?: string
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
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 outputFormat = ref<'json' | 'jsonl'>('json')
|
||||
const isLoading = ref(false)
|
||||
const isMerging = ref(false)
|
||||
const mergeProgress = ref(0)
|
||||
const currentStep = ref<'select' | 'conflict' | 'done'>('select')
|
||||
const outputFilePath = ref('')
|
||||
|
||||
// 输出格式选项
|
||||
const formatOptions = [
|
||||
{ value: 'json', label: 'JSON', description: '标准格式,兼容性好' },
|
||||
{ value: 'jsonl', label: 'JSONL', description: '流式格式,支持超大文件' },
|
||||
]
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
// 解析进度监听
|
||||
let unsubscribeProgress: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// 监听解析进度
|
||||
unsubscribeProgress = window.mergeApi.onParseProgress(({ filePath, progress }) => {
|
||||
const file = files.value.find((f) => f.path === filePath)
|
||||
if (file && file.status === 'pending') {
|
||||
// 使用 progress
|
||||
file.progress = progress.progress ?? 0
|
||||
// 构建更详细的进度消息
|
||||
if (progress.messagesProcessed && progress.messagesProcessed > 0) {
|
||||
file.progressMessage = `已处理 ${progress.messagesProcessed.toLocaleString()} 条消息`
|
||||
} else if (progress.message) {
|
||||
file.progressMessage = progress.message
|
||||
} else {
|
||||
file.progressMessage = '正在解析...'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeProgress) {
|
||||
unsubscribeProgress()
|
||||
}
|
||||
})
|
||||
|
||||
// 计算总消息数
|
||||
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.fileSize = info.fileSize
|
||||
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', 'jsonl', '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) {
|
||||
const file = files.value.find((f) => f.id === id)
|
||||
if (file) {
|
||||
// 清理该文件的解析缓存
|
||||
window.mergeApi.clearCache(file.path)
|
||||
}
|
||||
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
|
||||
currentPage.value = 1 // 重置分页
|
||||
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,
|
||||
outputFormat: outputFormat.value,
|
||||
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() {
|
||||
// 清理所有文件的解析缓存
|
||||
for (const file of files.value) {
|
||||
window.mergeApi.clearCache(file.path)
|
||||
}
|
||||
files.value = []
|
||||
conflicts.value = []
|
||||
outputName.value = ''
|
||||
outputDir.value = ''
|
||||
outputFormat.value = 'json'
|
||||
currentStep.value = 'select'
|
||||
mergeProgress.value = 0
|
||||
}
|
||||
|
||||
// 获取格式图标
|
||||
function getFormatIcon(format: string): string {
|
||||
if (format.includes('JSONL')) return 'i-heroicons-bars-3-bottom-left'
|
||||
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)
|
||||
|
||||
// 分页相关计算属性
|
||||
const totalPages = computed(() => Math.ceil(conflicts.value.length / pageSize))
|
||||
const paginatedConflicts = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return conflicts.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
// 分页导航
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
}
|
||||
}
|
||||
|
||||
// 批量选择所有冲突
|
||||
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">{{ t('title') }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('description') }}</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('usageHint') }}</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">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate text-sm font-medium text-gray-900 dark:text-white">{{ file.name }}</p>
|
||||
<span
|
||||
v-if="file.fileSize && file.fileSize > 50 * 1024 * 1024"
|
||||
class="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
{{ t('largeFile') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span :class="getStatusColor(file.status)">
|
||||
{{
|
||||
file.status === 'pending'
|
||||
? file.progressMessage || t('parsing')
|
||||
: file.status === 'error'
|
||||
? file.error
|
||||
: file.format
|
||||
}}
|
||||
</span>
|
||||
<template v-if="file.status === 'parsed'">
|
||||
· {{ t('messagesCount', { count: file.messageCount.toLocaleString() }) }}
|
||||
<span v-if="file.fileSize" class="text-gray-400">· {{ formatFileSize(file.fileSize) }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<!-- 解析进度条(大文件时显示) -->
|
||||
<div v-if="file.status === 'pending' && file.progress !== undefined" class="mt-1.5">
|
||||
<UProgress :model-value="file.progress" size="xs" />
|
||||
</div>
|
||||
</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', '.jsonl', '.txt']" :disabled="isLoading" @files="handleFileDrop">
|
||||
<template #default="{ isDragOver }">
|
||||
<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 ? t('dropToAdd') : t('dragOrClick') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">{{ t('supportedFormats') }}</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">{{ t('outputSettings') }}</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- 名称 -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">{{ t('chatName') }}</label>
|
||||
<UInput v-model="outputName" :placeholder="t('mergedChatRecords')" />
|
||||
</div>
|
||||
|
||||
<!-- 输出目录 -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">{{ t('outputDir') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<UInput v-model="outputDir" :placeholder="t('defaultOutputPath')" class="flex-1" readonly />
|
||||
<UButton icon="i-heroicons-folder" variant="soft" @click="selectOutputDir">{{ t('select') }}</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出格式 -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">{{ t('outputFormat') }}</label>
|
||||
<div class="flex gap-3">
|
||||
<label
|
||||
v-for="opt in formatOptions"
|
||||
:key="opt.value"
|
||||
class="flex flex-1 cursor-pointer items-center gap-3 rounded-lg border-2 p-3 transition-colors"
|
||||
:class="[
|
||||
outputFormat === opt.value
|
||||
? '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',
|
||||
]"
|
||||
>
|
||||
<input v-model="outputFormat" type="radio" :value="opt.value" class="h-4 w-4 text-primary-600" />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ opt.label }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ opt.description }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</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">
|
||||
{{ t('fileSummary', { fileCount: files.length, messageCount: 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">
|
||||
{{ t('mergeAndExport') }}
|
||||
</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">{{ t('conflictsFound', { count: conflicts.length }) }}</h2>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('conflictDescription') }}</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ t('selectedCount') }}
|
||||
<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">{{ t('batchSelect') }}</span>
|
||||
<UButton size="xs" variant="soft" @click="batchSelectAll('keep1')">{{ t('keepAll', { name: file1Name }) }}</UButton>
|
||||
<UButton size="xs" variant="soft" @click="batchSelectAll('keep2')">{{ t('keepAll', { name: file2Name }) }}</UButton>
|
||||
<UButton size="xs" variant="soft" @click="batchSelectAll('keepBoth')">{{ t('keepBothAll') }}</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 paginatedConflicts" :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"
|
||||
>
|
||||
{{ (currentPage - 1) * pageSize + 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">{{ t('keepBothOption') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页控件(仅当冲突数 > 20 时显示) -->
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="flex items-center justify-center gap-2 border-t border-gray-200 bg-gray-50 px-5 py-3 dark:border-gray-800 dark:bg-gray-800/50"
|
||||
>
|
||||
<UButton
|
||||
size="xs"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-chevron-left"
|
||||
:disabled="currentPage === 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="page in totalPages" :key="page">
|
||||
<UButton
|
||||
v-if="page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1"
|
||||
size="xs"
|
||||
:color="page === currentPage ? 'primary' : 'gray'"
|
||||
:variant="page === currentPage ? 'soft' : 'ghost'"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</UButton>
|
||||
<span v-else-if="page === 2 && currentPage > 3" class="px-1 text-xs text-gray-400">...</span>
|
||||
<span
|
||||
v-else-if="page === totalPages - 1 && currentPage < totalPages - 2"
|
||||
class="px-1 text-xs text-gray-400"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-chevron-right"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
/>
|
||||
<span class="ml-2 text-xs text-gray-500">第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="flex items-center justify-between border-t border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||
<UButton variant="ghost" @click="currentStep = 'select'">
|
||||
<UIcon name="i-heroicons-arrow-left" class="mr-1 h-4 w-4" />
|
||||
{{ t('back') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
:disabled="resolvedCount < conflicts.length"
|
||||
:loading="isMerging"
|
||||
@click="resolveConflictsAndMerge"
|
||||
>
|
||||
{{ t('mergeAndExport') }}
|
||||
</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">{{ t('mergeComplete') }}</h2>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">{{ t('mergeSuccessMessage') }}</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" />
|
||||
{{ t('openFolder') }}
|
||||
</UButton>
|
||||
<UButton @click="reset">{{ t('continueMerge') }}</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">{{ t('mergeFailed') }}</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 @click="showErrorModal = false">{{ t('gotIt') }}</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"zh-CN": {
|
||||
"title": "群工局:合并聊天记录",
|
||||
"description": "将多个聊天记录文件合并为一个,支持 JSON、JSONL、TXT 等多种格式",
|
||||
"usageHint": "该工具的用途是,如果你们群有多份聊天记录,每个人的聊天记录都不一样,可以试试使用这个工具进行合并,合并后将会得到该群最完整的聊天记录",
|
||||
"parsing": "解析中...",
|
||||
"messagesCount": "{count} 条消息",
|
||||
"dropToAdd": "松开以添加文件",
|
||||
"dragOrClick": "拖拽文件到这里,或点击选择",
|
||||
"supportedFormats": "支持 .json、.jsonl 和 .txt 格式",
|
||||
"outputSettings": "输出设置",
|
||||
"chatName": "群聊名称",
|
||||
"mergedChatRecords": "合并的聊天记录",
|
||||
"outputDir": "输出目录(可选)",
|
||||
"defaultOutputPath": "默认保存到文档/ChatLab/merged/",
|
||||
"select": "选择",
|
||||
"outputFormat": "输出格式",
|
||||
"fileSummary": "共 {fileCount} 个文件,约 {messageCount} 条消息",
|
||||
"mergeAndExport": "合并并导出",
|
||||
"conflictsFound": "发现 {count} 个格式差异",
|
||||
"conflictDescription": "同一条消息在不同文件中的格式可能有差异,请选择保留哪个版本",
|
||||
"selectedCount": "已选择",
|
||||
"batchSelect": "一键选择:",
|
||||
"keepAll": "全部保留「{name}」",
|
||||
"keepBothAll": "全部保留两者",
|
||||
"keepBothOption": "保留两者(作为两条独立消息)",
|
||||
"back": "返回",
|
||||
"mergeComplete": "合并完成!",
|
||||
"mergeSuccessMessage": "聊天记录已成功合并并导出",
|
||||
"openFolder": "打开文件夹",
|
||||
"continueMerge": "继续合并",
|
||||
"mergeFailed": "合并失败",
|
||||
"gotIt": "我知道了",
|
||||
"largeFile": "大文件"
|
||||
},
|
||||
"en-US": {
|
||||
"title": "Merge Chat Records",
|
||||
"description": "Merge multiple chat record files into one, supporting JSON, JSONL, TXT and more formats",
|
||||
"usageHint": "If your group has multiple chat records from different people, you can use this tool to merge them and get the most complete chat history",
|
||||
"parsing": "Parsing...",
|
||||
"messagesCount": "{count} messages",
|
||||
"dropToAdd": "Drop to add files",
|
||||
"dragOrClick": "Drag files here or click to select",
|
||||
"supportedFormats": "Supports .json, .jsonl and .txt formats",
|
||||
"outputSettings": "Output Settings",
|
||||
"chatName": "Chat Name",
|
||||
"mergedChatRecords": "Merged Chat Records",
|
||||
"outputDir": "Output Directory (Optional)",
|
||||
"defaultOutputPath": "Default: Documents/ChatLab/merged/",
|
||||
"select": "Select",
|
||||
"outputFormat": "Output Format",
|
||||
"fileSummary": "{fileCount} files, ~{messageCount} messages",
|
||||
"mergeAndExport": "Merge & Export",
|
||||
"conflictsFound": "Found {count} Format Differences",
|
||||
"conflictDescription": "The same message may have different formats in different files. Please choose which version to keep",
|
||||
"selectedCount": "Selected",
|
||||
"batchSelect": "Quick select:",
|
||||
"keepAll": "Keep all from {name}",
|
||||
"keepBothAll": "Keep both all",
|
||||
"keepBothOption": "Keep both (as separate messages)",
|
||||
"back": "Back",
|
||||
"mergeComplete": "Merge Complete!",
|
||||
"mergeSuccessMessage": "Chat records have been successfully merged and exported",
|
||||
"openFolder": "Open Folder",
|
||||
"continueMerge": "Continue Merging",
|
||||
"mergeFailed": "Merge Failed",
|
||||
"gotIt": "Got it",
|
||||
"largeFile": "Large File"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { SubTabs } from '@/components/UI'
|
||||
import MergeTab from './components/MergeTab.vue'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Tab 配置
|
||||
const tabs = computed(() => [
|
||||
{ id: 'merge', label: t('tools.tabs.merge'), icon: 'i-heroicons-document-duplicate' },
|
||||
])
|
||||
|
||||
const activeTab = ref('merge')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col bg-white pt-8 dark:bg-[var(--color-page-dark)]">
|
||||
<!-- Header -->
|
||||
<PageHeader
|
||||
:title="t('tools.title')"
|
||||
:description="t('tools.description')"
|
||||
icon="i-heroicons-wrench-screwdriver"
|
||||
/>
|
||||
|
||||
<!-- Tabs -->
|
||||
<SubTabs v-model="activeTab" :items="tabs" persist-key="toolTab" />
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<MergeTab v-if="activeTab === 'merge'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+3
-3
@@ -18,9 +18,9 @@ export const router = createRouter({
|
||||
component: () => import('@/pages/private-chat/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tools',
|
||||
name: 'tools',
|
||||
component: () => import('@/pages/tools/index.vue'),
|
||||
path: '/manage',
|
||||
name: 'manage',
|
||||
component: () => import('@/pages/manage/index.vue'),
|
||||
},
|
||||
],
|
||||
history: createWebHashHistory(),
|
||||
|
||||
Reference in New Issue
Block a user