feat: 完成聊天记录解析和基础数据渲染

This commit is contained in:
digua
2025-11-26 23:24:07 +08:00
parent 807b3925b3
commit 9667dc615a
34 changed files with 4714 additions and 714 deletions
+12
View File
@@ -1,8 +1,20 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import ImportProgressModal from '@/components/ImportProgressModal.vue'
const chatStore = useChatStore()
// 应用启动时从数据库加载会话列表
onMounted(async () => {
await chatStore.loadSessions()
})
</script>
<template>
<UApp>
<router-view />
<!-- 全局导入进度弹窗 -->
<ImportProgressModal />
</UApp>
</template>
+10 -8
View File
@@ -13,23 +13,25 @@ declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
UApp: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/App.vue')['default']
UAvatar: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Avatar.vue')['default']
UApp: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/App.vue')['default']
UAvatar: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Avatar.vue')['default']
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
UProgress: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Progress.vue')['default']
UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
UProgress: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Progress.vue')['default']
USelect: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default']
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
USkeleton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Skeleton.vue')['default']
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
UTextarea: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Textarea.vue')['default']
UToaster: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Toaster.vue')['default']
UTooltip: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.2__26fe4596361b2ba7cfd995b4ae348088/node_modules/@nuxt/ui/dist/runtime/components/Tooltip.vue')['default']
UTooltip: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Tooltip.vue')['default']
}
}
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const chatStore = useChatStore()
const { isImporting, importProgress } = storeToRefs(chatStore)
const isOpen = computed(() => isImporting.value)
const statusIcon = computed(() => {
if (!importProgress.value) return 'i-heroicons-arrow-path'
switch (importProgress.value.stage) {
case 'done':
return 'i-heroicons-check-circle'
case 'error':
return 'i-heroicons-exclamation-circle'
default:
return 'i-heroicons-arrow-path'
}
})
const statusColor = computed(() => {
if (!importProgress.value) return 'text-purple-500'
switch (importProgress.value.stage) {
case 'done':
return 'text-green-500'
case 'error':
return 'text-red-500'
default:
return 'text-purple-500'
}
})
const isSpinning = computed(() => {
if (!importProgress.value) return true
return !['done', 'error'].includes(importProgress.value.stage)
})
function getStageText(): 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>
<UModal v-model:open="isOpen" :ui="{ width: 'sm:max-w-md' }">
<template #content>
<div class="p-6 text-center">
<!-- Icon -->
<div class="mb-4 flex justify-center">
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800"
>
<UIcon
:name="statusIcon"
class="h-8 w-8"
:class="[statusColor, isSpinning ? 'animate-spin' : '']"
/>
</div>
</div>
<!-- Title -->
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ getStageText() }}
</h3>
<!-- Progress -->
<div v-if="importProgress" class="mb-4">
<UProgress
:value="importProgress.progress"
size="sm"
:color="importProgress.stage === 'error' ? 'red' : 'purple'"
/>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ importProgress.message }}
</p>
</div>
<!-- Hint -->
<p v-if="isSpinning" class="text-xs text-gray-400">
请稍候正在处理您的聊天记录...
</p>
</div>
</template>
</UModal>
</template>
+115 -35
View File
@@ -1,20 +1,66 @@
<script setup lang="ts">
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { ChatPlatform } from '@/types/chat'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const chatStore = useChatStore()
const { sessions, currentSessionId } = storeToRefs(chatStore)
const { sessions, currentSessionId, isImporting } = storeToRefs(chatStore)
const isCollapsed = ref(false)
const deleteConfirmId = ref<string | null>(null)
// 加载会话列表
onMounted(() => {
chatStore.loadSessions()
})
function toggleSidebar() {
isCollapsed.value = !isCollapsed.value
}
function handleImport() {
// TODO: Implement import logic
console.log('Import clicked')
// 清空当前会话选择,回到欢迎页(不触发导入弹窗)
chatStore.clearSelection()
}
function getPlatformIcon(platform: ChatPlatform): string {
switch (platform) {
case ChatPlatform.QQ:
return 'i-simple-icons-tencentqq'
case ChatPlatform.WECHAT:
return 'i-simple-icons-wechat'
case ChatPlatform.TELEGRAM:
return 'i-simple-icons-telegram'
case ChatPlatform.DISCORD:
return 'i-simple-icons-discord'
default:
return 'i-heroicons-chat-bubble-left-right'
}
}
function formatTime(timestamp: number): string {
return dayjs.unix(timestamp).fromNow()
}
function confirmDelete(id: string, event: Event) {
event.stopPropagation()
deleteConfirmId.value = id
}
async function handleDelete(id: string) {
await chatStore.deleteSession(id)
deleteConfirmId.value = null
}
function cancelDelete() {
deleteConfirmId.value = null
}
</script>
@@ -27,28 +73,22 @@ function handleImport() {
<div class="flex flex-col p-4">
<!-- Header / Toggle -->
<div class="mb-6 flex items-center" :class="[isCollapsed ? 'justify-center' : 'justify-between']">
<div v-if="!isCollapsed" class="text-lg font-semibold text-gray-900 dark:text-white">
ChatLens
</div>
<UButton
icon="i-heroicons-bars-3"
color="gray"
variant="ghost"
size="sm"
@click="toggleSidebar"
/>
<div v-if="!isCollapsed" class="text-lg font-semibold text-gray-900 dark:text-white">ChatLens</div>
<UButton icon="i-heroicons-bars-3" color="gray" variant="ghost" size="sm" @click="toggleSidebar" />
</div>
<!-- New Analysis Button -->
<UTooltip :text="isCollapsed ? '新建分析' : ''" :popper="{ placement: 'right' }">
<UTooltip :text="isCollapsed ? '分析新的聊天' : ''" :popper="{ placement: 'right' }">
<UButton
block
class="transition-all"
:class="[isCollapsed ? 'px-0' : '']"
color="gray"
variant="solid"
:icon="isCollapsed ? 'i-heroicons-plus' : 'i-heroicons-plus'"
:label="isCollapsed ? '' : '新建分析'"
:icon="isImporting ? '' : 'i-heroicons-plus'"
:label="isCollapsed ? '' : '分析新的聊天'"
:loading="isImporting"
:disabled="isImporting"
@click="handleImport"
/>
</UTooltip>
@@ -56,44 +96,84 @@ function handleImport() {
<!-- Session List -->
<div class="flex-1 overflow-y-auto px-3">
<div v-if="sessions.length === 0 && !isCollapsed" class="py-8 text-center text-sm text-gray-500">
暂无记录
</div>
<div v-if="sessions.length === 0 && !isCollapsed" class="py-8 text-center text-sm text-gray-500">暂无记录</div>
<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>
<button
<div
v-for="session in sessions"
:key="session.id"
class="flex w-full items-center rounded-full p-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-800"
class="group relative flex w-full items-center rounded-lg p-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-800"
:class="[
currentSessionId === session.id ? 'bg-primary-100 text-primary-900 dark:bg-primary-900/30 dark:text-primary-100' : 'text-gray-700 dark:text-gray-200',
isCollapsed ? 'justify-center' : ''
currentSessionId === session.id
? 'bg-primary-100 text-primary-900 dark:bg-primary-900/30 dark:text-primary-100'
: 'text-gray-700 dark:text-gray-200',
isCollapsed ? 'justify-center cursor-pointer' : 'cursor-pointer',
]"
@click="chatStore.selectSession(session.id)"
>
<UAvatar
:src="session.avatar"
:alt="session.name"
size="sm"
:class="[isCollapsed ? '' : 'mr-3']"
/>
<!-- Platform Icon -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
:class="[
currentSessionId === session.id ? 'bg-primary-200 dark:bg-primary-800' : 'bg-gray-200 dark:bg-gray-700',
isCollapsed ? '' : 'mr-3',
]"
>
<UIcon :name="getPlatformIcon(session.platform)" class="h-5 w-5" />
</div>
<!-- Session Info -->
<div v-if="!isCollapsed" class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ session.name }}
</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ session.messageCount }} 条消息 · {{ formatTime(session.importedAt) }}
</p>
</div>
</button>
<!-- Delete Button -->
<div v-if="!isCollapsed" class="shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
<UPopover v-if="deleteConfirmId === session.id" :open="true" @update:open="cancelDelete">
<template #default>
<UButton
icon="i-heroicons-trash"
color="red"
variant="ghost"
size="xs"
@click="(e: Event) => confirmDelete(session.id, e)"
/>
</template>
<template #content>
<div class="p-3">
<p class="mb-3 text-sm">确定删除此分析记录</p>
<div class="flex justify-end gap-2">
<UButton size="xs" color="gray" variant="ghost" @click="cancelDelete">取消</UButton>
<UButton size="xs" color="red" @click="handleDelete(session.id)">删除</UButton>
</div>
</div>
</template>
</UPopover>
<UButton
v-else
icon="i-heroicons-trash"
color="gray"
variant="ghost"
size="xs"
@click="(e: Event) => confirmDelete(session.id, e)"
/>
</div>
</div>
</div>
</div>
<!-- Footer (Optional settings or help) -->
<!-- Footer -->
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
<UTooltip :text="isCollapsed ? '设置' : ''" :popper="{ placement: 'right' }">
<UTooltip :text="isCollapsed ? '设置' : ''" :popper="{ placement: 'right' }">
<UButton
block
color="gray"
@@ -102,7 +182,7 @@ function handleImport() {
:label="isCollapsed ? '' : '设置'"
:class="[isCollapsed ? 'px-0' : 'justify-start']"
/>
</UTooltip>
</UTooltip>
</div>
</div>
</template>
+100 -26
View File
@@ -1,4 +1,13 @@
<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 features = [
{
icon: '🏆',
@@ -26,30 +35,57 @@ const features = [
},
]
function handleImport() {
console.log('Import clicked')
async function handleImport() {
importError.value = null
const result = await chatStore.importFile()
if (!result.success && result.error && result.error !== '未选择文件') {
importError.value = result.error
}
}
function openTutorial(type: 'ios' | 'android') {
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">
<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 animate-bounce items-center justify-center rounded-3xl bg-white p-4 shadow-lg shadow-purple-100 ring-1 ring-gray-100">
<UIcon name="i-heroicons-sparkles" class="h-8 w-8 text-purple-500" />
<div
class="mb-6 inline-flex items-center justify-center rounded-3xl bg-white p-4 shadow-lg shadow-purple-100 ring-1 ring-gray-100 dark:bg-gray-900 dark:shadow-purple-900/20 dark:ring-gray-800"
:class="[isImporting ? '' : 'animate-bounce']"
>
<UIcon v-if="!isImporting" name="i-heroicons-sparkles" class="h-8 w-8 text-purple-500" />
<UIcon v-else name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-purple-500" />
</div>
<h1 class="mb-4 bg-gradient-to-r from-purple-600 via-pink-500 to-orange-400 bg-clip-text text-5xl font-black tracking-tight text-transparent sm:text-6xl">
探索你的群聊记忆
<h1
class="mb-4 bg-linear-to-r from-purple-600 via-pink-500 to-orange-400 bg-clip-text text-5xl font-black tracking-tight text-transparent sm:text-6xl"
>
ChatLens
</h1>
<p class="text-lg font-medium text-gray-500">
ChatLens 帮你发现那些被遗忘的有趣瞬间
</p>
<p class="text-lg font-medium text-gray-500 dark:text-gray-400">ChatLens 帮你发现那些被遗忘的有趣瞬间</p>
</div>
<!-- Feature Cards -->
@@ -57,36 +93,74 @@ function openTutorial(type: 'ios' | 'android') {
<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-purple-500/10"
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-purple-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">{{ feature.title }}</h3>
<p class="text-sm text-gray-500">{{ feature.desc }}</p>
<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-8">
<div class="flex flex-col items-center space-y-6">
<!-- Import Button -->
<button
class="group relative inline-flex items-center justify-center rounded-full bg-gradient-to-r from-purple-500 to-pink-500 px-8 py-4 text-lg font-bold text-white shadow-lg shadow-purple-500/30 transition-all duration-300 hover:scale-105 hover:shadow-purple-500/50 focus:outline-none focus:ring-4 focus:ring-purple-500/30"
class="group relative inline-flex items-center justify-center rounded-full bg-linear-to-r from-purple-500 to-pink-500 px-8 py-4 text-lg font-bold text-white shadow-lg shadow-purple-500/30 transition-all duration-300 focus:outline-none focus:ring-4 focus:ring-purple-500/30 disabled:cursor-not-allowed disabled:opacity-70"
:class="[isImporting ? '' : 'hover:scale-105 hover:shadow-purple-500/50']"
:disabled="isImporting"
@click="handleImport"
>
<UIcon name="i-heroicons-arrow-up-tray" class="mr-2 h-5 w-5 transition-transform group-hover:-translate-y-1" />
立即导入聊天记录
<template v-if="isImporting">
<UIcon name="i-heroicons-arrow-path" class="mr-2 h-5 w-5 animate-spin" />
{{ getProgressText() }}
</template>
<template v-else>
<UIcon
name="i-heroicons-arrow-up-tray"
class="mr-2 h-5 w-5 transition-transform group-hover:-translate-y-1"
/>
立即导入聊天记录
</template>
</button>
<!-- Progress Bar -->
<div v-if="isImporting && importProgress" class="w-64">
<UProgress :value="importProgress.progress" size="sm" color="purple" />
<p class="mt-2 text-center text-xs text-gray-500">
{{ importProgress.message }}
</p>
</div>
<!-- Error Message -->
<div v-if="importError" class="flex items-center text-sm text-red-500">
<UIcon name="i-heroicons-exclamation-circle" class="mr-1.5 h-4 w-4" />
{{ importError }}
</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" @click="openTutorial('ios')">
<UIcon name="i-simple-icons-apple" class="mr-1.5 h-4 w-4" />
iOS 教程
<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"></span>
<button class="flex items-center transition-colors hover:text-gray-600" @click="openTutorial('android')">
<UIcon name="i-simple-icons-android" class="mr-1.5 h-4 w-4" />
Android 教程
<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>
<!-- Supported Formats -->
<p class="text-xs text-gray-400 dark:text-gray-500">支持 QQ微信聊天记录JSON/TXT 格式</p>
</div>
</div>
</div>
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
import type { AnalysisSession, MemberActivity, HourlyActivity, DailyActivity, MessageType } from '@/types/chat'
import OverviewTab from './OverviewTab.vue'
import MembersTab from './MembersTab.vue'
import TimelineTab from './TimelineTab.vue'
const chatStore = useChatStore()
const { currentSessionId } = storeToRefs(chatStore)
// 数据状态
const isLoading = ref(true)
const session = ref<AnalysisSession | null>(null)
const memberActivity = ref<MemberActivity[]>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
const timeRange = ref<{ start: number; end: number } | null>(null)
// 年份筛选
const availableYears = ref<number[]>([])
const selectedYear = ref<number | null>(null) // null 表示全部
// Tab 配置
const tabs = [
{ id: 'overview', label: '总览', icon: 'i-heroicons-chart-pie' },
{ id: 'members', label: '成员', icon: 'i-heroicons-user-group' },
{ id: 'timeline', label: '时间', icon: 'i-heroicons-chart-bar' },
]
const activeTab = ref('overview')
// 计算时间过滤参数
const timeFilter = computed(() => {
if (selectedYear.value === null) {
return undefined
}
// 计算年份的开始和结束时间戳
const startDate = new Date(selectedYear.value, 0, 1, 0, 0, 0)
const endDate = new Date(selectedYear.value, 11, 31, 23, 59, 59)
return {
startTs: Math.floor(startDate.getTime() / 1000),
endTs: Math.floor(endDate.getTime() / 1000),
}
})
// 年份选项
const yearOptions = computed(() => {
const options = [{ label: '全部时间', value: null as number | null }]
for (const year of availableYears.value) {
options.push({ label: `${year}`, value: year })
}
return options
})
// 计算属性
const topMembers = computed(() => memberActivity.value.slice(0, 3))
const bottomMembers = computed(() => {
if (memberActivity.value.length <= 1) return []
return [...memberActivity.value].sort((a, b) => a.messageCount - b.messageCount).slice(0, 1)
})
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
})
// 当前筛选后的活跃成员数
const filteredMemberCount = computed(() => {
return memberActivity.value.filter((m) => m.messageCount > 0).length
})
// 加载基础数据(不受年份筛选影响)
async function loadBaseData() {
if (!currentSessionId.value) return
try {
const [sessionData, years, range] = await Promise.all([
window.chatApi.getSession(currentSessionId.value),
window.chatApi.getAvailableYears(currentSessionId.value),
window.chatApi.getTimeRange(currentSessionId.value),
])
session.value = sessionData
availableYears.value = years
timeRange.value = range
} catch (error) {
console.error('加载基础数据失败:', error)
}
}
// 加载分析数据(受年份筛选影响)
async function loadAnalysisData() {
if (!currentSessionId.value) return
isLoading.value = true
try {
const filter = timeFilter.value
const [members, hourly, daily, types] = await Promise.all([
window.chatApi.getMemberActivity(currentSessionId.value, filter),
window.chatApi.getHourlyActivity(currentSessionId.value, filter),
window.chatApi.getDailyActivity(currentSessionId.value, filter),
window.chatApi.getMessageTypeDistribution(currentSessionId.value, filter),
])
memberActivity.value = members
hourlyActivity.value = hourly
dailyActivity.value = daily
messageTypes.value = types
} catch (error) {
console.error('加载分析数据失败:', error)
} finally {
isLoading.value = false
}
}
// 加载所有数据
async function loadData() {
await loadBaseData()
await loadAnalysisData()
}
// 监听会话变化
watch(
currentSessionId,
() => {
selectedYear.value = null // 重置年份筛选
loadData()
},
{ immediate: true }
)
// 监听年份筛选变化
watch(selectedYear, () => {
loadAnalysisData()
})
onMounted(loadData)
</script>
<template>
<div class="flex h-full flex-col bg-gray-50 dark:bg-gray-950">
<!-- Loading State -->
<div v-if="isLoading && !session" class="flex h-full items-center justify-center">
<div class="text-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-indigo-500" />
<p class="mt-2 text-sm text-gray-500">加载分析数据...</p>
</div>
</div>
<!-- Content -->
<template v-else-if="session">
<!-- Header -->
<div class="border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between">
<!-- Session Info -->
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600"
>
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-5 w-5 text-white" />
</div>
<div>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ session.name }}
</h1>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="selectedYear">
{{ selectedYear }}: {{ filteredMessageCount }} 条消息 · {{ filteredMemberCount }} 位活跃成员
</template>
<template v-else>{{ session.messageCount }} 条消息 · {{ session.memberCount }} 位成员</template>
</p>
</div>
</div>
<!-- Year Filter & Actions -->
<div class="flex items-center gap-3">
<!-- Year Filter -->
<USelectMenu v-model="selectedYear" :items="yearOptions" value-key="value" class="w-32" size="sm">
<template #leading>
<UIcon name="i-heroicons-calendar" class="h-4 w-4 text-gray-400" />
</template>
</USelectMenu>
<!-- Actions -->
<UButton icon="i-heroicons-arrow-down-tray" color="gray" variant="ghost" size="sm" disabled>
生成报告
</UButton>
</div>
</div>
<!-- Tabs -->
<div class="mt-4 flex gap-1">
<button
v-for="tab in tabs"
:key="tab.id"
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all"
:class="[
activeTab === tab.id
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800',
]"
@click="activeTab = tab.id"
>
<UIcon :name="tab.icon" class="h-4 w-4" />
{{ tab.label }}
</button>
</div>
</div>
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto p-6">
<!-- Loading Overlay -->
<div
v-if="isLoading"
class="absolute inset-0 z-10 flex items-center justify-center bg-gray-50/80 dark:bg-gray-950/80"
>
<div class="text-center">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-indigo-500" />
<p class="mt-2 text-sm text-gray-500">更新数据...</p>
</div>
</div>
<Transition name="fade" mode="out-in">
<OverviewTab
v-if="activeTab === 'overview'"
:session="session"
:member-activity="memberActivity"
:top-members="topMembers"
:bottom-members="bottomMembers"
:message-types="messageTypes"
:hourly-activity="hourlyActivity"
:time-range="timeRange"
:selected-year="selectedYear"
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
/>
<MembersTab v-else-if="activeTab === 'members'" :member-activity="memberActivity" />
<TimelineTab
v-else-if="activeTab === 'timeline'"
:daily-activity="dailyActivity"
:hourly-activity="hourlyActivity"
:time-range="timeRange"
/>
</Transition>
</div>
</template>
<!-- Empty State -->
<div v-else class="flex h-full items-center justify-center">
<p class="text-gray-500">无法加载会话数据</p>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { MemberActivity } from '@/types/chat'
import { MemberRankList } from '@/components/charts'
import type { MemberRankItem } from '@/components/charts'
const props = defineProps<{
memberActivity: MemberActivity[]
}>()
// Top 10 排行榜数据
const top10RankData = computed<MemberRankItem[]>(() => {
return props.memberActivity.slice(0, 10).map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.messageCount,
percentage: m.percentage,
}))
})
// 完整排行榜数据
const fullRankData = computed<MemberRankItem[]>(() => {
return props.memberActivity.map((m) => ({
id: m.memberId.toString(),
name: m.name,
value: m.messageCount,
percentage: m.percentage,
}))
})
const isOpen = ref(false)
</script>
<template>
<div class="space-y-6">
<!-- 标题 -->
<div class="flex items-center justify-between">
<div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> {{ memberActivity.length }} 位成员参与聊天</p>
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-800">
<h3 class="font-semibold text-gray-900 dark:text-white">成员活跃度排行</h3>
<!-- 完整排行榜 Dialog -->
<UModal v-model:open="isOpen" :ui="{ width: 'max-w-3xl' }">
<UButton v-if="memberActivity.length > 10" icon="i-heroicons-list-bullet" color="gray" variant="ghost">
查看完整排行
</UButton>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">完整成员排行榜</h3>
<span> {{ memberActivity.length }} 位成员</span>
</div>
</template>
<template #body>
<div class="max-h-[60vh] overflow-y-auto">
<MemberRankList :members="fullRankData" />
</div>
</template>
</UModal>
</div>
<MemberRankList :members="top10RankData" />
</div>
</div>
</template>
+221
View File
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AnalysisSession, MemberActivity, HourlyActivity, MessageType } from '@/types/chat'
import dayjs from 'dayjs'
import { DoughnutChart, ProgressBar } from '@/components/charts'
import type { DoughnutChartData } from '@/components/charts'
const props = defineProps<{
session: AnalysisSession
memberActivity: MemberActivity[]
topMembers: MemberActivity[]
bottomMembers: MemberActivity[]
messageTypes: Array<{ type: MessageType; count: number }>
hourlyActivity: HourlyActivity[]
timeRange: { start: number; end: number } | null
selectedYear: number | null
filteredMessageCount: number
filteredMemberCount: number
}>()
// 时间跨度
const durationDays = computed(() => {
if (props.selectedYear) {
// 选择了特定年份,计算该年的天数
const isLeapYear =
(props.selectedYear % 4 === 0 && props.selectedYear % 100 !== 0) || props.selectedYear % 400 === 0
return isLeapYear ? 366 : 365
}
if (!props.timeRange) return 0
return Math.ceil((props.timeRange.end - props.timeRange.start) / 86400)
})
const dateRangeText = computed(() => {
if (props.selectedYear) {
return `${props.selectedYear}年全年`
}
if (!props.timeRange) return ''
const start = dayjs.unix(props.timeRange.start).format('YYYY.MM.DD')
const end = dayjs.unix(props.timeRange.end).format('YYYY.MM.DD')
return `${start} - ${end}`
})
// 显示的消息数和成员数
const displayMessageCount = computed(() => {
return props.selectedYear ? props.filteredMessageCount : props.session.messageCount
})
const displayMemberCount = computed(() => {
return props.selectedYear ? props.filteredMemberCount : props.session.memberCount
})
// 消息类型名称映射
const typeNames: Record<number, string> = {
0: '文字',
1: '图片',
2: '语音',
3: '视频',
4: '文件',
5: '表情',
6: '系统',
99: '其他',
}
// 消息类型图表数据
const typeChartData = computed<DoughnutChartData>(() => {
return {
labels: props.messageTypes.map((t) => typeNames[t.type] || '未知'),
values: props.messageTypes.map((t) => t.count),
}
})
// 最活跃时段
const peakHour = computed(() => {
if (!props.hourlyActivity.length) return null
const peak = props.hourlyActivity.reduce(
(max, h) => (h.messageCount > max.messageCount ? h : max),
props.hourlyActivity[0]
)
return peak
})
// 图片消息数量
const imageCount = computed(() => {
const imageType = props.messageTypes.find((t) => t.type === 1)
return imageType?.count || 0
})
// 获取排名徽章
function getRankBadge(index: number): string {
const badges = ['🥇', '🥈', '🥉']
return badges[index] || `${index + 1}`
}
</script>
<template>
<div class="space-y-6">
<!-- 群聊身份卡 -->
<div class="rounded-2xl bg-linear-to-br from-indigo-500 via-purple-500 to-pink-500 p-6 text-white shadow-lg">
<div class="flex items-start justify-between">
<div>
<h2 class="text-2xl font-bold">{{ session.name }}</h2>
<p class="mt-1 text-white/80">
平台: {{ session.platform.toUpperCase() }} · {{ session.type === 'group' ? '群聊' : '私聊' }}
</p>
</div>
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-white/20 backdrop-blur">
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-6 w-6" />
</div>
</div>
<div class="mt-6 grid grid-cols-3 gap-4">
<div class="rounded-xl bg-white/10 px-4 py-3 backdrop-blur">
<p class="text-2xl font-bold">{{ displayMessageCount }}</p>
<p class="text-sm text-white/70">{{ selectedYear ? '筛选消息' : '消息总数' }}</p>
</div>
<div class="rounded-xl bg-white/10 px-4 py-3 backdrop-blur">
<p class="text-2xl font-bold">{{ displayMemberCount }}</p>
<p class="text-sm text-white/70">{{ selectedYear ? '活跃成员' : '群成员' }}</p>
</div>
<div class="rounded-xl bg-white/10 px-4 py-3 backdrop-blur">
<p class="text-2xl font-bold">{{ durationDays }}</p>
<p class="text-sm text-white/70"></p>
</div>
</div>
<p class="mt-4 text-sm text-white/60">
{{ dateRangeText }}
</p>
</div>
<!-- 关键指标卡片 -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<!-- 龙王 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-100 text-2xl dark:bg-amber-900/30">
🏆
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">龙王</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">
{{ topMembers[0]?.name || '-' }}
</p>
</div>
</div>
<div class="mt-3 flex items-baseline gap-1">
<span class="text-2xl font-bold text-amber-500">{{ topMembers[0]?.messageCount || 0 }}</span>
<span class="text-sm text-gray-500"></span>
<span class="ml-2 text-sm text-gray-400">({{ topMembers[0]?.percentage || 0 }}%)</span>
</div>
</div>
<!-- 潜水王 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-2xl dark:bg-blue-900/30">
🤫
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">潜水王</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">
{{ bottomMembers[0]?.name || '-' }}
</p>
</div>
</div>
<div class="mt-3 flex items-baseline gap-1">
<span class="text-2xl font-bold text-blue-500">{{ bottomMembers[0]?.messageCount || 0 }}</span>
<span class="text-sm text-gray-500"></span>
<span class="ml-2 text-sm text-gray-400">({{ bottomMembers[0]?.percentage || 0 }}%)</span>
</div>
</div>
<!-- 图片/表情 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-pink-100 text-2xl dark:bg-pink-900/30">
📸
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">图片消息</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ imageCount }} </p>
</div>
</div>
<div class="mt-3 flex items-baseline gap-1">
<span class="text-sm text-gray-500">最活跃时段:</span>
<span class="font-semibold text-pink-500">{{ peakHour?.hour || 0 }}:00</span>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 消息类型分布 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h3 class="mb-4 font-semibold text-gray-900 dark:text-white">消息类型分布</h3>
<DoughnutChart :data="typeChartData" :height="256" />
</div>
<!-- Top 成员预览 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h3 class="mb-4 font-semibold text-gray-900 dark:text-white">活跃榜 Top 5</h3>
<div class="space-y-3">
<div
v-for="(member, index) in memberActivity.slice(0, 5)"
:key="member.memberId"
class="flex items-center gap-3"
>
<span class="w-6 text-center text-lg">{{ getRankBadge(index) }}</span>
<div class="flex-1">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900 dark:text-white">{{ member.name }}</span>
<span class="text-sm text-gray-500">{{ member.messageCount }}</span>
</div>
<ProgressBar :percentage="member.percentage" color="from-indigo-500 to-purple-500" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { HourlyActivity, DailyActivity } from '@/types/chat'
import dayjs from 'dayjs'
import { LineChart, BarChart } from '@/components/charts'
import type { LineChartData, BarChartData } from '@/components/charts'
const props = defineProps<{
dailyActivity: DailyActivity[]
hourlyActivity: HourlyActivity[]
timeRange: { start: number; end: number } | null
}>()
// 检测是否跨年
const isMultiYear = computed(() => {
if (props.dailyActivity.length < 2) return false
const years = new Set(props.dailyActivity.map((d) => dayjs(d.date).year()))
return years.size > 1
})
// 每日趋势图数据
const dailyChartData = computed<LineChartData>(() => {
// 如果跨年,显示年份;否则只显示月/日
const dateFormat = isMultiYear.value ? 'YYYY/MM/DD' : 'MM/DD'
return {
labels: props.dailyActivity.map((d) => dayjs(d.date).format(dateFormat)),
values: props.dailyActivity.map((d) => d.messageCount),
}
})
// 24小时分布图数据
const hourlyChartData = computed<BarChartData>(() => {
return {
labels: props.hourlyActivity.map((h) => `${h.hour}:00`),
values: props.hourlyActivity.map((h) => h.messageCount),
}
})
// 分析指标
const peakHour = computed(() => {
if (!props.hourlyActivity.length) return null
return props.hourlyActivity.reduce((max, h) => (h.messageCount > max.messageCount ? h : max), props.hourlyActivity[0])
})
const lateNightRatio = computed(() => {
// 深夜定义为 0-6 点
const lateNight = props.hourlyActivity
.filter((h) => h.hour >= 0 && h.hour < 6)
.reduce((sum, h) => sum + h.messageCount, 0)
const total = props.hourlyActivity.reduce((sum, h) => sum + h.messageCount, 0)
return total > 0 ? Math.round((lateNight / total) * 100) : 0
})
const morningRatio = computed(() => {
// 早间定义为 6-12 点
const morning = props.hourlyActivity
.filter((h) => h.hour >= 6 && h.hour < 12)
.reduce((sum, h) => sum + h.messageCount, 0)
const total = props.hourlyActivity.reduce((sum, h) => sum + h.messageCount, 0)
return total > 0 ? Math.round((morning / total) * 100) : 0
})
const afternoonRatio = computed(() => {
// 下午定义为 12-18 点
const afternoon = props.hourlyActivity
.filter((h) => h.hour >= 12 && h.hour < 18)
.reduce((sum, h) => sum + h.messageCount, 0)
const total = props.hourlyActivity.reduce((sum, h) => sum + h.messageCount, 0)
return total > 0 ? Math.round((afternoon / total) * 100) : 0
})
const eveningRatio = computed(() => {
// 晚间定义为 18-24 点
const evening = props.hourlyActivity
.filter((h) => h.hour >= 18 && h.hour < 24)
.reduce((sum, h) => sum + h.messageCount, 0)
const total = props.hourlyActivity.reduce((sum, h) => sum + h.messageCount, 0)
return total > 0 ? Math.round((evening / total) * 100) : 0
})
// 最活跃的一天
const peakDay = computed(() => {
if (!props.dailyActivity.length) return null
return props.dailyActivity.reduce((max, d) => (d.messageCount > max.messageCount ? d : max), props.dailyActivity[0])
})
// 平均每日消息数
const avgDailyMessages = computed(() => {
if (!props.dailyActivity.length) return 0
const total = props.dailyActivity.reduce((sum, d) => sum + d.messageCount, 0)
return Math.round(total / props.dailyActivity.length)
})
</script>
<template>
<div class="space-y-6">
<!-- 标题 -->
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">时间维度分析</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">探索群聊的活跃规律</p>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">最活跃时段</p>
<p class="mt-1 text-2xl font-bold text-indigo-600 dark:text-indigo-400">{{ peakHour?.hour || 0 }}:00</p>
<p class="mt-1 text-xs text-gray-400">{{ peakHour?.messageCount || 0 }} 条消息</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">最活跃日期</p>
<p class="mt-1 text-2xl font-bold text-purple-600 dark:text-purple-400">
{{ peakDay ? dayjs(peakDay.date).format('MM/DD') : '-' }}
</p>
<p class="mt-1 text-xs text-gray-400">{{ peakDay?.messageCount || 0 }} 条消息</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">日均消息</p>
<p class="mt-1 text-2xl font-bold text-pink-600 dark:text-pink-400">
{{ avgDailyMessages }}
</p>
<p class="mt-1 text-xs text-gray-400">/</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">夜猫子指数</p>
<p class="mt-1 text-2xl font-bold text-amber-600 dark:text-amber-400">{{ lateNightRatio }}%</p>
<p class="mt-1 text-xs text-gray-400">深夜活跃占比</p>
</div>
</div>
<!-- 每日趋势 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h3 class="mb-4 font-semibold text-gray-900 dark:text-white">每日消息趋势</h3>
<LineChart :data="dailyChartData" :height="288" />
</div>
<!-- 24小时分布 -->
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h3 class="mb-4 font-semibold text-gray-900 dark:text-white">24小时活跃分布</h3>
<BarChart
:data="hourlyChartData"
:height="256"
:x-label-filter="(_, index) => (index % 3 === 0 ? `${index}:00` : '')"
/>
<!-- 时段分析 -->
<div class="mt-6 grid grid-cols-4 gap-4">
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">凌晨 0-6点</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{{ lateNightRatio }}%</div>
<div class="mx-auto mt-2 h-1 w-full max-w-16 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-300 transition-all" :style="{ width: `${lateNightRatio}%` }" />
</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">上午 6-12点</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{{ morningRatio }}%</div>
<div class="mx-auto mt-2 h-1 w-full max-w-16 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-400 transition-all" :style="{ width: `${morningRatio}%` }" />
</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">下午 12-18点</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{{ afternoonRatio }}%</div>
<div class="mx-auto mt-2 h-1 w-full max-w-16 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all" :style="{ width: `${afternoonRatio}%` }" />
</div>
</div>
<div class="text-center">
<div class="text-xs text-gray-500 dark:text-gray-400">晚上 18-24点</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{{ eveningRatio }}%</div>
<div class="mx-auto mt-2 h-1 w-full max-w-16 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-600 transition-all" :style="{ width: `${eveningRatio}%` }" />
</div>
</div>
</div>
</div>
</div>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
export interface BarChartData {
labels: string[]
values: number[]
colors?: string[]
}
interface Props {
data: BarChartData
height?: number
showLegend?: boolean
borderRadius?: number
colorMode?: 'static' | 'gradient' // static: 使用提供的colors, gradient: 根据值自动渐变
xLabelFilter?: (label: string, index: number) => string
}
const props = withDefaults(defineProps<Props>(), {
height: 256,
showLegend: false,
borderRadius: 4,
colorMode: 'gradient',
})
// 根据数据值计算渐变颜色
const calculateColors = computed(() => {
if (props.colorMode === 'static' && props.data.colors) {
return props.data.colors
}
const maxValue = Math.max(...props.data.values)
return props.data.values.map((value) => {
const intensity = value / maxValue
if (intensity > 0.8) return '#6366f1'
if (intensity > 0.6) return '#818cf8'
if (intensity > 0.4) return '#a5b4fc'
if (intensity > 0.2) return '#c7d2fe'
return '#e0e7ff'
})
})
const chartData = computed(() => {
return {
labels: props.data.labels,
datasets: [
{
label: '数量',
data: props.data.values,
backgroundColor: calculateColors.value,
borderRadius: props.borderRadius,
borderSkipped: false,
},
],
}
})
const chartOptions = computed(() => {
const options: any = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: props.showLegend,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
font: {
size: 10,
},
},
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
ticks: {
font: {
size: 11,
},
},
},
},
}
// 添加自定义 x 轴标签过滤器
if (props.xLabelFilter) {
options.scales.x.ticks.callback = function (this: any, _: unknown, index: number) {
const label = this.getLabelForValue(index)
return props.xLabelFilter!(label, index)
}
}
return options
})
</script>
<template>
<div :style="{ height: `${height}px` }">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Doughnut } from 'vue-chartjs'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
ChartJS.register(ArcElement, Tooltip, Legend)
export interface DoughnutChartData {
labels: string[]
values: number[]
colors?: string[]
}
interface Props {
data: DoughnutChartData
cutout?: number | string
height?: number
showLegend?: boolean
legendPosition?: 'top' | 'bottom' | 'left' | 'right'
}
const props = withDefaults(defineProps<Props>(), {
cutout: '60%',
height: 256,
showLegend: true,
legendPosition: 'bottom',
})
// 默认颜色方案
const defaultColors = [
'#6366f1', // indigo
'#8b5cf6', // violet
'#ec4899', // pink
'#f43f5e', // rose
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#94a3b8', // gray
]
const chartData = computed(() => {
return {
labels: props.data.labels,
datasets: [
{
data: props.data.values,
backgroundColor: props.data.colors || defaultColors.slice(0, props.data.values.length),
borderWidth: 0,
hoverOffset: 4,
},
],
}
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: props.showLegend,
position: props.legendPosition,
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
},
},
cutout: props.cutout,
}))
</script>
<template>
<div :style="{ height: `${height}px` }">
<Doughnut :data="chartData" :options="chartOptions" />
</div>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
export interface HorizontalBarChartData {
labels: string[]
values: number[]
colors?: string[]
}
interface Props {
data: HorizontalBarChartData
height?: number
showLegend?: boolean
borderRadius?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 320,
showLegend: false,
borderRadius: 8,
})
// 默认渐变色方案
const defaultColors = [
'#6366f1', // indigo
'#8b5cf6', // violet
'#a855f7', // purple
'#d946ef', // fuchsia
'#ec4899', // pink
'#f43f5e', // rose
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
]
const chartData = computed(() => {
return {
labels: props.data.labels,
datasets: [
{
label: '数量',
data: props.data.values,
backgroundColor: props.data.colors || defaultColors.slice(0, props.data.values.length),
borderRadius: props.borderRadius,
borderSkipped: false,
},
],
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y' as const,
plugins: {
legend: {
display: props.showLegend,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
titleFont: {
size: 14,
},
bodyFont: {
size: 13,
},
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
font: {
size: 12,
},
},
},
y: {
grid: {
display: false,
},
ticks: {
font: {
size: 12,
},
},
},
},
}
</script>
<template>
<div :style="{ height: `${height}px` }">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
export interface LineChartData {
labels: string[]
values: number[]
}
interface Props {
data: LineChartData
height?: number
fill?: boolean
lineColor?: string
fillColor?: string
tension?: number
showLegend?: boolean
xAxisRotation?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 288,
fill: true,
lineColor: '#6366f1',
fillColor: 'rgba(99, 102, 241, 0.1)',
tension: 0.4,
showLegend: false,
xAxisRotation: 45,
})
const chartData = computed(() => {
return {
labels: props.data.labels,
datasets: [
{
label: '数量',
data: props.data.values,
fill: props.fill,
borderColor: props.lineColor,
backgroundColor: props.fillColor,
tension: props.tension,
pointBackgroundColor: props.lineColor,
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
],
}
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: props.showLegend,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
maxRotation: props.xAxisRotation,
minRotation: props.xAxisRotation,
font: {
size: 11,
},
},
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
ticks: {
font: {
size: 11,
},
},
},
},
interaction: {
intersect: false,
mode: 'index' as const,
},
}))
</script>
<template>
<div :style="{ height: `${height}px` }">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface MemberRankItem {
id: string
name: string
value: number
percentage: number
}
interface Props {
members: MemberRankItem[]
showAvatar?: boolean
rankLimit?: number // 限制显示数量,0 表示不限制
}
const props = withDefaults(defineProps<Props>(), {
showAvatar: false,
rankLimit: 0,
})
const displayMembers = computed(() => {
return props.rankLimit > 0 ? props.members.slice(0, props.rankLimit) : props.members
})
// 获取相对于第一名的百分比
function getRelativePercentage(index: number): number {
if (displayMembers.value.length === 0) return 0
const maxValue = displayMembers.value[0].value
if (maxValue === 0) return 0
return Math.round((displayMembers.value[index].value / maxValue) * 100)
}
// 获取排名样式
function getRankStyle(index: number): string {
if (index === 0) return 'bg-linear-to-r from-amber-400 to-orange-500 text-white'
if (index === 1) return 'bg-linear-to-r from-gray-300 to-gray-400 text-white'
if (index === 2) return 'bg-linear-to-r from-amber-600 to-amber-700 text-white'
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}
// 获取进度条颜色
function getBarColor(index: number): string {
const colors = [
'from-amber-400 to-orange-500',
'from-gray-300 to-gray-400',
'from-amber-600 to-amber-700',
'from-indigo-400 to-purple-500',
'from-pink-400 to-rose-500',
'from-cyan-400 to-blue-500',
'from-green-400 to-emerald-500',
'from-violet-400 to-purple-500',
]
return colors[index % colors.length]
}
</script>
<template>
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<div
v-for="(member, index) in displayMembers"
:key="member.id"
class="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<!-- 排名 -->
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold"
:class="getRankStyle(index)"
>
{{ index + 1 }}
</div>
<!-- 头像占位 -->
<div
v-if="showAvatar"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-indigo-100 to-purple-100 text-sm font-medium text-indigo-600 dark:from-indigo-900/30 dark:to-purple-900/30 dark:text-indigo-400"
>
{{ member.name.slice(0, 1) }}
</div>
<!-- 名字 -->
<div class="w-32 shrink-0">
<p class="truncate font-medium text-gray-900 dark:text-white">
{{ member.name }}
</p>
</div>
<!-- 进度条 -->
<div class="flex flex-1 items-center">
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-linear-to-r transition-all"
:class="getBarColor(index)"
:style="{ width: `${getRelativePercentage(index)}%` }"
/>
</div>
</div>
<!-- 数值和百分比 -->
<div class="flex shrink-0 items-baseline gap-2">
<span class="text-lg font-bold text-gray-900 dark:text-white">{{ member.value }}</span>
<span class="text-sm text-gray-500"> ({{ member.percentage }}%)</span>
</div>
</div>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
interface Props {
percentage: number
color?: string
height?: number
showLabel?: boolean
animated?: boolean
}
const props = withDefaults(defineProps<Props>(), {
color: 'from-indigo-500 to-purple-500',
height: 8,
showLabel: false,
animated: true,
})
</script>
<template>
<div class="flex items-center gap-3">
<div class="flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800" :style="{ height: `${height}px` }">
<div
class="h-full rounded-full bg-linear-to-r"
:class="[color, animated ? 'transition-all duration-500' : '']"
:style="{ width: `${percentage}%` }"
/>
</div>
<span v-if="showLabel" class="text-sm text-gray-500 dark:text-gray-400">{{ percentage }}%</span>
</div>
</template>
+327
View File
@@ -0,0 +1,327 @@
# 图表组件库
这是一个基于 `vue-chartjs``Chart.js` 的可复用图表组件库,专为 ChatLens 项目设计。所有组件都已经过封装,只需传入数据和简单配置即可使用。
## 📦 组件列表
### 1. DoughnutChart - 环形图
用于展示占比数据,如消息类型分布。
**Props:**
```typescript
interface DoughnutChartData {
labels: string[] // 标签数组
values: number[] // 数值数组
colors?: string[] // 可选,自定义颜色数组
}
interface Props {
data: DoughnutChartData
cutout?: number | string // 中心空洞大小,默认 '60%'
height?: number // 高度(px),默认 256
showLegend?: boolean // 是否显示图例,默认 true
legendPosition?: 'top' | 'bottom' | 'left' | 'right' // 图例位置,默认 'bottom'
}
```
**使用示例:**
```vue
<script setup lang="ts">
import { DoughnutChart } from '@/components/charts'
import type { DoughnutChartData } from '@/components/charts'
const chartData: DoughnutChartData = {
labels: ['文字', '图片', '语音', '视频'],
values: [1500, 300, 200, 100],
}
</script>
<template>
<DoughnutChart :data="chartData" :height="300" />
</template>
```
---
### 2. HorizontalBarChart - 横向柱状图
用于展示排名数据,如 Top 10 活跃成员。
**Props:**
```typescript
interface HorizontalBarChartData {
labels: string[] // 标签数组
values: number[] // 数值数组
colors?: string[] // 可选,自定义颜色数组
}
interface Props {
data: HorizontalBarChartData
height?: number // 高度(px),默认 320
showLegend?: boolean // 是否显示图例,默认 false
borderRadius?: number // 圆角大小,默认 8
}
```
**使用示例:**
```vue
<script setup lang="ts">
import { HorizontalBarChart } from '@/components/charts'
import type { HorizontalBarChartData } from '@/components/charts'
const chartData: HorizontalBarChartData = {
labels: ['张三', '李四', '王五'],
values: [500, 400, 300],
}
</script>
<template>
<HorizontalBarChart :data="chartData" />
</template>
```
---
### 3. LineChart - 折线图
用于展示趋势数据,如每日消息趋势。
**Props:**
```typescript
interface LineChartData {
labels: string[] // X 轴标签数组
values: number[] // Y 轴数值数组
}
interface Props {
data: LineChartData
height?: number // 高度(px),默认 288
fill?: boolean // 是否填充区域,默认 true
lineColor?: string // 线条颜色,默认 '#6366f1'
fillColor?: string // 填充颜色,默认 'rgba(99, 102, 241, 0.1)'
tension?: number // 曲线张力,默认 0.4
showLegend?: boolean // 是否显示图例,默认 false
xAxisRotation?: number // X 轴标签旋转角度,默认 45
}
```
**使用示例:**
```vue
<script setup lang="ts">
import { LineChart } from '@/components/charts'
import type { LineChartData } from '@/components/charts'
const chartData: LineChartData = {
labels: ['01/01', '01/02', '01/03', '01/04', '01/05'],
values: [120, 150, 180, 140, 200],
}
</script>
<template>
<LineChart :data="chartData" :height="300" line-color="#ec4899" fill-color="rgba(236, 72, 153, 0.1)" />
</template>
```
---
### 4. BarChart - 垂直柱状图
用于展示分布数据,如 24 小时活跃分布。
**Props:**
```typescript
interface BarChartData {
labels: string[] // X 轴标签数组
values: number[] // Y 轴数值数组
colors?: string[] // 可选,自定义颜色数组
}
interface Props {
data: BarChartData
height?: number // 高度(px),默认 256
showLegend?: boolean // 是否显示图例,默认 false
borderRadius?: number // 圆角大小,默认 4
colorMode?: 'static' | 'gradient' // 颜色模式,默认 'gradient'
xLabelFilter?: (label: string, index: number) => string // X 轴标签过滤器
}
```
**使用示例:**
```vue
<script setup lang="ts">
import { BarChart } from '@/components/charts'
import type { BarChartData } from '@/components/charts'
const hourlyData: BarChartData = {
labels: Array.from({ length: 24 }, (_, i) => `${i}:00`),
values: [
50, 30, 20, 15, 10, 20, 40, 60, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 180, 150, 100,
],
}
</script>
<template>
<BarChart :data="hourlyData" :x-label-filter="(_, index) => (index % 3 === 0 ? `${index}:00` : '')" />
</template>
```
---
### 5. MemberRankList - 成员排行列表
用于展示成员排行榜,带排名徽章和进度条。
**Props:**
```typescript
interface MemberRankItem {
id: string // 唯一标识
name: string // 成员名称
value: number // 数值(如消息数)
percentage: number // 百分比(0-100
}
interface Props {
members: MemberRankItem[]
showAvatar?: boolean // 是否显示头像,默认 true
rankLimit?: number // 显示数量限制,0 表示不限制,默认 0
}
```
**使用示例:**
```vue
<script setup lang="ts">
import { MemberRankList } from '@/components/charts'
import type { MemberRankItem } from '@/components/charts'
const members: MemberRankItem[] = [
{ id: '1', name: '张三', value: 500, percentage: 45 },
{ id: '2', name: '李四', value: 400, percentage: 36 },
{ id: '3', name: '王五', value: 300, percentage: 27 },
]
</script>
<template>
<!-- 显示所有成员 -->
<MemberRankList :members="members" />
<!-- 只显示前5名 -->
<MemberRankList :members="members" :rank-limit="5" />
</template>
```
---
### 6. ProgressBar - 进度条
通用进度条组件,支持自定义颜色和动画。
**Props:**
```typescript
interface Props {
percentage: number // 百分比(0-100
color?: string // 渐变色类名,默认 'from-indigo-500 to-purple-500'
height?: number // 高度(px),默认 8
showLabel?: boolean // 是否显示百分比标签,默认 false
animated?: boolean // 是否启用动画,默认 true
}
```
**使用示例:**
```vue
<script setup lang="ts">
import { ProgressBar } from '@/components/charts'
</script>
<template>
<!-- 基础用法 -->
<ProgressBar :percentage="75" />
<!-- 自定义颜色和显示标签 -->
<ProgressBar :percentage="85" color="from-amber-400 to-orange-500" :show-label="true" />
</template>
```
---
## 🎨 设计特性
### 颜色方案
所有图表组件使用统一的配色方案,与 ChatLens 的设计语言保持一致:
- 主色调:Indigo (#6366f1)
- 辅助色:Violet, Purple, Pink, Rose
- 灰度色:Gray 系列
### 响应式设计
- 所有图表组件都支持响应式布局
- 图表尺寸根据容器自动调整
- 支持暗色模式(通过 Tailwind CSS dark: 前缀)
### 交互体验
- 鼠标悬停时显示详细数据
- 平滑的动画过渡效果
- 优化的 Tooltip 样式
---
## 📚 完整导入示例
```typescript
// 单个导入
import { DoughnutChart, LineChart, MemberRankList } from '@/components/charts'
// 类型导入
import type { DoughnutChartData, LineChartData, MemberRankItem } from '@/components/charts'
```
---
## 🔧 技术栈
- **Chart.js**: 强大的图表库
- **vue-chartjs**: Vue 3 的 Chart.js 包装器
- **TypeScript**: 完整的类型支持
- **Tailwind CSS**: 统一的样式系统
---
## 📝 注意事项
1. **数据格式**: 确保传入的数据格式正确,`labels``values` 数组长度必须一致
2. **性能优化**: 大数据量时,考虑对数据进行分页或限制显示数量
3. **颜色自定义**: 如果提供自定义颜色,确保颜色数量与数据点数量匹配
4. **响应式**: 图表会自动响应容器尺寸变化,无需手动调整
---
## 🚀 扩展建议
如需添加新的图表类型,建议遵循以下原则:
1. **统一接口**: 使用相似的 Props 结构
2. **类型安全**: 导出完整的 TypeScript 类型定义
3. **可配置**: 提供合理的默认值和可选配置项
4. **文档完善**: 在本文档中添加使用说明和示例
---
## 📖 更多资源
- [Chart.js 官方文档](https://www.chartjs.org/docs/latest/)
- [vue-chartjs 文档](https://vue-chartjs.org/)
+14
View File
@@ -0,0 +1,14 @@
// 图表组件统一导出
export { default as DoughnutChart } from './DoughnutChart.vue'
export { default as HorizontalBarChart } from './HorizontalBarChart.vue'
export { default as LineChart } from './LineChart.vue'
export { default as BarChart } from './BarChart.vue'
export { default as MemberRankList } from './MemberRankList.vue'
export { default as ProgressBar } from './ProgressBar.vue'
// 导出类型定义
export type { DoughnutChartData } from './DoughnutChart.vue'
export type { HorizontalBarChartData } from './HorizontalBarChart.vue'
export type { LineChartData } from './LineChart.vue'
export type { BarChartData } from './BarChart.vue'
export type { MemberRankItem } from './MemberRankList.vue'
+16 -16
View File
@@ -3,28 +3,28 @@ 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/analysis/AnalysisDashboard.vue'
const chatStore = useChatStore()
const { currentSessionId } = storeToRefs(chatStore)
const { currentSessionId, isInitialized } = storeToRefs(chatStore)
</script>
<template>
<div class="flex h-screen w-full overflow-hidden bg-white dark:bg-gray-950">
<!-- Sidebar -->
<Sidebar />
<!-- Main Content -->
<main class="flex-1 overflow-hidden">
<template v-if="!currentSessionId">
<WelcomeGuide />
</template>
<template v-else>
<!-- TODO: Analysis Dashboard -->
<div class="flex h-full items-center justify-center text-gray-500">
分析仪表盘 (开发中...)
<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-indigo-500" />
<p class="mt-2 text-sm text-gray-500">加载中...</p>
</div>
</template>
</main>
</div>
</template>
<template v-else>
<Sidebar />
<main class="flex-1 overflow-hidden">
<WelcomeGuide v-if="!currentSessionId" />
<AnalysisDashboard v-else />
</main>
</template>
</div>
</template>
+132 -23
View File
@@ -1,48 +1,157 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface ChatSession {
id: string
name: string
avatar?: string
lastMessage?: string
timestamp: number
}
import { ref, computed } from 'vue'
import type { AnalysisSession, ImportProgress } from '@/types/chat'
export const useChatStore = defineStore(
'chat',
() => {
const sessions = ref<ChatSession[]>([])
// 会话列表
const sessions = ref<AnalysisSession[]>([])
// 当前选中的会话ID
const currentSessionId = ref<string | null>(null)
// 导入状态
const isImporting = ref(false)
const importProgress = ref<ImportProgress | null>(null)
function addSession(session: ChatSession) {
sessions.value.push(session)
currentSessionId.value = session.id
}
// 当前选中的会话
const currentSession = computed(() => {
if (!currentSessionId.value) return null
return sessions.value.find((s) => s.id === currentSessionId.value) || null
})
function removeSession(id: string) {
const index = sessions.value.findIndex((s) => s.id === id)
if (index !== -1) {
sessions.value.splice(index, 1)
if (currentSessionId.value === id) {
// 是否已初始化
const isInitialized = ref(false)
/**
* 从数据库加载会话列表
*/
async function loadSessions() {
try {
const list = await window.chatApi.getSessions()
sessions.value = list
// 如果当前选中的会话不存在了,清除选中状态
if (currentSessionId.value && !list.find((s) => s.id === currentSessionId.value)) {
currentSessionId.value = null
}
isInitialized.value = true
} catch (error) {
console.error('加载会话列表失败:', error)
isInitialized.value = true
}
}
/**
* 选择文件并导入
*/
async function importFile(): Promise<{ success: boolean; error?: string }> {
try {
// 选择文件
const result = await window.chatApi.selectFile()
if (!result || !result.filePath) {
return { success: false, error: '未选择文件' }
}
if (result.error) {
return { success: false, error: result.error }
}
// 开始导入
isImporting.value = true
importProgress.value = {
stage: 'reading',
progress: 0,
message: '准备导入...',
}
// 监听导入进度
const unsubscribe = window.chatApi.onImportProgress((progress) => {
importProgress.value = progress
})
// 执行导入
const importResult = await window.chatApi.import(result.filePath)
// 取消监听
unsubscribe()
if (importResult.success && importResult.sessionId) {
// 刷新会话列表
await loadSessions()
// 不自动选中新会话,保持在欢迎页
// currentSessionId.value = importResult.sessionId
return { success: true }
} else {
return { success: false, error: importResult.error || '导入失败' }
}
} catch (error) {
return { success: false, error: String(error) }
} finally {
isImporting.value = false
// 延迟清除进度,让用户看到完成状态
setTimeout(() => {
importProgress.value = null
}, 1500)
}
}
/**
* 选择会话
*/
function selectSession(id: string) {
currentSessionId.value = id
}
/**
* 删除会话
*/
async function deleteSession(id: string): Promise<boolean> {
try {
const success = await window.chatApi.deleteSession(id)
if (success) {
// 从列表中移除
const index = sessions.value.findIndex((s) => s.id === id)
if (index !== -1) {
sessions.value.splice(index, 1)
}
// 如果删除的是当前选中的会话,清除选中状态
if (currentSessionId.value === id) {
currentSessionId.value = null
}
}
return success
} catch (error) {
console.error('删除会话失败:', error)
return false
}
}
/**
* 清除选中状态
*/
function clearSelection() {
currentSessionId.value = null
}
return {
// State
sessions,
currentSessionId,
addSession,
removeSession,
isImporting,
importProgress,
isInitialized,
// Computed
currentSession,
// Actions
loadSessions,
importFile,
selectSession,
deleteSession,
clearSelection,
}
},
{
persist: true,
},
persist: {
// 只持久化 currentSessionIdsessions 从数据库加载
pick: ['currentSessionId'],
},
}
)
+168
View File
@@ -0,0 +1,168 @@
/**
* ChatLens 聊天数据模型定义
* 统一的数据结构,支持多平台聊天记录导入
*/
// ==================== 枚举定义 ====================
/**
* 消息类型枚举
*/
export enum MessageType {
TEXT = 0, // 文本消息
IMAGE = 1, // 图片
VOICE = 2, // 语音
VIDEO = 3, // 视频
FILE = 4, // 文件
EMOJI = 5, // 表情包/贴纸
SYSTEM = 6, // 系统消息(入群/退群/撤回等)
OTHER = 99 // 其他
}
/**
* 聊天平台枚举
*/
export enum ChatPlatform {
QQ = 'qq',
WECHAT = 'wechat',
TELEGRAM = 'telegram',
DISCORD = 'discord',
UNKNOWN = 'unknown'
}
/**
* 聊天类型枚举
*/
export enum ChatType {
GROUP = 'group', // 群聊
PRIVATE = 'private' // 私聊
}
// ==================== 数据库模型 ====================
/**
* 元信息(数据库中存储的格式)
*/
export interface DbMeta {
name: string // 群名/对话名
platform: ChatPlatform // 平台
type: ChatType // 聊天类型
imported_at: number // 导入时间戳(秒)
}
/**
* 成员(数据库中存储的格式)
*/
export interface DbMember {
id: number // 自增ID
platform_id: string // 平台标识(QQ号等)
name: string // 最新昵称
}
/**
* 消息(数据库中存储的格式)
*/
export interface DbMessage {
id: number // 自增ID
sender_id: number // FK -> member.id
ts: number // 时间戳(秒)
type: MessageType // 消息类型
content: string | null // 纯文本内容
}
// ==================== Parser 解析结果 ====================
/**
* 解析后的成员信息
*/
export interface ParsedMember {
platformId: string // 平台标识
name: string // 昵称
}
/**
* 解析后的消息
*/
export interface ParsedMessage {
senderPlatformId: string // 发送者平台ID
timestamp: number // 时间戳(秒)
type: MessageType // 消息类型
content: string | null // 内容
}
/**
* Parser 解析结果
*/
export interface ParseResult {
meta: {
name: string
platform: ChatPlatform
type: ChatType
}
members: ParsedMember[]
messages: ParsedMessage[]
}
// ==================== 分析结果类型 ====================
/**
* 成员活跃度统计
*/
export interface MemberActivity {
memberId: number
platformId: string
name: string
messageCount: number
percentage: number // 占总消息的百分比
}
/**
* 时段活跃度统计
*/
export interface HourlyActivity {
hour: number // 0-23
messageCount: number
}
/**
* 日期活跃度统计
*/
export interface DailyActivity {
date: string // YYYY-MM-DD
messageCount: number
}
/**
* 分析会话信息(用于会话列表展示)
*/
export interface AnalysisSession {
id: string // 数据库文件名(不含扩展名)
name: string // 群名/对话名
platform: ChatPlatform
type: ChatType
importedAt: number // 导入时间戳
messageCount: number // 消息总数
memberCount: number // 成员数
dbPath: string // 数据库文件完整路径
}
// ==================== IPC 通信类型 ====================
/**
* 导入进度回调
*/
export interface ImportProgress {
stage: 'reading' | 'parsing' | 'saving' | 'done' | 'error'
progress: number // 0-100
message?: string
}
/**
* 导入结果
*/
export interface ImportResult {
success: boolean
sessionId?: string // 成功时返回会话ID
error?: string // 失败时返回错误信息
}
+5
View File
@@ -0,0 +1,5 @@
/**
* 类型定义导出
*/
export * from './chat'