mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-25 16:10:18 +08:00
feat: 完成聊天记录解析和基础数据渲染
This commit is contained in:
+12
@@ -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>
|
||||
|
||||
Vendored
+10
-8
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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/)
|
||||
@@ -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
@@ -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
@@ -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: {
|
||||
// 只持久化 currentSessionId,sessions 从数据库加载
|
||||
pick: ['currentSessionId'],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 // 失败时返回错误信息
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 类型定义导出
|
||||
*/
|
||||
export * from './chat'
|
||||
|
||||
Reference in New Issue
Block a user