Compare commits

...

7 Commits

Author SHA1 Message Date
digua 9ad71d491a release: v0.5.2 2026-01-20 00:55:48 +08:00
digua 0341c86add fix: 优化构建配置以解决macOS x64编译问题 2026-01-20 00:55:39 +08:00
digua 35a2260fdc fix: 消息记录查看器在windows下关闭按钮样式问题 2026-01-19 22:44:56 +08:00
digua 9d5511a4b0 feat: 支持合并导入 2026-01-19 22:37:29 +08:00
digua f47f9a220f feat: 主面板显示聊天记录起止时间 2026-01-19 21:46:16 +08:00
digua a26ccda70f feat: 拖拽区域优化 2026-01-19 20:54:13 +08:00
digua 8d9b94886a fix: macOS 打包时需在对应架构上编译(fixes #36) 2026-01-17 01:15:52 +08:00
15 changed files with 514 additions and 126 deletions
+24 -9
View File
@@ -14,8 +14,17 @@ on:
- 'v*'
jobs:
# macOS 构建需要分架构
# better-sqlite3 等原生模块会通过 prebuild 下载对应架构的预编译二进制
build-mac:
runs-on: macos-latest
strategy:
matrix:
include:
- os: macos-14 # 使用 ARM runner 交叉编译 x64
arch: x64
- os: macos-14 # Apple Silicon (arm64) 原生构建
arch: arm64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -39,9 +48,9 @@ jobs:
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
key: ${{ runner.os }}-${{ matrix.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
${{ runner.os }}-${{ matrix.arch }}-pnpm-store-
- name: Install dependencies
run: pnpm install
@@ -52,7 +61,7 @@ jobs:
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_API_KEY }}" > ~/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8
- name: Build Electron app for macOS
- name: Build Electron app for macOS (${{ matrix.arch }})
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
# 代码签名
@@ -64,12 +73,12 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
# 分析服务
APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }}
run: pnpm build:mac
run: pnpm build && pnpm exec electron-builder --mac --${{ matrix.arch }} --config electron-builder.yml -p never
- name: Upload macOS artifacts
- name: Upload macOS artifacts (${{ matrix.arch }})
uses: actions/upload-artifact@v4
with:
name: ChatLab-mac
name: ChatLab-mac-${{ matrix.arch }}
path: |
dist/*.dmg
dist/*.zip
@@ -141,10 +150,16 @@ jobs:
with:
fetch-depth: 0 # 获取完整历史用于生成 changelog
- name: Download macOS artifacts
- name: Download macOS artifacts (x64)
uses: actions/download-artifact@v4
with:
name: ChatLab-mac
name: ChatLab-mac-x64
path: dist
- name: Download macOS artifacts (arm64)
uses: actions/download-artifact@v4
with:
name: ChatLab-mac-arm64
path: dist
- name: Download Windows artifacts
+1 -1
View File
@@ -84,4 +84,4 @@ appImage:
artifactName: ChatLab-${version}.${ext}
# 是否在构建之前重新编译原生模块
npmRebuild: false
npmRebuild: true
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ChatLab",
"version": "0.5.1",
"version": "0.5.2",
"description": "本地聊天分析实验室",
"repository": {
"type": "git",
@@ -1,14 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AnalysisSession } from '@/types/base'
import { formatDateRange } from '@/utils'
const { t } = useI18n()
defineProps<{
const props = defineProps<{
session: AnalysisSession
totalDurationDays: number
totalDailyAvgMessages: number
timeRange: { start: number; end: number } | null
}>()
// 聊天记录起止时间(完整范围)
const fullTimeRangeText = computed(() => {
if (!props.timeRange) return ''
return formatDateRange(props.timeRange.start, props.timeRange.end, 'YYYY/MM/DD')
})
</script>
<template>
@@ -29,6 +38,10 @@ defineProps<{
{{ session.type === 'private' ? t('privateChat') : t('groupChat') }} ·
<span class="opacity-80">{{ t('analysisReport') }}</span>
</p>
<!-- 聊天记录起止时间 -->
<p v-if="fullTimeRangeText" class="mt-2 text-sm font-medium text-pink-100/90 dark:text-gray-400">
{{ fullTimeRangeText }}
</p>
</div>
<div class="mt-8 grid grid-cols-3 gap-6">
@@ -3,7 +3,7 @@
* 聊天记录查看器 Drawer
* 主组件,组合筛选面板、消息列表、会话时间线等子组件
*/
import { ref, watch, toRaw, nextTick } from 'vue'
import { ref, watch, toRaw, nextTick, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import FilterPanel from './FilterPanel.vue'
import MessageList from './MessageList.vue'
@@ -18,6 +18,13 @@ const layoutStore = useLayoutStore()
const sessionStore = useSessionStore()
const { currentSessionId } = storeToRefs(sessionStore)
// 平台检测
const isWindows = ref(false)
onMounted(() => {
isWindows.value = navigator.platform.toLowerCase().includes('win')
})
// 消息列表组件引用
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
@@ -147,7 +154,10 @@ watch(
<template #content>
<div class="flex h-full w-[680px] flex-col bg-white dark:bg-gray-900" style="-webkit-app-region: no-drag">
<!-- 头部 -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<div
class="flex items-center justify-between border-b border-gray-200 px-4 dark:border-gray-800"
:class="isWindows ? 'pt-10 pb-3' : 'py-3'"
>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('title') }}</h3>
<UButton
icon="i-heroicons-x-mark"
+16
View File
@@ -51,6 +51,22 @@
"completed": "Batch import completed",
"summary": "{success} succeeded, {failed} failed, {cancelled} skipped",
"view": "View"
},
"options": {
"title": "More Options",
"mergeImport": "Merge Import",
"mergeImportHint": "Multiple files will be merged into one chat record"
},
"merge": {
"importing": "Merging",
"parsing": "Parsing files...",
"parsingFile": "Parsing: {name}",
"merging": "Merging messages...",
"completed": "Merge completed",
"completedHint": "{count} files merged",
"messageCount": "{count} messages",
"failed": "Merge failed",
"cancel": "Cancel merge"
}
},
"migration": {
+16
View File
@@ -51,6 +51,22 @@
"completed": "批量导入完成",
"summary": "成功 {success} 个,失败 {failed} 个,跳过 {cancelled} 个",
"view": "查看"
},
"options": {
"title": "更多选项",
"mergeImport": "合并导入",
"mergeImportHint": "勾选后,多个文件将合并为一个聊天记录"
},
"merge": {
"importing": "正在合并",
"parsing": "正在解析文件...",
"parsingFile": "正在解析: {name}",
"merging": "正在合并消息...",
"completed": "合并完成",
"completedHint": "{count} 个文件已合并",
"messageCount": "{count} 条消息",
"failed": "合并失败",
"cancel": "取消合并"
}
},
"migration": {
@@ -137,6 +137,7 @@ watch(
:session="session"
:total-duration-days="totalDurationDays"
:total-daily-avg-messages="totalDailyAvgMessages"
:time-range="timeRange"
/>
<!-- 关键指标卡片 -->
+7 -1
View File
@@ -125,7 +125,8 @@ function handleDisagree() {
}"
>
<template #content>
<div class="flex max-h-[85vh] flex-col p-6">
<!-- 弹窗区域禁止拖拽避免顶部点击被拖拽区域抢占 -->
<div class="agreement-modal flex max-h-[85vh] flex-col p-6">
<!-- Header -->
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
@@ -179,6 +180,11 @@ function handleDisagree() {
</template>
<style scoped>
/* 弹窗内禁用窗口拖拽 */
.agreement-modal {
-webkit-app-region: no-drag;
}
/* 用户协议 markdown 样式优化 */
.agreement-content {
font-size: 0.875rem;
@@ -0,0 +1,45 @@
<script setup lang="ts">
/**
* 文件列表项组件
* 用于批量导入/合并导入的文件列表显示
*/
defineProps<{
/** 文件名 */
name: string
/** 状态图标名称 */
statusIcon: string
/** 状态图标样式类 */
statusClass: string
/** 进度/状态描述文本 */
progressText?: string
/** 当前索引(从0开始) */
index: number
/** 总数 */
total: number
/** 是否高亮当前项 */
highlight?: boolean
}>()
</script>
<template>
<div
class="flex items-center gap-3 rounded-lg px-3 py-2 transition-colors"
:class="{ 'bg-pink-50/50 dark:bg-pink-500/5': highlight }"
>
<UIcon :name="statusIcon" class="h-5 w-5 shrink-0" :class="statusClass" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-gray-900 dark:text-white">
{{ name }}
</p>
<p v-if="progressText" class="text-xs text-gray-500 dark:text-gray-400">
{{ progressText }}
</p>
<!-- 额外内容插槽如错误信息操作按钮等 -->
<slot name="extra" />
</div>
<span class="text-xs text-gray-400">{{ index + 1 }}/{{ total }}</span>
<!-- 操作按钮插槽 -->
<slot name="action" />
</div>
</template>
+205 -102
View File
@@ -1,14 +1,27 @@
<script setup lang="ts">
import { FileDropZone } from '@/components/UI'
import FileListItem from './FileListItem.vue'
import { storeToRefs } from 'pinia'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useSessionStore, type BatchFileInfo } from '@/stores/session'
import { useSessionStore, type BatchFileInfo, type MergeFileInfo } from '@/stores/session'
const { t } = useI18n()
const sessionStore = useSessionStore()
const { isImporting, importProgress, isBatchImporting, batchFiles, batchImportResult } = storeToRefs(sessionStore)
const {
isImporting,
importProgress,
isBatchImporting,
batchFiles,
batchImportResult,
// 合并导入
isMergeImporting,
mergeFiles,
mergeStage,
mergeError,
mergeResult,
} = storeToRefs(sessionStore)
const importError = ref<string | null>(null)
const diagnosisSuggestion = ref<string | null>(null)
@@ -16,8 +29,13 @@ const hasImportLog = ref(false)
const router = useRouter()
// 计算是否正在导入(单文件或批量)
const isAnyImporting = computed(() => isImporting.value || isBatchImporting.value)
// 更多选项展开状态
// 合并导入开关
const mergeImportEnabled = ref(false)
// 计算是否正在导入(单文件、批量或合并)
const isAnyImporting = computed(() => isImporting.value || isBatchImporting.value || isMergeImporting.value)
// 计算批量导入进度
const batchProgress = computed(() => {
@@ -104,23 +122,43 @@ async function handleFileDrop({ paths }: { files: File[]; paths: string[] }) {
// 统一处理文件路径(单文件或多文件)
async function processFilePaths(paths: string[]) {
if (paths.length === 1) {
// 单文件导入 - 使用原有逻辑
const result = await sessionStore.importFileFromPath(paths[0])
if (!result.success && result.error) {
importError.value = translateError(result.error)
if (result.diagnosisSuggestion) {
diagnosisSuggestion.value = result.diagnosisSuggestion
// 单文件 或 未启用合并导入 - 使用原有逻辑
if (paths.length === 1 || !mergeImportEnabled.value) {
if (paths.length === 1) {
// 单文件导入
const result = await sessionStore.importFileFromPath(paths[0])
if (!result.success && result.error) {
importError.value = translateError(result.error)
if (result.diagnosisSuggestion) {
diagnosisSuggestion.value = result.diagnosisSuggestion
}
await checkImportLog()
} else if (result.success && sessionStore.currentSessionId) {
await navigateToSession(sessionStore.currentSessionId)
}
await checkImportLog()
} else if (result.success && sessionStore.currentSessionId) {
await navigateToSession(sessionStore.currentSessionId)
} else {
// 多文件批量导入(未启用合并)
await sessionStore.importFilesFromPaths(paths)
}
} else {
// 多文件批量导入
await sessionStore.importFilesFromPaths(paths)
// 批量导入完成后不自动跳转,显示结果摘要
return
}
// 多文件 + 合并导入(调用 store 方法)
await sessionStore.mergeImportFiles(paths)
}
// 关闭合并结果并跳转
async function handleMergeGoToSession() {
if (mergeResult.value?.sessionId) {
const sessionId = mergeResult.value.sessionId
sessionStore.clearMergeImportResult()
await navigateToSession(sessionId)
}
}
// 关闭合并结果
function closeMergeResult() {
sessionStore.clearMergeImportResult()
}
// 取消批量导入
@@ -195,69 +233,40 @@ function getProgressDetail(): string {
return importProgress.value.message || ''
}
// 获取文件状态图标
function getFileStatusIcon(file: BatchFileInfo): string {
switch (file.status) {
case 'pending':
return 'i-heroicons-clock'
case 'importing':
return 'i-heroicons-arrow-path'
case 'success':
return 'i-heroicons-check-circle'
case 'failed':
return 'i-heroicons-x-circle'
case 'cancelled':
return 'i-heroicons-minus-circle'
default:
return 'i-heroicons-question-mark-circle'
}
// 文件状态配置
const STATUS_CONFIG: Record<string, { icon: string; class: string }> = {
pending: { icon: 'i-heroicons-clock', class: 'text-gray-400' },
importing: { icon: 'i-heroicons-arrow-path', class: 'text-pink-500 animate-spin' },
parsing: { icon: 'i-heroicons-arrow-path', class: 'text-pink-500 animate-spin' },
success: { icon: 'i-heroicons-check-circle', class: 'text-green-500' },
done: { icon: 'i-heroicons-check-circle', class: 'text-green-500' },
failed: { icon: 'i-heroicons-x-circle', class: 'text-red-500' },
cancelled: { icon: 'i-heroicons-minus-circle', class: 'text-gray-400' },
}
// 获取文件状态颜色类名
function getFileStatusClass(file: BatchFileInfo): string {
switch (file.status) {
case 'pending':
return 'text-gray-400'
case 'importing':
return 'text-pink-500 animate-spin'
case 'success':
return 'text-green-500'
case 'failed':
return 'text-red-500'
case 'cancelled':
return 'text-gray-400'
default:
return 'text-gray-400'
}
}
const getStatusIcon = (status: string) => STATUS_CONFIG[status]?.icon ?? 'i-heroicons-question-mark-circle'
const getStatusClass = (status: string) => STATUS_CONFIG[status]?.class ?? 'text-gray-400'
// 获取文件进度描述
function getFileProgressText(file: BatchFileInfo): string {
// 获取批量导入文件进度描述
function getBatchFileProgressText(file: BatchFileInfo): string {
if (file.status === 'pending') return t('home.import.batch.waiting')
if (file.status === 'cancelled') return t('home.import.batch.skipped')
if (file.status === 'success') return t('home.import.batch.success')
if (file.status === 'failed') return translateError(file.error || 'error.import_failed')
// importing 状态
if (file.progress) {
switch (file.progress.stage) {
case 'detecting':
return t('home.import.progress.detecting')
case 'reading':
return t('home.import.progress.reading')
case 'parsing':
if (file.progress.messagesProcessed) {
return t('home.import.processed', { count: file.progress.messagesProcessed.toLocaleString() })
}
return t('home.import.progress.parsing')
case 'saving':
return t('home.import.progress.saving')
default:
return ''
const { stage, messagesProcessed } = file.progress
if (stage === 'parsing' && messagesProcessed) {
return t('home.import.processed', { count: messagesProcessed.toLocaleString() })
}
return t(`home.import.progress.${stage}`)
}
return ''
}
// 获取合并文件进度描述
const getMergeFileProgressText = (file: MergeFileInfo) =>
file.info ? t('home.import.merge.messageCount', { count: file.info.messageCount.toLocaleString() }) : ''
</script>
<template>
@@ -294,24 +303,97 @@ function getFileProgressText(file: BatchFileInfo): string {
<!-- 文件列表 -->
<div class="max-h-80 space-y-2 overflow-y-auto">
<div
<FileListItem
v-for="(file, index) in batchFiles"
:key="file.path"
class="flex items-center gap-3 rounded-lg px-3 py-2 transition-colors"
:class="{
'bg-pink-50/50 dark:bg-pink-500/5': file.status === 'importing',
}"
>
<UIcon :name="getFileStatusIcon(file)" class="h-5 w-5 shrink-0" :class="getFileStatusClass(file)" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-gray-900 dark:text-white">
{{ file.name }}
:name="file.name"
:status-icon="getStatusIcon(file.status)"
:status-class="getStatusClass(file.status)"
:progress-text="getBatchFileProgressText(file)"
:index="index"
:total="batchFiles.length"
:highlight="file.status === 'importing'"
/>
</div>
</div>
<!-- 合并导入进度 -->
<div
v-else-if="isMergeImporting && mergeStage !== 'done'"
class="w-full max-w-4xl rounded-3xl border border-gray-200/50 bg-gray-100/50 px-8 py-8 backdrop-blur-md dark:border-white/10 dark:bg-gray-800/40"
>
<!-- 标题 -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-pink-50 dark:bg-pink-500/10">
<UIcon
v-if="mergeStage !== 'error'"
name="i-heroicons-arrow-path"
class="h-5 w-5 animate-spin text-pink-600 dark:text-pink-400"
/>
<UIcon v-else name="i-heroicons-x-circle" class="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p class="text-lg font-semibold text-gray-900 dark:text-white">
{{ mergeStage === 'error' ? t('home.import.merge.failed') : t('home.import.merge.importing') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ getFileProgressText(file) }}
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ mergeStage === 'parsing' ? t('home.import.merge.parsing') : '' }}
{{ mergeStage === 'merging' ? t('home.import.merge.merging') : '' }}
{{ mergeStage === 'error' ? mergeError : '' }}
</p>
</div>
<span class="text-xs text-gray-400">{{ index + 1 }}/{{ batchFiles.length }}</span>
</div>
<UButton
v-if="mergeStage === 'error'"
color="neutral"
variant="ghost"
size="sm"
icon="i-heroicons-x-mark"
@click="closeMergeResult"
/>
</div>
<!-- 文件列表 -->
<div class="max-h-80 space-y-2 overflow-y-auto">
<FileListItem
v-for="(file, index) in mergeFiles"
:key="file.path"
:name="file.name"
:status-icon="getStatusIcon(file.status)"
:status-class="getStatusClass(file.status)"
:progress-text="getMergeFileProgressText(file)"
:index="index"
:total="mergeFiles.length"
:highlight="file.status === 'parsing'"
/>
</div>
</div>
<!-- 合并导入完成 -->
<div
v-else-if="isMergeImporting && mergeStage === 'done' && mergeResult"
class="w-full max-w-4xl rounded-3xl border border-gray-200/50 bg-gray-100/50 px-8 py-8 backdrop-blur-md dark:border-white/10 dark:bg-gray-800/40"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-green-50 dark:bg-green-500/10">
<UIcon name="i-heroicons-check-circle" class="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('home.import.merge.completed') }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('home.import.merge.completedHint', { count: mergeFiles.length }) }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-x-mark" @click="closeMergeResult" />
<UButton size="sm" @click="handleMergeGoToSession">
{{ t('home.import.batch.view') }}
</UButton>
</div>
</div>
</div>
@@ -360,32 +442,29 @@ function getFileProgressText(file: BatchFileInfo): string {
<!-- 文件列表 -->
<div class="max-h-80 space-y-2 overflow-y-auto">
<div
v-for="file in batchImportResult.files"
<FileListItem
v-for="(file, index) in batchImportResult.files"
:key="file.path"
class="flex items-center gap-3 rounded-lg px-3 py-2"
:name="file.name"
:status-icon="getStatusIcon(file.status)"
:status-class="getStatusClass(file.status)"
:index="index"
:total="batchImportResult.files.length"
>
<UIcon :name="getFileStatusIcon(file)" class="h-5 w-5 shrink-0" :class="getFileStatusClass(file)" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-gray-900 dark:text-white">
{{ file.name }}
</p>
<template #extra>
<p v-if="file.status === 'failed'" class="text-xs text-red-500">
{{ translateError(file.error || 'error.import_failed') }}
</p>
<p v-else-if="file.status === 'cancelled'" class="text-xs text-gray-500">
{{ t('home.import.batch.skipped') }}
</p>
</div>
<UButton
v-if="file.status === 'success' && file.sessionId"
size="xs"
variant="soft"
@click="handleGoToSession(file.sessionId)"
>
{{ t('home.import.batch.view') }}
</UButton>
</div>
</template>
<template v-if="file.status === 'success' && file.sessionId" #action>
<UButton size="xs" variant="soft" @click="handleGoToSession(file.sessionId!)">
{{ t('home.import.batch.view') }}
</UButton>
</template>
</FileListItem>
</div>
</div>
@@ -400,7 +479,7 @@ function getFileProgressText(file: BatchFileInfo): string {
>
<template #default="{ isDragOver }">
<div
class="group relative flex w-full cursor-pointer flex-col items-center justify-center rounded-3xl border border-gray-200/50 bg-gray-100/50 px-8 py-10 backdrop-blur-md transition-all duration-300 hover:border-pink-500/30 hover:bg-gray-100/80 hover:shadow-2xl hover:shadow-pink-500/10 focus:outline-none focus:ring-4 focus:ring-pink-500/20 sm:px-12 sm:py-14 dark:border-white/10 dark:bg-gray-800/40 dark:hover:border-pink-500/30 dark:hover:bg-gray-800/60"
class="group relative flex w-full cursor-pointer flex-col items-center justify-center rounded-3xl border border-gray-200/50 bg-gray-100/50 px-8 py-4 backdrop-blur-md transition-all duration-300 hover:border-pink-500/30 hover:bg-gray-100/80 hover:shadow-2xl hover:shadow-pink-500/10 focus:outline-none focus:ring-4 focus:ring-pink-500/20 sm:px-12 sm:py-6 dark:border-white/10 dark:bg-gray-800/40 dark:hover:border-pink-500/30 dark:hover:bg-gray-800/60"
:class="{
'border-pink-500/50 bg-pink-50/50 dark:border-pink-400/50 dark:bg-pink-500/10':
isDragOver && !isAnyImporting,
@@ -408,9 +487,9 @@ function getFileProgressText(file: BatchFileInfo): string {
}"
@click="!isAnyImporting && handleClickImport()"
>
<!-- Icon -->
<!-- 上传图标容器 -->
<div
class="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-pink-50 transition-transform duration-300 group-hover:scale-105 dark:bg-pink-500/10"
class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-105"
:class="{ 'scale-105': isDragOver && !isAnyImporting, 'animate-pulse': isImporting }"
>
<UIcon
@@ -447,6 +526,30 @@ function getFileProgressText(file: BatchFileInfo): string {
</template>
</FileDropZone>
<!-- 合并导入选项 -->
<div v-if="!isAnyImporting && !batchImportResult" class="flex items-center justify-center">
<div class="flex items-center gap-2">
<UCheckbox
v-model="mergeImportEnabled"
:label="t('home.import.options.mergeImport')"
input-class="h-4 w-4"
size="sm"
label-class="text-sm font-medium text-gray-600 dark:text-gray-300"
/>
<UPopover mode="hover">
<UIcon
name="i-heroicons-question-mark-circle"
class="h-4 w-4 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
/>
<template #content>
<div class="max-w-xs px-3 py-2 text-xs text-gray-600 dark:text-gray-300">
{{ t('home.import.options.mergeImportHint') }}
</div>
</template>
</UPopover>
</div>
</div>
<!-- Error Message -->
<div
v-if="importError"
+3 -7
View File
@@ -29,12 +29,9 @@ const features = computed(() => [
<div class="relative h-full w-full overflow-y-auto">
<div class="flex min-h-full w-full flex-col items-center justify-center px-4 py-12">
<!-- Hero Section -->
<div class="relative xl:mb-6 mb-4 text-center">
<!-- Draggable Area above title -->
<div
class="absolute -top-24 left-1/2 h-24 w-full max-w-3xl -translate-x-1/2"
style="-webkit-app-region: drag"
/>
<div class="relative xl:mb-6 mb-4 w-full text-center">
<!-- 标题上方可拖拽区域向上扩展覆盖空隙 -->
<div class="absolute -top-32 left-0 right-0 h-32" style="-webkit-app-region: drag" />
<!-- Title -->
<h1 class="mb-4 select-none text-5xl sm:text-5xl lg:text-6xl font-black tracking-tight text-pink-500">
{{ t('home.title') }}
@@ -42,7 +39,6 @@ const features = computed(() => [
<!-- Description -->
<div class="relative select-none inline-block mb-8">
<p class="text-lg sm:text-5xl text-gray-700 dark:text-gray-400 font-medium">{{ t('home.subtitle') }}</p>
<UIcon name="i-heroicons-sparkles" class="absolute -right-6 -top-3 h-5 w-5 animate-bounce text-pink-400" />
</div>
</div>
@@ -147,6 +147,7 @@ watch(
:session="session"
:total-duration-days="totalDurationDays"
:total-daily-avg-messages="totalDailyAvgMessages"
:time-range="timeRange"
/>
<!-- 双方消息对比 -->
+166
View File
@@ -42,6 +42,34 @@ export interface BatchImportResult {
files: BatchFileInfo[]
}
/** 合并导入文件状态 */
export type MergeFileStatus = 'pending' | 'parsing' | 'done'
/** 合并导入单个文件信息 */
export interface MergeFileInfo {
path: string
name: string
status: MergeFileStatus
info?: {
name: string
format: string
platform: string
messageCount: number
memberCount: number
fileSize?: number
}
}
/** 合并导入阶段 */
export type MergeImportStage = 'parsing' | 'merging' | 'done' | 'error'
/** 合并导入结果 */
export interface MergeImportResult {
success: boolean
sessionId?: string
error?: string
}
/**
* 会话与导入相关的全局状态
*/
@@ -64,6 +92,13 @@ export const useSessionStore = defineStore(
const batchImportCancelled = ref(false)
const batchImportResult = ref<BatchImportResult | null>(null)
// 合并导入状态
const isMergeImporting = ref(false)
const mergeFiles = ref<MergeFileInfo[]>([])
const mergeStage = ref<MergeImportStage>('parsing')
const mergeError = ref<string | null>(null)
const mergeResult = ref<MergeImportResult | null>(null)
// 当前选中的会话
const currentSession = computed(() => {
if (!currentSessionId.value) return null
@@ -451,6 +486,129 @@ export const useSessionStore = defineStore(
batchFiles.value = []
}
/**
* 合并导入多个文件为一个会话
*/
async function mergeImportFiles(filePaths: string[]): Promise<MergeImportResult> {
if (filePaths.length < 2) {
return { success: false, error: '合并导入至少需要2个文件' }
}
// 阶段最小显示时间(和单文件导入保持一致)
const MIN_STAGE_TIME = 800
isMergeImporting.value = true
mergeError.value = null
mergeResult.value = null
mergeStage.value = 'parsing'
// 初始化文件列表
mergeFiles.value = filePaths.map((path) => ({
path,
name: path.split('/').pop() || path.split('\\').pop() || path,
status: 'pending' as MergeFileStatus,
}))
let stageStartTime = Date.now()
try {
// 阶段1:串行解析所有文件
for (let i = 0; i < mergeFiles.value.length; i++) {
const file = mergeFiles.value[i]
const fileStartTime = Date.now()
file.status = 'parsing'
try {
const info = await window.mergeApi.parseFileInfo(file.path)
file.info = info
// 确保每个文件的解析状态至少显示一定时间
const elapsed = Date.now() - fileStartTime
const minFileTime = Math.max(300, MIN_STAGE_TIME / filePaths.length)
if (elapsed < minFileTime) {
await new Promise((resolve) => setTimeout(resolve, minFileTime - elapsed))
}
file.status = 'done'
} catch (err) {
throw new Error(`解析文件失败: ${file.name} - ${err instanceof Error ? err.message : String(err)}`)
}
}
// 确保解析阶段至少显示 MIN_STAGE_TIME
const parsingElapsed = Date.now() - stageStartTime
if (parsingElapsed < MIN_STAGE_TIME) {
await new Promise((resolve) => setTimeout(resolve, MIN_STAGE_TIME - parsingElapsed))
}
// 阶段2:执行合并
stageStartTime = Date.now()
mergeStage.value = 'merging'
// 智能命名:如果所有文件群名相同则用该名,否则用第一个文件的群名
const names = mergeFiles.value.map((f) => f.info?.name).filter(Boolean)
const uniqueNames = [...new Set(names)]
const outputName = uniqueNames.length === 1 ? uniqueNames[0]! : (names[0] || '合并记录')
const result = await window.mergeApi.mergeFiles({
filePaths,
outputName,
conflictResolutions: [], // 默认 keepBoth(保留所有消息)
andAnalyze: true, // 合并后创建会话
})
if (!result.success) {
throw new Error(result.error || '合并失败')
}
// 清理缓存
await window.mergeApi.clearCache()
// 确保合并阶段至少显示 MIN_STAGE_TIME
const mergingElapsed = Date.now() - stageStartTime
if (mergingElapsed < MIN_STAGE_TIME) {
await new Promise((resolve) => setTimeout(resolve, MIN_STAGE_TIME - mergingElapsed))
}
mergeStage.value = 'done'
mergeResult.value = { success: true, sessionId: result.sessionId }
// 刷新会话列表
await loadSessions()
// 自动生成会话索引
if (result.sessionId) {
try {
const savedThreshold = localStorage.getItem('sessionGapThreshold')
const gapThreshold = savedThreshold ? parseInt(savedThreshold, 10) : 1800
await window.sessionApi.generate(result.sessionId, gapThreshold)
} catch (error) {
console.error('自动生成会话索引失败:', error)
}
}
return { success: true, sessionId: result.sessionId }
} catch (err) {
mergeStage.value = 'error'
const errorMessage = err instanceof Error ? err.message : String(err)
mergeError.value = errorMessage
mergeResult.value = { success: false, error: errorMessage }
// 清理缓存
await window.mergeApi.clearCache()
return { success: false, error: errorMessage }
}
}
/**
* 清除合并导入结果
*/
function clearMergeImportResult() {
isMergeImporting.value = false
mergeFiles.value = []
mergeResult.value = null
mergeError.value = null
}
/**
* 选择指定会话
*/
@@ -607,6 +765,14 @@ export const useSessionStore = defineStore(
importFilesFromPaths,
cancelBatchImport,
clearBatchImportResult,
// 合并导入
isMergeImporting,
mergeFiles,
mergeStage,
mergeError,
mergeResult,
mergeImportFiles,
clearMergeImportResult,
}
},
{
+2 -2
View File
@@ -81,13 +81,13 @@ export function formatWithDayjs(ts: number, format: string): string {
* 格式化日期范围(支持自定义格式)
* @param startTs 开始时间戳(秒)
* @param endTs 结束时间戳(秒)
* @param format 日期格式,默认 'YYYY.MM.DD'
* @param format 日期格式,默认 'YYYY/MM/DD'
* @param separator 分隔符,默认 ' - '
*/
export function formatDateRange(
startTs: number,
endTs: number,
format: string = 'YYYY.MM.DD',
format: string = 'YYYY/MM/DD',
separator: string = ' - '
): string {
const start = dayjs.unix(startTs).format(format)