refactor: 重构页面架构

This commit is contained in:
digua
2025-11-29 00:31:42 +08:00
parent acf6a3d26c
commit a637a8eb2e
6 changed files with 316 additions and 269 deletions
+44 -1
View File
@@ -1,8 +1,13 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import { useRoute } from 'vue-router'
import Sidebar from '@/components/Sidebar.vue'
const chatStore = useChatStore()
const { isInitialized } = storeToRefs(chatStore)
const route = useRoute()
// 应用启动时从数据库加载会话列表
onMounted(async () => {
@@ -12,6 +17,44 @@ onMounted(async () => {
<template>
<UApp>
<router-view />
<div class="flex h-screen w-full overflow-hidden bg-white dark:bg-gray-950">
<template v-if="!isInitialized">
<div class="flex h-full w-full items-center justify-center">
<div class="text-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
<p class="mt-2 text-sm text-gray-500">加载中...</p>
</div>
</div>
</template>
<template v-else>
<Sidebar />
<main class="flex-1 overflow-hidden">
<router-view v-slot="{ Component }">
<Transition name="page-fade" mode="out-in">
<component :is="Component" :key="route.fullPath" />
</Transition>
</router-view>
</main>
</template>
</div>
</UApp>
</template>
<style scoped>
.page-fade-enter-active,
.page-fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.page-fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
+11 -8
View File
@@ -2,6 +2,7 @@
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -11,7 +12,9 @@ dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const chatStore = useChatStore()
const { sessions, currentSessionId } = storeToRefs(chatStore)
const { sessions } = storeToRefs(chatStore)
const router = useRouter()
const route = useRoute()
const isCollapsed = ref(false)
const deleteConfirmId = ref<string | null>(null)
@@ -26,8 +29,8 @@ function toggleSidebar() {
}
function handleImport() {
// 清空当前会话选择,回到欢迎页(不触发导入弹窗)
chatStore.clearSelection()
// Navigate to home (Welcome Guide)
router.push('/')
}
function formatTime(timestamp: number): string {
@@ -91,7 +94,7 @@ function cancelDelete() {
<div class="space-y-1">
<div v-if="!isCollapsed && sessions.length > 0" class="mb-2 px-2 text-xs font-medium text-gray-500">
分析记录
聊天记录
</div>
<div
@@ -99,18 +102,18 @@ function cancelDelete() {
:key="session.id"
class="group relative flex w-full items-center rounded-full p-2 text-left transition-colors"
:class="[
currentSessionId === session.id && !isCollapsed
route.params.id === session.id && !isCollapsed
? 'bg-primary-100 text-gray-900 dark:bg-primary-900/30 dark:text-primary-100'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-200/60 dark:hover:bg-gray-800',
isCollapsed ? 'justify-center cursor-pointer' : 'cursor-pointer',
]"
@click="chatStore.selectSession(session.id)"
@click="router.push({ name: 'chat', params: { id: session.id } })"
>
<!-- Platform Icon / Text Avatar -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold"
:class="[
currentSessionId === session.id
route.params.id === session.id
? 'bg-primary-600 text-white dark:bg-primary-500 dark:text-white'
: 'bg-gray-400 text-white dark:bg-gray-600 dark:text-white',
isCollapsed ? '' : 'mr-3',
@@ -144,7 +147,7 @@ function cancelDelete() {
</template>
<template #content>
<div class="p-3">
<p class="mb-3 text-sm">确定删除此分析记录</p>
<p class="mb-3 text-sm">确定删除此记录</p>
<div class="flex justify-end gap-2">
<UButton size="xs" color="red" @click="handleDelete(session.id)">确定删除</UButton>
</div>
-235
View File
@@ -1,235 +0,0 @@
<script setup lang="ts">
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
const chatStore = useChatStore()
const { isImporting, importProgress } = storeToRefs(chatStore)
const importError = ref<string | null>(null)
const isDragOver = ref(false)
const features = [
{
icon: '🏆',
title: '活跃度分析',
desc: '谁是群里的潜水王?',
color: 'text-yellow-500',
bg: 'bg-yellow-50',
delay: '0ms',
},
{
icon: '☁️',
title: '词云生成',
desc: '大家最爱说什么?',
color: 'text-blue-500',
bg: 'bg-blue-50',
delay: '100ms',
},
{
icon: '❤️',
title: '情感分析',
desc: '群聊氛围怎么样?',
color: 'text-pink-500',
bg: 'bg-pink-50',
delay: '200ms',
},
]
async function handleImport() {
importError.value = null
const result = await chatStore.importFile()
if (!result.success && result.error && result.error !== '未选择文件') {
importError.value = result.error
}
}
// 拖拽事件处理
function handleDragOver(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragOver.value = true
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragOver.value = false
}
async function handleDrop(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragOver.value = false
if (isImporting.value) return
const files = e.dataTransfer?.files
if (!files || files.length === 0) return
const file = files[0]
// 使用 Electron 的 webUtils 获取文件真实路径
let filePath: string
try {
filePath = window.electron.webUtils.getPathForFile(file)
} catch (error) {
console.error('获取文件路径失败:', error)
importError.value = '无法读取文件路径'
return
}
if (!filePath) {
importError.value = '无法读取文件路径'
return
}
importError.value = null
const result = await chatStore.importFileFromPath(filePath)
if (!result.success && result.error) {
importError.value = result.error
}
}
function openTutorial(type: 'wechat' | 'qq') {
// TODO: 打开教程页面
console.log('Tutorial:', type)
}
function getProgressText(): string {
if (!importProgress.value) return ''
switch (importProgress.value.stage) {
case 'reading':
return '读取文件中...'
case 'parsing':
return '解析聊天记录...'
case 'saving':
return '保存数据...'
case 'done':
return '导入完成!'
case 'error':
return '导入失败'
default:
return ''
}
}
</script>
<template>
<div class="relative flex h-full w-full overflow-hidden bg-white dark:bg-gray-950">
<!-- Content Container -->
<div class="relative z-10 flex h-full w-full flex-col items-center justify-center px-4">
<!-- Hero Section -->
<div class="mb-12 text-center">
<div
class="mb-6 inline-flex items-center justify-center rounded-3xl bg-white p-4 shadow-lg shadow-pink-100 ring-1 ring-gray-100 dark:bg-gray-900 dark:shadow-pink-900/20 dark:ring-gray-800"
:class="[isImporting ? '' : 'animate-bounce']"
>
<UIcon v-if="!isImporting" name="i-heroicons-sparkles" class="h-8 w-8 text-pink-500" />
<UIcon v-else name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
</div>
<h1
class="mb-4 bg-linear-to-r from-pink-600 via-pink-500 to-rose-400 bg-clip-text text-5xl font-black tracking-tight text-transparent sm:text-6xl"
>
ChatLab
</h1>
<p class="text-lg font-medium text-gray-500 dark:text-gray-400">你的AI聊天分析实验室</p>
</div>
<!-- Feature Cards -->
<div class="mb-12 grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-3">
<div
v-for="feature in features"
:key="feature.title"
class="group relative transform cursor-default rounded-2xl border border-gray-100 bg-white p-6 shadow-sm transition-all duration-300 hover:-translate-y-2 hover:shadow-xl hover:shadow-pink-500/10 dark:border-gray-800 dark:bg-gray-900"
:style="{ animationDelay: feature.delay }"
>
<div class="mb-4 text-4xl transition-transform duration-300 group-hover:scale-110">
{{ feature.icon }}
</div>
<h3 class="mb-2 text-lg font-bold text-gray-900 dark:text-white">{{ feature.title }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ feature.desc }}</p>
</div>
</div>
<!-- Actions -->
<div class="flex flex-col items-center space-y-6">
<!-- Import Drop Zone -->
<div
class="group relative flex w-full max-w-2xl cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-pink-300 bg-white px-12 py-12 transition-all duration-300 hover:border-pink-400 hover:bg-pink-50/50 focus:outline-none focus:ring-4 focus:ring-pink-500/20 dark:border-pink-700 dark:bg-gray-900 dark:hover:border-pink-500 dark:hover:bg-pink-900/10"
:class="{
'border-pink-500 bg-pink-50 dark:border-pink-400 dark:bg-pink-900/20': isDragOver && !isImporting,
'cursor-not-allowed opacity-70': isImporting,
'hover:scale-[1.02] hover:shadow-xl hover:shadow-pink-500/10': !isImporting,
}"
@click="!isImporting && handleImport()"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<!-- Icon -->
<div
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-linear-to-br from-pink-100 to-rose-100 transition-transform duration-300 dark:from-pink-900/30 dark:to-rose-900/30"
:class="{ 'scale-110': isDragOver && !isImporting, 'animate-pulse': isImporting }"
>
<UIcon
v-if="!isImporting"
name="i-heroicons-arrow-up-tray"
class="h-8 w-8 text-pink-600 transition-transform group-hover:-translate-y-1 dark:text-pink-400"
/>
<UIcon v-else name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-600 dark:text-pink-400" />
</div>
<!-- Text -->
<div class="w-full text-center">
<template v-if="isImporting && importProgress">
<!-- 导入中显示进度 -->
<p class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ getProgressText() }}</p>
<div class="mx-auto w-full max-w-md">
<UProgress :value="importProgress.progress" size="md" color="pink" />
</div>
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">
{{ importProgress.message }}
</p>
</template>
<template v-else>
<!-- 默认状态 -->
<p class="text-lg font-semibold text-gray-900 dark:text-white">
{{ isDragOver ? '松开鼠标导入文件' : '点击选择或拖拽文件到这里' }}
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">支持 QQ微信聊天记录JSON/TXT 格式</p>
</template>
</div>
</div>
<!-- Error Message -->
<div
v-if="importError"
class="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
>
<UIcon name="i-heroicons-exclamation-circle" class="h-5 w-5 shrink-0" />
<span>{{ importError }}</span>
</div>
<!-- Tutorial Links -->
<div class="flex items-center space-x-6 text-sm font-medium text-gray-400">
<button
class="flex items-center transition-colors hover:text-gray-600 dark:hover:text-gray-300"
@click="openTutorial('wechat')"
>
<UIcon name="i-simple-icons-wechat" class="mr-1.5 h-4 w-4" />
微信导入教程
</button>
<span class="h-1 w-1 rounded-full bg-gray-300 dark:bg-gray-600"></span>
<button
class="flex items-center transition-colors hover:text-gray-600 dark:hover:text-gray-300"
@click="openTutorial('qq')"
>
<UIcon name="i-simple-icons-tencentqq" class="mr-1.5 h-4 w-4" />
QQ导入教程
</button>
</div>
</div>
</div>
</div>
</template>
@@ -1,14 +1,17 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import type { AnalysisSession, MemberActivity, HourlyActivity, DailyActivity, MessageType } from '@/types/chat'
import UITabs from '@/components/UI/Tabs.vue'
import OverviewTab from './analysis/OverviewTab.vue'
import MembersTab from './analysis/MembersTab.vue'
import TimeTab from './analysis/TimeTab.vue'
import TimelineTab from './analysis/TimelineTab.vue'
import OverviewTab from '@/components/analysis/OverviewTab.vue'
import MembersTab from '@/components/analysis/MembersTab.vue'
import TimeTab from '@/components/analysis/TimeTab.vue'
import TimelineTab from '@/components/analysis/TimelineTab.vue'
const route = useRoute()
const router = useRouter()
const chatStore = useChatStore()
const { currentSessionId } = storeToRefs(chatStore)
@@ -76,6 +79,18 @@ const filteredMemberCount = computed(() => {
return memberActivity.value.filter((m) => m.messageCount > 0).length
})
// Sync route param to store
function syncSession() {
const id = route.params.id as string
if (id) {
chatStore.selectSession(id)
// If selection failed (e.g. invalid ID), redirect to home
if (chatStore.currentSessionId !== id) {
router.replace('/')
}
}
}
//
async function loadBaseData() {
if (!currentSessionId.value) return
@@ -137,7 +152,15 @@ async function loadData() {
isInitialLoad.value = false
}
//
//
watch(
() => route.params.id,
() => {
syncSession()
}
)
// (syncSession currentSessionId )
watch(
currentSessionId,
() => {
@@ -154,7 +177,10 @@ watch(selectedYear, () => {
loadAnalysisData()
})
onMounted(loadData)
onMounted(() => {
syncSession()
// loadData is triggered by currentSessionId watch
})
</script>
<template>
+223 -18
View File
@@ -1,30 +1,235 @@
<script setup lang="ts">
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import Sidebar from '@/components/Sidebar.vue'
import WelcomeGuide from '@/components/WelcomeGuide.vue'
import AnalysisDashboard from '@/components/AnalysisDashboard.vue'
import { ref } from 'vue'
const chatStore = useChatStore()
const { currentSessionId, isInitialized } = storeToRefs(chatStore)
const { isImporting, importProgress } = storeToRefs(chatStore)
const importError = ref<string | null>(null)
const isDragOver = ref(false)
const features = [
{
icon: '🏆',
title: '活跃度分析',
desc: '谁是群里的潜水王?',
color: 'text-yellow-500',
bg: 'bg-yellow-50',
delay: '0ms',
},
{
icon: '☁️',
title: '词云生成',
desc: '大家最爱说什么?',
color: 'text-blue-500',
bg: 'bg-blue-50',
delay: '100ms',
},
{
icon: '❤️',
title: '情感分析',
desc: '群聊氛围怎么样?',
color: 'text-pink-500',
bg: 'bg-pink-50',
delay: '200ms',
},
]
async function handleImport() {
importError.value = null
const result = await chatStore.importFile()
if (!result.success && result.error && result.error !== '未选择文件') {
importError.value = result.error
}
}
// 拖拽事件处理
function handleDragOver(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragOver.value = true
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragOver.value = false
}
async function handleDrop(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragOver.value = false
if (isImporting.value) return
const files = e.dataTransfer?.files
if (!files || files.length === 0) return
const file = files[0]
// 使用 Electron 的 webUtils 获取文件真实路径
let filePath: string
try {
filePath = window.electron.webUtils.getPathForFile(file)
} catch (error) {
console.error('获取文件路径失败:', error)
importError.value = '无法读取文件路径'
return
}
if (!filePath) {
importError.value = '无法读取文件路径'
return
}
importError.value = null
const result = await chatStore.importFileFromPath(filePath)
if (!result.success && result.error) {
importError.value = result.error
}
}
function openTutorial(type: 'wechat' | 'qq') {
// TODO: 打开教程页面
console.log('Tutorial:', type)
}
function getProgressText(): string {
if (!importProgress.value) return ''
switch (importProgress.value.stage) {
case 'reading':
return '读取文件中...'
case 'parsing':
return '解析聊天记录...'
case 'saving':
return '保存数据...'
case 'done':
return '导入完成!'
case 'error':
return '导入失败'
default:
return ''
}
}
</script>
<template>
<div class="flex h-screen w-full overflow-hidden bg-white dark:bg-gray-950">
<template v-if="!isInitialized">
<div class="flex h-full w-full items-center justify-center">
<div class="text-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
<p class="mt-2 text-sm text-gray-500">加载中...</p>
<div class="relative flex h-full w-full overflow-hidden bg-white dark:bg-gray-950">
<!-- Content Container -->
<div class="relative z-10 flex h-full w-full flex-col items-center justify-center px-4">
<!-- Hero Section -->
<div class="mb-12 text-center">
<div
class="mb-6 inline-flex items-center justify-center rounded-3xl bg-white p-4 shadow-lg shadow-pink-100 ring-1 ring-gray-100 dark:bg-gray-900 dark:shadow-pink-900/20 dark:ring-gray-800"
:class="[isImporting ? '' : 'animate-bounce']"
>
<UIcon v-if="!isImporting" name="i-heroicons-sparkles" class="h-8 w-8 text-pink-500" />
<UIcon v-else name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
</div>
<h1
class="mb-4 bg-linear-to-r from-pink-600 via-pink-500 to-rose-400 bg-clip-text text-5xl font-black tracking-tight text-transparent sm:text-6xl"
>
ChatLab
</h1>
<p class="text-lg font-medium text-gray-500 dark:text-gray-400">你的AI聊天分析实验室</p>
</div>
<!-- Feature Cards -->
<div class="mb-12 grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-3">
<div
v-for="feature in features"
:key="feature.title"
class="group relative transform cursor-default rounded-2xl border border-gray-100 bg-white p-6 shadow-sm transition-all duration-300 hover:-translate-y-2 hover:shadow-xl hover:shadow-pink-500/10 dark:border-gray-800 dark:bg-gray-900"
:style="{ animationDelay: feature.delay }"
>
<div class="mb-4 text-4xl transition-transform duration-300 group-hover:scale-110">
{{ feature.icon }}
</div>
<h3 class="mb-2 text-lg font-bold text-gray-900 dark:text-white">{{ feature.title }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ feature.desc }}</p>
</div>
</div>
</template>
<template v-else>
<Sidebar />
<main class="flex-1 overflow-hidden">
<WelcomeGuide v-if="!currentSessionId" />
<AnalysisDashboard v-else />
</main>
</template>
<!-- Actions -->
<div class="flex flex-col items-center space-y-6">
<!-- Import Drop Zone -->
<div
class="group relative flex w-full max-w-2xl cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-pink-300 bg-white px-12 py-12 transition-all duration-300 hover:border-pink-400 hover:bg-pink-50/50 focus:outline-none focus:ring-4 focus:ring-pink-500/20 dark:border-pink-700 dark:bg-gray-900 dark:hover:border-pink-500 dark:hover:bg-pink-900/10"
:class="{
'border-pink-500 bg-pink-50 dark:border-pink-400 dark:bg-pink-900/20': isDragOver && !isImporting,
'cursor-not-allowed opacity-70': isImporting,
'hover:scale-[1.02] hover:shadow-xl hover:shadow-pink-500/10': !isImporting,
}"
@click="!isImporting && handleImport()"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<!-- Icon -->
<div
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-linear-to-br from-pink-100 to-rose-100 transition-transform duration-300 dark:from-pink-900/30 dark:to-rose-900/30"
:class="{ 'scale-110': isDragOver && !isImporting, 'animate-pulse': isImporting }"
>
<UIcon
v-if="!isImporting"
name="i-heroicons-arrow-up-tray"
class="h-8 w-8 text-pink-600 transition-transform group-hover:-translate-y-1 dark:text-pink-400"
/>
<UIcon v-else name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-600 dark:text-pink-400" />
</div>
<!-- Text -->
<div class="w-full text-center">
<template v-if="isImporting && importProgress">
<!-- 导入中显示进度 -->
<p class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ getProgressText() }}</p>
<div class="mx-auto w-full max-w-md">
<UProgress :value="importProgress.progress" size="md" color="pink" />
</div>
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">
{{ importProgress.message }}
</p>
</template>
<template v-else>
<!-- 默认状态 -->
<p class="text-lg font-semibold text-gray-900 dark:text-white">
{{ isDragOver ? '松开鼠标导入文件' : '点击选择或拖拽文件到这里' }}
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">支持 QQ微信聊天记录JSON/TXT 格式</p>
</template>
</div>
</div>
<!-- Error Message -->
<div
v-if="importError"
class="flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
>
<UIcon name="i-heroicons-exclamation-circle" class="h-5 w-5 shrink-0" />
<span>{{ importError }}</span>
</div>
<!-- Tutorial Links -->
<div class="flex items-center space-x-6 text-sm font-medium text-gray-400">
<button
class="flex items-center transition-colors hover:text-gray-600 dark:hover:text-gray-300"
@click="openTutorial('wechat')"
>
<UIcon name="i-simple-icons-wechat" class="mr-1.5 h-4 w-4" />
微信导入教程
</button>
<span class="h-1 w-1 rounded-full bg-gray-300 dark:bg-gray-600"></span>
<button
class="flex items-center transition-colors hover:text-gray-600 dark:hover:text-gray-300"
@click="openTutorial('qq')"
>
<UIcon name="i-simple-icons-tencentqq" class="mr-1.5 h-4 w-4" />
QQ导入教程
</button>
</div>
</div>
</div>
</div>
</template>
+6 -1
View File
@@ -4,9 +4,14 @@ export const router = createRouter({
routes: [
{
path: '/',
name: 'index',
name: 'home',
component: () => import('@/pages/index.vue'),
},
{
path: '/chat/:id',
name: 'chat',
component: () => import('@/pages/chat.vue'),
},
],
history: createWebHashHistory(),
})