feat: 支持导入微信默认数据库

This commit is contained in:
digua
2025-12-11 01:10:15 +08:00
parent 14e54958e8
commit 018c47be42
6 changed files with 623 additions and 17 deletions
+3 -15
View File
@@ -341,21 +341,9 @@ onMounted(() => {
<div class="mt-4 flex items-start gap-3 rounded-xl bg-amber-50 p-4 dark:bg-amber-900/20">
<UIcon name="i-heroicons-exclamation-triangle" class="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">字段说明</p>
<ul class="mt-1 list-inside list-disc text-sm text-amber-700 dark:text-amber-300">
<li>
<strong>账号名称</strong>
:用户的 QQ 原始昵称
</li>
<li>
<strong>群昵称</strong>
:用户在本群的专属备注名称
</li>
<li>
<strong>自定义别名</strong>
:您为用户添加的备注,用于搜索和 AI 分析
</li>
</ul>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
提示:添加别名可以更好地识别聊天记录中的对话对象,别名将用于搜索和 AI 分析中。
</p>
</div>
</div>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import type { MemberWithStats } from '@/types/chat'
// Props
const props = defineProps<{
sessionId: string
}>()
// 成员列表
const members = ref<MemberWithStats[]>([])
const isLoading = ref(false)
// 正在保存别名的成员ID
const savingAliasesId = ref<number | null>(null)
// 获取成员显示名称
function getDisplayName(member: MemberWithStats): string {
return member.groupNickname || member.accountName || member.platformId
}
// 获取成员首字符(用于头像)
function getFirstChar(member: MemberWithStats): string {
const name = getDisplayName(member)
return name.slice(0, 1)
}
// 计算消息总数
const totalMessageCount = computed(() => {
return members.value.reduce((sum, m) => sum + m.messageCount, 0)
})
// 计算每个成员的消息占比
function getPercentage(count: number): number {
if (totalMessageCount.value === 0) return 0
return Math.round((count / totalMessageCount.value) * 100)
}
// 加载成员列表
async function loadMembers() {
if (!props.sessionId) return
isLoading.value = true
try {
members.value = await window.chatApi.getMembers(props.sessionId)
} catch (error) {
console.error('加载成员列表失败:', error)
} finally {
isLoading.value = false
}
}
// 更新别名
async function updateAliases(member: MemberWithStats, newAliases: string[]) {
const aliasesToSave = JSON.parse(JSON.stringify(newAliases)) as string[]
const currentAliases = JSON.stringify(member.aliases)
const newAliasesStr = JSON.stringify(aliasesToSave)
if (currentAliases === newAliasesStr) return
savingAliasesId.value = member.id
try {
const success = await window.chatApi.updateMemberAliases(props.sessionId, member.id, aliasesToSave)
if (success) {
const idx = members.value.findIndex((m) => m.id === member.id)
if (idx !== -1) {
members.value[idx] = {
...members.value[idx],
aliases: aliasesToSave,
}
}
}
} catch (error) {
console.error('保存别名失败:', error)
} finally {
savingAliasesId.value = null
}
}
// 监听 sessionId 变化
watch(
() => props.sessionId,
() => {
loadMembers()
},
{ immediate: true }
)
onMounted(() => {
loadMembers()
})
</script>
<template>
<div class="max-w-4xl p-6">
<!-- 页面标题 -->
<div class="mb-6">
<div class="flex items-center gap-3">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">对话成员</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ members.length }} 位成员可为成员添加别名备注用于搜索和 AI 分析
</p>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="flex h-60 items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
</div>
<!-- 成员卡片列表 -->
<div v-else class="grid gap-4 md:grid-cols-2">
<div
v-for="member in members"
:key="member.id"
class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<!-- 成员头部信息 -->
<div class="flex items-start gap-4">
<!-- 头像 -->
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-400 to-pink-600 text-lg font-medium text-white"
>
{{ getFirstChar(member) }}
</div>
<!-- 名称和ID -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
{{ getDisplayName(member) }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">ID: {{ member.platformId }}</p>
</div>
</div>
<!-- 消息统计 -->
<div class="mt-4 flex items-center gap-4">
<div class="flex-1">
<div class="flex items-baseline justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">消息数</span>
<span class="text-lg font-bold text-gray-900 dark:text-white">
{{ member.messageCount.toLocaleString() }}
</span>
</div>
<!-- 进度条 -->
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-gradient-to-r from-pink-400 to-pink-600 transition-all duration-500"
:style="{ width: `${getPercentage(member.messageCount)}%` }"
/>
</div>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">占比 {{ getPercentage(member.messageCount) }}%</p>
</div>
</div>
<!-- 别名编辑 -->
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-800">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">自定义别名</label>
<div class="relative">
<UInputTags
:model-value="member.aliases"
@update:model-value="(val) => updateAliases(member, val)"
placeholder="输入后回车添加别名"
class="w-full"
/>
<!-- 保存中指示器 -->
<div v-if="savingAliasesId === member.id" class="absolute right-3 top-1/2 -translate-y-1/2">
<UIcon name="i-heroicons-arrow-path" class="h-4 w-4 animate-spin text-pink-500" />
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!isLoading && members.length === 0" class="flex h-60 flex-col items-center justify-center">
<UIcon name="i-heroicons-user-group" class="mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" />
<p class="text-gray-500 dark:text-gray-400">暂无成员数据</p>
</div>
<!-- 提示信息 -->
<div v-if="members.length > 0" class="mt-6 flex items-start gap-3 rounded-xl bg-blue-50 p-4 dark:bg-blue-900/20">
<UIcon name="i-heroicons-information-circle" class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" />
<div>
<p class="text-sm font-medium text-blue-800 dark:text-blue-200">提示</p>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-300">
添加别名可以更好地识别聊天记录中的对话对象别名将用于搜索和 AI 分析中
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref } from 'vue'
import { SubTabs } from '@/components/UI'
import { CatchphraseTab, KeywordAnalysis } from './quotes'
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
// 子 Tab 配置(私聊只保留口头禅和关键词分析)
const subTabs = [
{ id: 'catchphrase', label: '口头禅', icon: 'i-heroicons-chat-bubble-bottom-center-text' },
{ id: 'keyword', label: '关键词分析', icon: 'i-heroicons-magnifying-glass' },
]
const activeSubTab = ref('catchphrase')
</script>
<template>
<div class="flex h-full flex-col">
<!-- Tab 导航 -->
<SubTabs v-model="activeSubTab" :items="subTabs" />
<!-- Tab 内容 -->
<div class="flex-1 min-h-0 overflow-auto">
<Transition name="fade" mode="out-in">
<!-- 口头禅分析 -->
<CatchphraseTab
v-if="activeSubTab === 'catchphrase'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<!-- 关键词分析 -->
<div v-else-if="activeSubTab === 'keyword'" class="mx-auto max-w-3xl p-6">
<KeywordAnalysis :session-id="props.sessionId" :time-filter="props.timeFilter" />
</div>
</Transition>
</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>
+23 -1
View File
@@ -8,7 +8,10 @@ import { formatDateRange } from '@/utils'
import UITabs from '@/components/UI/Tabs.vue'
import PrivateOverviewTab from '@/components/analysis/PrivateOverviewTab.vue'
import PrivateTimelineTab from '@/components/analysis/PrivateTimelineTab.vue'
import PrivateQuotesTab from '@/components/analysis/PrivateQuotesTab.vue'
import PrivateMemberTab from '@/components/analysis/PrivateMemberTab.vue'
import AITab from '@/components/analysis/AITab.vue'
import SQLLabTab from '@/components/analysis/SQLLabTab.vue'
const route = useRoute()
const router = useRouter()
@@ -29,11 +32,14 @@ const availableYears = ref<number[]>([])
const selectedYear = ref<number>(0) // 0 表示全部
const isInitialLoad = ref(true) // 用于跳过初始加载时的 watch 触发,并控制首屏加载状态
// Tab 配置 - 私聊有总览、趋势和 AI
// Tab 配置 - 私聊有总览、趋势、语录、成员、AI 和 SQL
const tabs = [
{ id: 'overview', label: '总览', icon: 'i-heroicons-chart-pie' },
{ id: 'timeline', label: '趋势', icon: 'i-heroicons-chart-bar' },
{ id: 'quotes', label: '语录', icon: 'i-heroicons-chat-bubble-left-right' },
{ id: 'member', label: '成员', icon: 'i-heroicons-user-group' },
{ id: 'ai', label: 'AI实验室', icon: 'i-heroicons-sparkles' },
{ id: 'sql', label: 'SQL实验室', icon: 'i-heroicons-command-line' },
]
const activeTab = ref((route.query.tab as string) || 'overview')
@@ -292,6 +298,17 @@ onMounted(() => {
:time-range="timeRange"
:time-filter="timeFilter"
/>
<PrivateQuotesTab
v-else-if="activeTab === 'quotes'"
:key="'quotes-' + selectedYear"
:session-id="currentSessionId!"
:time-filter="timeFilter"
/>
<PrivateMemberTab
v-else-if="activeTab === 'member'"
:key="'member'"
:session-id="currentSessionId!"
/>
<AITab
v-else-if="activeTab === 'ai'"
:key="'ai-' + selectedYear"
@@ -300,6 +317,11 @@ onMounted(() => {
:time-filter="timeFilter"
chat-type="private"
/>
<SQLLabTab
v-else-if="activeTab === 'sql'"
:key="'sql-' + selectedYear"
:session-id="currentSessionId!"
/>
</Transition>
</div>
</div>