mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-30 16:52:45 +08:00
feat: 新增视图Tab
This commit is contained in:
@@ -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 []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取消息类型分布
|
||||
*/
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息类型分布
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
getDailyActivity,
|
||||
getWeekdayActivity,
|
||||
getMonthlyActivity,
|
||||
getYearlyActivity,
|
||||
getMessageTypeDistribution,
|
||||
getTimeRange,
|
||||
getMemberNameHistory,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
4
electron/preload/index.d.ts
vendored
4
electron/preload/index.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取消息类型分布
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"view": "View",
|
||||
"ranking": "Rankings",
|
||||
"groupQuotes": "Quotes",
|
||||
"quotes": "Quotes",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "总览",
|
||||
"view": "视图",
|
||||
"ranking": "群榜单",
|
||||
"groupQuotes": "群语录",
|
||||
"quotes": "语录",
|
||||
|
||||
91
src/pages/private-chat/components/ViewTab.vue
Normal file
91
src/pages/private-chat/components/ViewTab.vue
Normal 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>
|
||||
|
||||
420
src/pages/private-chat/components/view/MessageView.vue
Normal file
420
src/pages/private-chat/components/view/MessageView.vue
Normal 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>
|
||||
48
src/pages/private-chat/components/view/PortraitView.vue
Normal file
48
src/pages/private-chat/components/view/PortraitView.vue
Normal 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>
|
||||
|
||||
48
src/pages/private-chat/components/view/TimelineView.vue
Normal file
48
src/pages/private-chat/components/view/TimelineView.vue
Normal 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>
|
||||
|
||||
48
src/pages/private-chat/components/view/WordcloudView.vue
Normal file
48
src/pages/private-chat/components/view/WordcloudView.vue
Normal 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user