mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-13 09:41:01 +08:00
feat: 支持导入微信默认数据库
This commit is contained in:
@@ -8,6 +8,7 @@ import type { FormatModule } from '../types'
|
||||
// 导入所有格式模块
|
||||
import chatlab from './chatlab'
|
||||
import shuakamiQqExporterV4 from './shuakami-qq-exporter-v4'
|
||||
import wechatDefault from './wechat-default'
|
||||
import qqNativeTxt from './qq-native-txt'
|
||||
|
||||
/**
|
||||
@@ -16,8 +17,9 @@ import qqNativeTxt from './qq-native-txt'
|
||||
export const formats: FormatModule[] = [
|
||||
chatlab, // 优先级 1
|
||||
shuakamiQqExporterV4, // 优先级 10 - shuakami/qq-chat-exporter V4
|
||||
wechatDefault, // 优先级 20 - 微信数据库导出 JSON
|
||||
qqNativeTxt, // 优先级 30 - QQ 官方导出 TXT
|
||||
]
|
||||
|
||||
// 按名称导出,方便单独使用
|
||||
export { chatlab, shuakamiQqExporterV4, qqNativeTxt }
|
||||
export { chatlab, shuakamiQqExporterV4, wechatDefault, qqNativeTxt }
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* 微信默认数据库导出 JSON 格式解析器
|
||||
* 适配微信数据库直接导出的 JSON 格式(私聊)
|
||||
*
|
||||
* 格式特征:
|
||||
* - JSON 数组,每个元素是一条消息
|
||||
* - 无 metadata 头部,需从文件名提取聊天名称
|
||||
* - mesDes: 0=自己发送,1=对方发送
|
||||
* - messageType: 1=文本, 3=图片, 34=语音, 43=视频, 47=表情, 48=位置, 49=应用消息, 10000=系统
|
||||
*
|
||||
* 发送者标识:
|
||||
* - 自己:platformId = "self"
|
||||
* - 对方:platformId = 文件名(如 bingbing.json → "bingbing")
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { parser } from 'stream-json'
|
||||
import { streamArray } from 'stream-json/streamers/StreamArray'
|
||||
import { chain } from 'stream-chain'
|
||||
import { ChatPlatform, ChatType, MessageType } from '../../../../src/types/chat'
|
||||
import type {
|
||||
FormatFeature,
|
||||
FormatModule,
|
||||
Parser,
|
||||
ParseOptions,
|
||||
ParseEvent,
|
||||
ParsedMeta,
|
||||
ParsedMember,
|
||||
ParsedMessage,
|
||||
} from '../types'
|
||||
import { getFileSize, createProgress } from '../utils'
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
/**
|
||||
* 从文件名提取聊天名称(对方名称)
|
||||
*/
|
||||
function extractNameFromFilePath(filePath: string): string {
|
||||
const basename = path.basename(filePath)
|
||||
const name = basename.replace(/\.json$/i, '')
|
||||
return name || '未知对话'
|
||||
}
|
||||
|
||||
// ==================== 特征定义 ====================
|
||||
|
||||
export const feature: FormatFeature = {
|
||||
id: 'wechat-default',
|
||||
name: '微信数据库导出 (JSON)',
|
||||
platform: ChatPlatform.WECHAT,
|
||||
priority: 20,
|
||||
extensions: ['.json'],
|
||||
signatures: {
|
||||
// 数组结构,包含微信特有字段
|
||||
head: [/"mesDes"\s*:/, /"messageType"\s*:/, /"msgContent"\s*:/, /"ConBlob"\s*:/],
|
||||
},
|
||||
}
|
||||
|
||||
// ==================== 微信消息结构 ====================
|
||||
|
||||
interface WechatMessage {
|
||||
mesDes: number // 0=自己发送,1=对方发送
|
||||
mesLocalID: number
|
||||
mesSvrID: number
|
||||
messageType: number
|
||||
msgContent: string | null
|
||||
msgCreateTime: number // Unix 时间戳(秒)
|
||||
msgImgStatus: number
|
||||
msgSeq: number
|
||||
msgSource: string | null
|
||||
msgStatus: number
|
||||
msgVoiceText: string | null
|
||||
CompressContent: string | null
|
||||
ConBlob: string | null
|
||||
IntRes1: number
|
||||
IntRes2: number
|
||||
StrRes1: string | null
|
||||
StrRes2: string | null
|
||||
}
|
||||
|
||||
// ==================== 消息类型转换 ====================
|
||||
|
||||
/**
|
||||
* 从 messageType=49 的 XML 内容中提取应用消息子类型
|
||||
*/
|
||||
function parseAppMsgType(content: string | null): MessageType {
|
||||
if (!content) return MessageType.OTHER
|
||||
|
||||
// 尝试从 XML 中提取 <type> 标签的值
|
||||
const typeMatch = content.match(/<type>(\d+)<\/type>/)
|
||||
if (!typeMatch) return MessageType.OTHER
|
||||
|
||||
const appType = parseInt(typeMatch[1], 10)
|
||||
switch (appType) {
|
||||
case 5: // 链接
|
||||
return MessageType.LINK
|
||||
case 6: // 文件
|
||||
return MessageType.FILE
|
||||
case 19: // 聊天记录
|
||||
return MessageType.FORWARD
|
||||
case 33: // 小程序
|
||||
case 36: // 小程序
|
||||
return MessageType.SHARE
|
||||
case 51: // 视频号
|
||||
return MessageType.SHARE
|
||||
case 57: // 引用回复
|
||||
return MessageType.REPLY
|
||||
case 2000: // 转账
|
||||
return MessageType.TRANSFER
|
||||
case 2001: // 红包
|
||||
return MessageType.RED_PACKET
|
||||
default:
|
||||
return MessageType.SHARE // 默认作为分享处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换微信消息类型到标准消息类型
|
||||
*/
|
||||
function convertMessageType(wechatType: number, content: string | null): MessageType {
|
||||
switch (wechatType) {
|
||||
case 1: // 文本
|
||||
return MessageType.TEXT
|
||||
case 3: // 图片
|
||||
return MessageType.IMAGE
|
||||
case 34: // 语音
|
||||
return MessageType.VOICE
|
||||
case 43: // 视频
|
||||
return MessageType.VIDEO
|
||||
case 47: // 表情包
|
||||
return MessageType.EMOJI
|
||||
case 48: // 位置
|
||||
return MessageType.LOCATION
|
||||
case 49: // 应用消息(需要细分)
|
||||
return parseAppMsgType(content)
|
||||
case 10000: // 系统消息
|
||||
// 检查是否是撤回消息
|
||||
if (content && content.includes('撤回')) {
|
||||
return MessageType.RECALL
|
||||
}
|
||||
return MessageType.SYSTEM
|
||||
case 10002: // 系统消息(撤回等)
|
||||
return MessageType.RECALL
|
||||
default:
|
||||
return MessageType.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取消息的纯文本内容
|
||||
* 对于 XML 格式的消息,提取其中的文本部分
|
||||
*/
|
||||
function extractTextContent(wechatType: number, content: string | null): string | null {
|
||||
if (!content) return null
|
||||
|
||||
// 文本消息直接返回
|
||||
if (wechatType === 1) {
|
||||
return content
|
||||
}
|
||||
|
||||
// 引用回复消息 (messageType=49, type=57),提取 title 作为回复内容
|
||||
if (wechatType === 49) {
|
||||
const titleMatch = content.match(/<title>([^<]*)<\/title>/)
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
return titleMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 系统消息直接返回
|
||||
if (wechatType === 10000 || wechatType === 10002) {
|
||||
return content
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
if (wechatType === 3) {
|
||||
return '[图片]'
|
||||
}
|
||||
|
||||
// 语音消息
|
||||
if (wechatType === 34) {
|
||||
return '[语音]'
|
||||
}
|
||||
|
||||
// 视频消息
|
||||
if (wechatType === 43) {
|
||||
return '[视频]'
|
||||
}
|
||||
|
||||
// 表情包
|
||||
if (wechatType === 47) {
|
||||
return '[表情]'
|
||||
}
|
||||
|
||||
// 位置
|
||||
if (wechatType === 48) {
|
||||
return '[位置]'
|
||||
}
|
||||
|
||||
// 其他复杂消息,尝试提取描述
|
||||
const desMatch = content.match(/<des>([^<]*)<\/des>/)
|
||||
if (desMatch && desMatch[1]) {
|
||||
return desMatch[1]
|
||||
}
|
||||
|
||||
return content.length > 200 ? `${content.substring(0, 200)}...` : content
|
||||
}
|
||||
|
||||
// ==================== 解析器实现 ====================
|
||||
|
||||
async function* parseWechatDefault(options: ParseOptions): AsyncGenerator<ParseEvent, void, unknown> {
|
||||
const { filePath, batchSize = 5000, onProgress } = options
|
||||
|
||||
const totalBytes = getFileSize(filePath)
|
||||
let bytesRead = 0
|
||||
let messagesProcessed = 0
|
||||
|
||||
// 发送初始进度
|
||||
const initialProgress = createProgress('parsing', 0, totalBytes, 0, '开始解析...')
|
||||
yield { type: 'progress', data: initialProgress }
|
||||
onProgress?.(initialProgress)
|
||||
|
||||
// 从文件名提取对方名称
|
||||
const otherName = extractNameFromFilePath(filePath)
|
||||
const selfPlatformId = 'self'
|
||||
const otherPlatformId = otherName
|
||||
|
||||
// 成员信息
|
||||
const memberMap = new Map<string, { platformId: string; accountName: string }>()
|
||||
memberMap.set(selfPlatformId, { platformId: selfPlatformId, accountName: '我' })
|
||||
memberMap.set(otherPlatformId, { platformId: otherPlatformId, accountName: otherName })
|
||||
|
||||
// 发送 meta
|
||||
const meta: ParsedMeta = {
|
||||
name: otherName,
|
||||
platform: ChatPlatform.WECHAT,
|
||||
type: ChatType.PRIVATE,
|
||||
}
|
||||
yield { type: 'meta', data: meta }
|
||||
|
||||
// 消息批次收集器
|
||||
const messageBatch: ParsedMessage[] = []
|
||||
|
||||
// 流式解析
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(filePath, { encoding: 'utf-8' })
|
||||
|
||||
readStream.on('data', (chunk: string | Buffer) => {
|
||||
bytesRead += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length
|
||||
})
|
||||
|
||||
const pipeline = chain([readStream, parser(), streamArray()])
|
||||
|
||||
pipeline.on('data', ({ value }: { value: WechatMessage }) => {
|
||||
const msg = value
|
||||
|
||||
// 数据验证
|
||||
if (msg.msgCreateTime === undefined || msg.msgCreateTime === null) {
|
||||
return
|
||||
}
|
||||
if (msg.messageType === undefined || msg.messageType === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 确定发送者
|
||||
const isFromSelf = msg.mesDes === 0
|
||||
const senderPlatformId = isFromSelf ? selfPlatformId : otherPlatformId
|
||||
const senderAccountName = isFromSelf ? '我' : otherName
|
||||
|
||||
// 转换消息类型
|
||||
const type = convertMessageType(msg.messageType, msg.msgContent)
|
||||
|
||||
// 提取文本内容
|
||||
const content = extractTextContent(msg.messageType, msg.msgContent)
|
||||
|
||||
messageBatch.push({
|
||||
senderPlatformId,
|
||||
senderAccountName,
|
||||
timestamp: msg.msgCreateTime,
|
||||
type,
|
||||
content,
|
||||
})
|
||||
|
||||
messagesProcessed++
|
||||
|
||||
// 每处理 1000 条更新进度
|
||||
if (messagesProcessed % 1000 === 0) {
|
||||
const progress = createProgress(
|
||||
'parsing',
|
||||
bytesRead,
|
||||
totalBytes,
|
||||
messagesProcessed,
|
||||
`已处理 ${messagesProcessed} 条消息...`
|
||||
)
|
||||
onProgress?.(progress)
|
||||
}
|
||||
})
|
||||
|
||||
pipeline.on('end', resolve)
|
||||
pipeline.on('error', reject)
|
||||
})
|
||||
|
||||
// 发送成员
|
||||
const members: ParsedMember[] = Array.from(memberMap.values()).map((m) => ({
|
||||
platformId: m.platformId,
|
||||
accountName: m.accountName,
|
||||
}))
|
||||
yield { type: 'members', data: members }
|
||||
|
||||
// 分批发送消息
|
||||
for (let i = 0; i < messageBatch.length; i += batchSize) {
|
||||
const batch = messageBatch.slice(i, i + batchSize)
|
||||
yield { type: 'messages', data: batch }
|
||||
}
|
||||
|
||||
// 完成
|
||||
const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '解析完成')
|
||||
yield { type: 'progress', data: doneProgress }
|
||||
onProgress?.(doneProgress)
|
||||
|
||||
yield {
|
||||
type: 'done',
|
||||
data: { messageCount: messagesProcessed, memberCount: memberMap.size },
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 导出解析器 ====================
|
||||
|
||||
export const parser_: Parser = {
|
||||
feature,
|
||||
parse: parseWechatDefault,
|
||||
}
|
||||
|
||||
// ==================== 导出格式模块 ====================
|
||||
|
||||
const module_: FormatModule = {
|
||||
feature,
|
||||
parser: parser_,
|
||||
}
|
||||
|
||||
export default module_
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user