feat: 新增批量管理,支持批量删除和合并

This commit is contained in:
digua
2026-01-30 23:50:38 +08:00
committed by digua
parent 3d333476a5
commit 51ed4a9f53
12 changed files with 907 additions and 848 deletions
+33
View File
@@ -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) }
}
})
}
+125
View File
@@ -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)
}
}
}
+9 -1
View File
@@ -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
View File
@@ -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"
+42 -3
View File
@@ -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"
}
}
+42 -3
View File
@@ -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>
+23
View File
@@ -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>
-790
View File
@@ -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>
-35
View File
@@ -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
View File
@@ -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(),