feat: 新增视图Tab

This commit is contained in:
digua
2026-01-21 23:07:30 +08:00
parent 5c8745a786
commit 007c442067
15 changed files with 733 additions and 1 deletions

View File

@@ -323,6 +323,21 @@ export function registerChatHandlers(ctx: IpcContext): void {
}
)
/**
* 获取年份活跃度分布
*/
ipcMain.handle(
'chat:getYearlyActivity',
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
try {
return await worker.getYearlyActivity(sessionId, filter)
} catch (error) {
console.error('获取年份活跃度失败:', error)
return []
}
}
)
/**
* 获取消息类型分布
*/

View File

@@ -18,6 +18,7 @@ import {
getDailyActivity,
getWeekdayActivity,
getMonthlyActivity,
getYearlyActivity,
getMessageTypeDistribution,
getTimeRange,
getMemberNameHistory,
@@ -82,6 +83,7 @@ const syncHandlers: Record<string, (payload: any) => any> = {
getDailyActivity: (p) => getDailyActivity(p.sessionId, p.filter),
getWeekdayActivity: (p) => getWeekdayActivity(p.sessionId, p.filter),
getMonthlyActivity: (p) => getMonthlyActivity(p.sessionId, p.filter),
getYearlyActivity: (p) => getYearlyActivity(p.sessionId, p.filter),
getMessageTypeDistribution: (p) => getMessageTypeDistribution(p.sessionId, p.filter),
getTimeRange: (p) => getTimeRange(p.sessionId),
getMemberNameHistory: (p) => getMemberNameHistory(p.sessionId, p.memberId),

View File

@@ -242,6 +242,37 @@ export function getMonthlyActivity(sessionId: string, filter?: TimeFilter): any[
return result
}
/**
* 获取年份活跃度分布
*/
export function getYearlyActivity(sessionId: string, filter?: TimeFilter): any[] {
const db = openDatabase(sessionId)
if (!db) return []
const { clause, params } = buildTimeFilter(filter)
const clauseWithSystem = buildSystemMessageFilter(clause)
const rows = db
.prepare(
`
SELECT
CAST(strftime('%Y', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as year,
COUNT(*) as messageCount
FROM message msg
JOIN member m ON msg.sender_id = m.id
${clauseWithSystem}
GROUP BY year
ORDER BY year
`
)
.all(...params) as Array<{ year: number; messageCount: number }>
return rows.map((r) => ({
year: r.year,
messageCount: r.messageCount,
}))
}
/**
* 获取消息类型分布
*/

View File

@@ -11,6 +11,7 @@ export {
getDailyActivity,
getWeekdayActivity,
getMonthlyActivity,
getYearlyActivity,
getMessageTypeDistribution,
getTimeRange,
getMemberNameHistory,

View File

@@ -225,6 +225,10 @@ export async function getMonthlyActivity(sessionId: string, filter?: any): Promi
return sendToWorker('getMonthlyActivity', { sessionId, filter })
}
export async function getYearlyActivity(sessionId: string, filter?: any): Promise<any[]> {
return sendToWorker('getYearlyActivity', { sessionId, filter })
}
export async function getMessageTypeDistribution(sessionId: string, filter?: any): Promise<any[]> {
return sendToWorker('getMessageTypeDistribution', { sessionId, filter })
}

View File

@@ -79,6 +79,10 @@ interface ChatApi {
getDailyActivity: (sessionId: string, filter?: TimeFilter) => Promise<DailyActivity[]>
getWeekdayActivity: (sessionId: string, filter?: TimeFilter) => Promise<WeekdayActivity[]>
getMonthlyActivity: (sessionId: string, filter?: TimeFilter) => Promise<MonthlyActivity[]>
getYearlyActivity: (
sessionId: string,
filter?: TimeFilter
) => Promise<Array<{ year: number; messageCount: number }>>
getMessageTypeDistribution: (
sessionId: string,
filter?: TimeFilter

View File

@@ -183,6 +183,16 @@ const chatApi = {
return ipcRenderer.invoke('chat:getMonthlyActivity', sessionId, filter)
},
/**
* 获取年份活跃度分布
*/
getYearlyActivity: (
sessionId: string,
filter?: { startTs?: number; endTs?: number }
): Promise<Array<{ year: number; messageCount: number }>> => {
return ipcRenderer.invoke('chat:getYearlyActivity', sessionId, filter)
},
/**
* 获取消息类型分布
*/

View File

@@ -13,6 +13,7 @@
},
"tabs": {
"overview": "Overview",
"view": "View",
"ranking": "Rankings",
"groupQuotes": "Quotes",
"quotes": "Quotes",

View File

@@ -13,6 +13,7 @@
},
"tabs": {
"overview": "总览",
"view": "视图",
"ranking": "群榜单",
"groupQuotes": "群语录",
"quotes": "语录",

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubTabs } from '@/components/UI'
import MessageView from './view/MessageView.vue'
import WordcloudView from './view/WordcloudView.vue'
import PortraitView from './view/PortraitView.vue'
const { t } = useI18n()
interface TimeFilter {
startTs?: number
endTs?: number
}
// Props
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
// 子 Tab 配置
const subTabs = computed(() => [
{ id: 'message', label: t('message'), icon: 'i-heroicons-chat-bubble-left-right' },
{ id: 'wordcloud', label: t('wordcloud'), icon: 'i-heroicons-cloud' },
{ id: 'portrait', label: t('portrait'), icon: 'i-heroicons-user-circle' },
])
const activeSubTab = ref('message')
</script>
<template>
<div class="flex h-full flex-col">
<!-- Tab 导航 -->
<SubTabs v-model="activeSubTab" :items="subTabs" persist-key="privateViewTab" />
<!-- Tab 内容 -->
<div class="flex-1 min-h-0 overflow-auto">
<Transition name="fade" mode="out-in">
<!-- 消息 -->
<MessageView
v-if="activeSubTab === 'message'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<!-- 词云 -->
<WordcloudView
v-else-if="activeSubTab === 'wordcloud'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
<!-- 对话画像 -->
<PortraitView
v-else-if="activeSubTab === 'portrait'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
</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>
<i18n>
{
"zh-CN": {
"message": "消息",
"wordcloud": "词云",
"portrait": "对话画像"
},
"en-US": {
"message": "Messages",
"wordcloud": "Word Cloud",
"portrait": "Chat Portrait"
}
}
</i18n>

View File

@@ -0,0 +1,420 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MessageType } from '@/types/base'
import { getMessageTypeName } from '@/types/base'
import type { HourlyActivity, WeekdayActivity, MonthlyActivity } from '@/types/analysis'
import { DoughnutChart, BarChart } from '@/components/charts'
import type { DoughnutChartData, BarChartData } from '@/components/charts'
import { SectionCard } from '@/components/UI'
const { t } = useI18n()
interface TimeFilter {
startTs?: number
endTs?: number
}
// Props
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
// 数据状态
const isLoading = ref(true)
const messageTypes = ref<Array<{ type: MessageType; count: number }>>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const weekdayActivity = ref<WeekdayActivity[]>([])
const monthlyActivity = ref<MonthlyActivity[]>([])
const yearlyActivity = ref<Array<{ year: number; messageCount: number }>>([])
// 星期名称(按 1=周一 到 7=周日 的顺序)
const weekdayNames = computed(() => [
t('weekdays.mon'),
t('weekdays.tue'),
t('weekdays.wed'),
t('weekdays.thu'),
t('weekdays.fri'),
t('weekdays.sat'),
t('weekdays.sun'),
])
// 月份名称
const monthNames = computed(() => [
t('months.jan'),
t('months.feb'),
t('months.mar'),
t('months.apr'),
t('months.may'),
t('months.jun'),
t('months.jul'),
t('months.aug'),
t('months.sep'),
t('months.oct'),
t('months.nov'),
t('months.dec'),
])
// 消息类型饼图数据
const typeChartData = computed<DoughnutChartData>(() => {
// 按数量排序
const sorted = [...messageTypes.value].sort((a, b) => b.count - a.count)
return {
labels: sorted.map((t) => getMessageTypeName(t.type)),
values: sorted.map((t) => t.count),
}
})
// 小时分布图表数据
const hourlyChartData = computed<BarChartData>(() => {
// 补全 24 小时数据
const hourMap = new Map(hourlyActivity.value.map((h) => [h.hour, h.messageCount]))
const labels: string[] = []
const values: number[] = []
for (let i = 0; i < 24; i++) {
labels.push(`${i}`)
values.push(hourMap.get(i) || 0)
}
return { labels, values }
})
// 星期分布图表数据
const weekdayChartData = computed<BarChartData>(() => {
// 补全 7 天数据weekday: 1=周一, 2=周二, ..., 7=周日)
const dayMap = new Map(weekdayActivity.value.map((w) => [w.weekday, w.messageCount]))
const values: number[] = []
// 按周一到周日的顺序1-7
for (let i = 1; i <= 7; i++) {
values.push(dayMap.get(i) || 0)
}
return {
labels: weekdayNames.value,
values,
}
})
// 月份分布图表数据
const monthlyChartData = computed<BarChartData>(() => {
// 补全 12 个月数据
const monthMap = new Map(monthlyActivity.value.map((m) => [m.month, m.messageCount]))
const values: number[] = []
for (let i = 1; i <= 12; i++) {
values.push(monthMap.get(i) || 0)
}
return {
labels: monthNames.value,
values,
}
})
// 年份分布图表数据
const yearlyChartData = computed<BarChartData>(() => {
// 按年份排序
const sorted = [...yearlyActivity.value].sort((a, b) => a.year - b.year)
return {
labels: sorted.map((y) => String(y.year)),
values: sorted.map((y) => y.messageCount),
}
})
// 热力图数据(小时 x 星期)
const heatmapData = computed(() => {
// 这里需要新的 API 来获取二维数据
// 暂时使用模拟数据展示效果(基于独立的小时和星期数据估算)
const data: number[][] = []
const total = messageTypes.value.reduce((sum, t) => sum + t.count, 0) || 1
// 按周一(1)到周日(7)的顺序
for (let day = 1; day <= 7; day++) {
const row: number[] = []
for (let hour = 0; hour < 24; hour++) {
// 使用现有数据模拟:星期数据 * 小时数据 / 总数 作为近似值
const dayCount = weekdayActivity.value.find((w) => w.weekday === day)?.messageCount || 0
const hourCount = hourlyActivity.value.find((h) => h.hour === hour)?.messageCount || 0
// 归一化到 0-100
const normalized = Math.round((dayCount * hourCount) / total)
row.push(normalized)
}
data.push(row)
}
return data
})
// 热力图最大值(用于颜色映射)
const heatmapMax = computed(() => {
let max = 0
for (const row of heatmapData.value) {
for (const val of row) {
if (val > max) max = val
}
}
return max || 1
})
// 获取热力图单元格颜色
function getHeatmapColor(value: number): string {
const intensity = value / heatmapMax.value
if (intensity === 0) return 'bg-gray-100 dark:bg-gray-800'
if (intensity < 0.2) return 'bg-pink-100 dark:bg-pink-900/30'
if (intensity < 0.4) return 'bg-pink-200 dark:bg-pink-800/40'
if (intensity < 0.6) return 'bg-pink-300 dark:bg-pink-700/50'
if (intensity < 0.8) return 'bg-pink-400 dark:bg-pink-600/60'
return 'bg-pink-500 dark:bg-pink-500'
}
// 加载数据
async function loadData() {
if (!props.sessionId) return
isLoading.value = true
try {
const [types, hourly, weekday, monthly, yearly] = await Promise.all([
window.chatApi.getMessageTypeDistribution(props.sessionId, props.timeFilter),
window.chatApi.getHourlyActivity(props.sessionId, props.timeFilter),
window.chatApi.getWeekdayActivity(props.sessionId, props.timeFilter),
window.chatApi.getMonthlyActivity(props.sessionId, props.timeFilter),
window.chatApi.getYearlyActivity(props.sessionId, props.timeFilter),
])
messageTypes.value = types
hourlyActivity.value = hourly
weekdayActivity.value = weekday
monthlyActivity.value = monthly
yearlyActivity.value = yearly
} catch (error) {
console.error('加载消息视图数据失败:', error)
} finally {
isLoading.value = false
}
}
// 监听 props 变化
watch(
() => [props.sessionId, props.timeFilter],
() => {
loadData()
},
{ immediate: true, deep: true }
)
</script>
<template>
<div class="main-content space-y-6 p-6">
<!-- 加载状态 -->
<div v-if="isLoading" class="flex h-64 items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-gray-400" />
</div>
<template v-else>
<!-- 消息类型分布 -->
<SectionCard :title="t('typeDistribution')" :show-divider="false">
<div class="p-5">
<DoughnutChart v-if="typeChartData.values.length > 0" :data="typeChartData" :height="280" />
<div v-else class="flex h-48 items-center justify-center text-gray-400">
{{ t('noData') }}
</div>
</div>
</SectionCard>
<!-- 时间分布图表小时 & 星期 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 小时分布 -->
<SectionCard :title="t('hourlyDistribution')" :show-divider="false">
<div class="p-5">
<BarChart :data="hourlyChartData" :height="200" />
</div>
</SectionCard>
<!-- 星期分布 -->
<SectionCard :title="t('weekdayDistribution')" :show-divider="false">
<div class="p-5">
<BarChart :data="weekdayChartData" :height="200" />
</div>
</SectionCard>
</div>
<!-- 时间分布图表月份 & 年份 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 月份分布 -->
<SectionCard :title="t('monthlyDistribution')" :show-divider="false">
<div class="p-5">
<BarChart :data="monthlyChartData" :height="200" />
</div>
</SectionCard>
<!-- 年份分布 -->
<SectionCard :title="t('yearlyDistribution')" :show-divider="false">
<div class="p-5">
<BarChart
v-if="yearlyChartData.values.length > 0"
:data="yearlyChartData"
:height="200"
/>
<div v-else class="flex h-48 items-center justify-center text-gray-400">
{{ t('noData') }}
</div>
</div>
</SectionCard>
</div>
<!-- 时间热力图 -->
<SectionCard :title="t('timeHeatmap')" :show-divider="false">
<template #headerRight>
<span class="text-xs text-gray-400">{{ t('heatmapHint') }}</span>
</template>
<div class="p-5">
<div class="overflow-x-auto">
<!-- 小时标签 -->
<div class="mb-1 flex pl-12">
<div
v-for="hour in 24"
:key="hour"
class="flex h-4 w-4 shrink-0 items-center justify-center text-[10px] text-gray-400"
>
{{ hour - 1 }}
</div>
</div>
<!-- 热力图行 -->
<div v-for="(row, dayIndex) in heatmapData" :key="dayIndex" class="flex items-center">
<!-- 星期标签 -->
<div class="w-12 shrink-0 text-xs text-gray-500 dark:text-gray-400">
{{ weekdayNames[dayIndex] }}
</div>
<!-- 热力格子 -->
<div class="flex gap-px">
<div
v-for="(value, hourIndex) in row"
:key="hourIndex"
class="h-4 w-4 rounded-sm transition-colors"
:class="getHeatmapColor(value)"
:title="`${weekdayNames[dayIndex]} ${hourIndex}:00 - ${value}`"
/>
</div>
</div>
<!-- 图例 -->
<div class="mt-4 flex items-center justify-end gap-2">
<span class="text-xs text-gray-400">{{ t('less') }}</span>
<div class="flex gap-px">
<div class="h-3 w-3 rounded-sm bg-gray-100 dark:bg-gray-800" />
<div class="h-3 w-3 rounded-sm bg-pink-100 dark:bg-pink-900/30" />
<div class="h-3 w-3 rounded-sm bg-pink-200 dark:bg-pink-800/40" />
<div class="h-3 w-3 rounded-sm bg-pink-300 dark:bg-pink-700/50" />
<div class="h-3 w-3 rounded-sm bg-pink-400 dark:bg-pink-600/60" />
<div class="h-3 w-3 rounded-sm bg-pink-500 dark:bg-pink-500" />
</div>
<span class="text-xs text-gray-400">{{ t('more') }}</span>
</div>
</div>
</div>
</SectionCard>
<!-- 消息长度分布 (占位) -->
<SectionCard :title="t('lengthDistribution')" :show-divider="false">
<div class="flex h-48 items-center justify-center">
<div class="text-center">
<UIcon name="i-heroicons-chart-bar" class="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" />
<p class="mt-2 text-sm text-gray-400">{{ t('comingSoon') }}</p>
</div>
</div>
</SectionCard>
<!-- 双方类型对比 (占位) -->
<SectionCard :title="t('memberTypeComparison')" :show-divider="false">
<div class="flex h-48 items-center justify-center">
<div class="text-center">
<UIcon name="i-heroicons-user-group" class="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" />
<p class="mt-2 text-sm text-gray-400">{{ t('comingSoon') }}</p>
</div>
</div>
</SectionCard>
</template>
</div>
</template>
<i18n>
{
"zh-CN": {
"typeDistribution": "消息类型分布",
"hourlyDistribution": "小时分布",
"weekdayDistribution": "星期分布",
"monthlyDistribution": "月份分布",
"yearlyDistribution": "年份分布",
"timeHeatmap": "时间热力图",
"heatmapHint": "展示聊天时间规律",
"lengthDistribution": "消息长度分布",
"memberTypeComparison": "双方类型对比",
"noData": "暂无数据",
"comingSoon": "功能开发中...",
"less": "少",
"more": "多",
"weekdays": {
"sun": "周日",
"mon": "周一",
"tue": "周二",
"wed": "周三",
"thu": "周四",
"fri": "周五",
"sat": "周六"
},
"months": {
"jan": "1月",
"feb": "2月",
"mar": "3月",
"apr": "4月",
"may": "5月",
"jun": "6月",
"jul": "7月",
"aug": "8月",
"sep": "9月",
"oct": "10月",
"nov": "11月",
"dec": "12月"
}
},
"en-US": {
"typeDistribution": "Message Type Distribution",
"hourlyDistribution": "Hourly Distribution",
"weekdayDistribution": "Weekday Distribution",
"monthlyDistribution": "Monthly Distribution",
"yearlyDistribution": "Yearly Distribution",
"timeHeatmap": "Time Heatmap",
"heatmapHint": "Shows chat time patterns",
"lengthDistribution": "Message Length Distribution",
"memberTypeComparison": "Member Type Comparison",
"noData": "No data",
"comingSoon": "Coming soon...",
"less": "Less",
"more": "More",
"weekdays": {
"sun": "Sun",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat"
},
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mar",
"apr": "Apr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dec"
}
}
}
</i18n>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
interface TimeFilter {
startTs?: number
endTs?: number
}
// Props
defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
</script>
<template>
<div class="main-content flex h-full items-center justify-center p-6">
<div
class="flex h-full w-full items-center justify-center rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50"
>
<div class="text-center">
<UIcon name="i-heroicons-user-circle" class="mx-auto h-12 w-12 text-gray-400" />
<p class="mt-3 text-sm font-medium text-gray-600 dark:text-gray-400">
{{ t('title') }}
</p>
<p class="mt-1 max-w-md px-4 text-sm text-gray-500">
{{ t('description') }}
</p>
</div>
</div>
</div>
</template>
<i18n>
{
"zh-CN": {
"title": "对话画像",
"description": "双方发言特征对比(消息长度、表情使用、消息类型等)"
},
"en-US": {
"title": "Chat Portrait",
"description": "Comparison of chat characteristics (message length, emoji usage, message types, etc.)"
}
}
</i18n>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
interface TimeFilter {
startTs?: number
endTs?: number
}
// Props
defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
</script>
<template>
<div class="main-content flex h-full items-center justify-center p-6">
<div
class="flex h-full w-full items-center justify-center rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50"
>
<div class="text-center">
<UIcon name="i-heroicons-chart-bar" class="mx-auto h-12 w-12 text-gray-400" />
<p class="mt-3 text-sm font-medium text-gray-600 dark:text-gray-400">
{{ t('title') }}
</p>
<p class="mt-1 max-w-md px-4 text-sm text-gray-500">
{{ t('description') }}
</p>
</div>
</div>
</div>
</template>
<i18n>
{
"zh-CN": {
"title": "时间线视图",
"description": "消息时间分布、活跃周期变化趋势等可视化内容"
},
"en-US": {
"title": "Timeline View",
"description": "Message time distribution, active period trends and other visualizations"
}
}
</i18n>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
interface TimeFilter {
startTs?: number
endTs?: number
}
// Props
defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
</script>
<template>
<div class="main-content flex h-full items-center justify-center p-6">
<div
class="flex h-full w-full items-center justify-center rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50"
>
<div class="text-center">
<UIcon name="i-heroicons-cloud" class="mx-auto h-12 w-12 text-gray-400" />
<p class="mt-3 text-sm font-medium text-gray-600 dark:text-gray-400">
{{ t('title') }}
</p>
<p class="mt-1 max-w-md px-4 text-sm text-gray-500">
{{ t('description') }}
</p>
</div>
</div>
</div>
</template>
<i18n>
{
"zh-CN": {
"title": "词云视图",
"description": "双方高频词汇对比可视化"
},
"en-US": {
"title": "Word Cloud View",
"description": "Visual comparison of frequently used words between both parties"
}
}
</i18n>

View File

@@ -10,6 +10,7 @@ import CaptureButton from '@/components/common/CaptureButton.vue'
import UITabs from '@/components/UI/Tabs.vue'
import AITab from '@/components/analysis/AITab.vue'
import OverviewTab from './components/OverviewTab.vue'
import ViewTab from './components/ViewTab.vue'
import QuotesTab from './components/QuotesTab.vue'
import MemberTab from './components/MemberTab.vue'
import PageHeader from '@/components/layout/PageHeader.vue'
@@ -52,9 +53,10 @@ const availableYears = ref<number[]>([])
const selectedYear = ref<number>(0) // 0 表示全部
const isInitialLoad = ref(true) // 用于跳过初始加载时的 watch 触发,并控制首屏加载状态
// Tab 配置 - 私聊有总览、语录、成员、AI实验室(趋势已合并到总览)
// Tab 配置 - 私聊有总览、视图、语录、成员、AI实验室
const tabs = [
{ id: 'overview', labelKey: 'analysis.tabs.overview', icon: 'i-heroicons-chart-pie' },
{ id: 'view', labelKey: 'analysis.tabs.view', icon: 'i-heroicons-presentation-chart-bar' },
{ id: 'quotes', labelKey: 'analysis.tabs.quotes', icon: 'i-heroicons-chat-bubble-left-right' },
{ id: 'member', labelKey: 'analysis.tabs.member', icon: 'i-heroicons-user-group' },
{ id: 'ai', labelKey: 'analysis.tabs.ai', icon: 'i-heroicons-sparkles' },
@@ -350,6 +352,12 @@ onMounted(() => {
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
/>
<ViewTab
v-else-if="activeTab === 'view'"
:key="'view-' + selectedYear"
:session-id="currentSessionId!"
:time-filter="timeFilter"
/>
<QuotesTab
v-else-if="activeTab === 'quotes'"
:key="'quotes-' + selectedYear"