refactor: 重构部分图表为插件形式

This commit is contained in:
digua
2026-02-19 22:56:41 +08:00
parent 1f4c0cfbf7
commit 8a12aa5c1b
69 changed files with 2969 additions and 2049 deletions
+411
View File
@@ -0,0 +1,411 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { EChartPie, EChartBar, EChartHeatmap, EChartCalendar } from '@/components/charts'
import type { EChartPieData, EChartBarData, EChartHeatmapData, EChartCalendarData } from '@/components/charts'
import { SectionCard } from '@/components/UI'
import {
queryMessageTypes,
queryHourlyActivity,
queryDailyActivity,
queryWeekdayActivity,
queryMonthlyActivity,
queryYearlyActivity,
queryLengthDistribution,
} from './queries'
import { getMessageTypeName } from './types'
import type {
HourlyActivity,
WeekdayActivity,
MonthlyActivity,
DailyActivity,
YearlyActivity,
MessageTypeCount,
} from './types'
interface TimeFilter {
startTs?: number
endTs?: number
memberId?: number | null
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const { t } = useI18n()
// 数据状态
const isLoading = ref(true)
const messageTypes = ref<MessageTypeCount[]>([])
const hourlyActivity = ref<HourlyActivity[]>([])
const weekdayActivity = ref<WeekdayActivity[]>([])
const monthlyActivity = ref<MonthlyActivity[]>([])
const yearlyActivity = ref<YearlyActivity[]>([])
const dailyActivity = ref<DailyActivity[]>([])
const lengthDetail = ref<Array<{ len: number; count: number }>>([])
const lengthGrouped = ref<Array<{ range: string; count: number }>>([])
// 星期名称(按 1=周一 到 7=周日 的顺序)
const weekdayNames = computed(() => [
t('common.weekday.mon'),
t('common.weekday.tue'),
t('common.weekday.wed'),
t('common.weekday.thu'),
t('common.weekday.fri'),
t('common.weekday.sat'),
t('common.weekday.sun'),
])
// 月份名称
const monthNames = computed(() => [
t('common.month.jan'),
t('common.month.feb'),
t('common.month.mar'),
t('common.month.apr'),
t('common.month.may'),
t('common.month.jun'),
t('common.month.jul'),
t('common.month.aug'),
t('common.month.sep'),
t('common.month.oct'),
t('common.month.nov'),
t('common.month.dec'),
])
// 消息类型饼图数据
const typeChartData = computed<EChartPieData>(() => {
const sorted = [...messageTypes.value].sort((a, b) => b.count - a.count)
return {
labels: sorted.map((item) => getMessageTypeName(item.type)),
values: sorted.map((item) => item.count),
}
})
// 消息类型摘要数据(用于右侧列表展示)
const typeSummary = computed(() => {
const total = messageTypes.value.reduce((sum, item) => sum + item.count, 0)
const sorted = [...messageTypes.value].sort((a, b) => b.count - a.count)
return sorted.map((item) => ({
name: getMessageTypeName(item.type),
count: item.count,
percentage: total > 0 ? Math.round((item.count / total) * 100) : 0,
}))
})
// 类型颜色
const typeColors = [
'#6366f1',
'#8b5cf6',
'#ec4899',
'#f43f5e',
'#f97316',
'#eab308',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#3b82f6',
]
function getTypeColor(index: number): string {
return typeColors[index % typeColors.length]
}
// 小时分布图表数据
const hourlyChartData = computed<EChartBarData>(() => {
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<EChartBarData>(() => {
const dayMap = new Map(weekdayActivity.value.map((w) => [w.weekday, w.messageCount]))
const values: number[] = []
for (let i = 1; i <= 7; i++) {
values.push(dayMap.get(i) || 0)
}
return { labels: weekdayNames.value, values }
})
// 月份分布图表数据
const monthlyChartData = computed<EChartBarData>(() => {
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<EChartBarData>(() => {
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),
}
})
// 消息长度详细分布图表数据
const lengthDetailChartData = computed<EChartBarData>(() => ({
labels: lengthDetail.value.map((d) => String(d.len)),
values: lengthDetail.value.map((d) => d.count),
}))
// 消息长度分组分布图表数据
const lengthGroupedChartData = computed<EChartBarData>(() => ({
labels: lengthGrouped.value.map((d) => d.range),
values: lengthGrouped.value.map((d) => d.count),
}))
// 热力图数据(小时 x 星期)
const heatmapChartData = computed<EChartHeatmapData>(() => {
const xLabels = Array.from({ length: 24 }, (_, i) => `${i}:00`)
const yLabels = weekdayNames.value
const total = messageTypes.value.reduce((sum, item) => sum + item.count, 0) || 1
const data: Array<[number, number, number]> = []
for (let day = 1; day <= 7; day++) {
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
const value = Math.round((dayCount * hourCount) / total)
data.push([hour, day - 1, value])
}
}
return { xLabels, yLabels, data }
})
// 日历热力图数据
const calendarChartData = computed<EChartCalendarData[]>(() =>
dailyActivity.value.map((d) => ({ date: d.date, value: d.messageCount }))
)
// 日历可用年份
const calendarYears = computed(() => {
const years = new Set<number>()
dailyActivity.value.forEach((d) => {
const year = parseInt(d.date.split('-')[0])
if (!isNaN(year)) years.add(year)
})
return Array.from(years).sort((a, b) => b - a)
})
const selectedCalendarYear = ref<number>(new Date().getFullYear())
const filteredCalendarData = computed(() => {
const year = selectedCalendarYear.value
return calendarChartData.value.filter((d) => d.date.startsWith(`${year}-`))
})
// 加载数据 — 所有查询通过 window.chatApi.pluginQuery 在 Worker 线程执行
async function loadData() {
if (!props.sessionId) return
isLoading.value = true
try {
const [types, hourly, weekday, monthly, yearly, daily, lengthData] = await Promise.all([
queryMessageTypes(props.sessionId, props.timeFilter),
queryHourlyActivity(props.sessionId, props.timeFilter),
queryWeekdayActivity(props.sessionId, props.timeFilter),
queryMonthlyActivity(props.sessionId, props.timeFilter),
queryYearlyActivity(props.sessionId, props.timeFilter),
queryDailyActivity(props.sessionId, props.timeFilter),
queryLengthDistribution(props.sessionId, props.timeFilter),
])
messageTypes.value = types
hourlyActivity.value = hourly
weekdayActivity.value = weekday
monthlyActivity.value = monthly
yearlyActivity.value = yearly
dailyActivity.value = daily
lengthDetail.value = lengthData.detail
lengthGrouped.value = lengthData.grouped
if (calendarYears.value.length > 0) {
selectedCalendarYear.value = calendarYears.value[0]
}
} catch (error) {
console.error('[chart-message] Failed to load data:', 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('views.message.typeDistribution')" :show-divider="false">
<div class="p-5">
<div v-if="typeChartData.values.length > 0" class="grid grid-cols-1 gap-6 lg:grid-cols-2 lg:items-center">
<div>
<EChartPie :data="typeChartData" :height="280" :show-legend="false" />
</div>
<div>
<div class="space-y-3">
<div v-for="(item, index) in typeSummary" :key="index" class="flex items-center gap-3">
<div class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: getTypeColor(index) }" />
<div class="min-w-20 shrink-0 text-sm text-gray-700 dark:text-gray-300">{{ item.name }}</div>
<div class="flex-1">
<div class="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full transition-all"
:style="{ width: `${item.percentage}%`, backgroundColor: getTypeColor(index) }"
/>
</div>
</div>
<div class="shrink-0 text-right">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ item.count.toLocaleString() }}
</span>
<span class="ml-1 text-xs text-gray-400">({{ item.percentage }}%)</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex h-48 items-center justify-center text-gray-400">
{{ t('views.message.noData') }}
</div>
</div>
</SectionCard>
<!-- 时间分布图表小时 & 星期 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard :title="t('views.message.hourlyDistribution')" :show-divider="false">
<div class="p-5">
<EChartBar :data="hourlyChartData" :height="200" />
</div>
</SectionCard>
<SectionCard :title="t('views.message.weekdayDistribution')" :show-divider="false">
<div class="p-5">
<EChartBar :data="weekdayChartData" :height="200" />
</div>
</SectionCard>
</div>
<!-- 时间分布图表月份 & 年份 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard :title="t('views.message.monthlyDistribution')" :show-divider="false">
<div class="p-5">
<EChartBar :data="monthlyChartData" :height="200" />
</div>
</SectionCard>
<SectionCard :title="t('views.message.yearlyDistribution')" :show-divider="false">
<div class="p-5">
<EChartBar 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('views.message.noData') }}
</div>
</div>
</SectionCard>
</div>
<!-- 消息长度分布 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard :title="t('views.message.lengthDetailTitle')" :show-divider="false">
<template #headerRight>
<span class="text-xs text-gray-400">{{ t('views.message.lengthDetailHint') }}</span>
</template>
<div class="p-5">
<EChartBar
v-if="lengthDetailChartData.values.some((v) => v > 0)"
:data="lengthDetailChartData"
:height="200"
/>
<div v-else class="flex h-48 items-center justify-center text-gray-400">
{{ t('views.message.noTextMessages') }}
</div>
</div>
</SectionCard>
<SectionCard :title="t('views.message.lengthGroupedTitle')" :show-divider="false">
<template #headerRight>
<span class="text-xs text-gray-400">{{ t('views.message.lengthGroupedHint') }}</span>
</template>
<div class="p-5">
<EChartBar
v-if="lengthGroupedChartData.values.some((v) => v > 0)"
:data="lengthGroupedChartData"
:height="200"
/>
<div v-else class="flex h-48 items-center justify-center text-gray-400">
{{ t('views.message.noTextMessages') }}
</div>
</div>
</SectionCard>
</div>
<!-- 时间热力图 -->
<SectionCard :title="t('views.message.timeHeatmap')" :show-divider="false">
<template #headerRight>
<span class="text-xs text-gray-400">{{ t('views.message.heatmapHint') }}</span>
</template>
<div class="p-5">
<EChartHeatmap :data="heatmapChartData" :height="320" />
</div>
</SectionCard>
<!-- 日历热力图 -->
<SectionCard :title="t('views.message.calendarHeatmap')" :show-divider="false">
<template #headerRight>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">{{ t('views.message.calendarHint') }}</span>
<USelect
v-if="calendarYears.length > 1"
v-model="selectedCalendarYear"
:items="calendarYears.map((y) => ({ value: y, label: String(y) }))"
size="xs"
class="w-20"
/>
</div>
</template>
<div class="p-5">
<EChartCalendar
v-if="filteredCalendarData.length > 0"
:data="filteredCalendarData"
:year="selectedCalendarYear"
:height="180"
/>
<div v-else class="flex h-32 items-center justify-center text-gray-400">
{{ t('views.message.noData') }}
</div>
</div>
</SectionCard>
</template>
</div>
</template>
+205
View File
@@ -0,0 +1,205 @@
/**
* chart-message SQL 查询
* 直接通过 window.chatApi.pluginQuery 执行(参数化 + readonly + Worker 线程)
*/
import type {
HourlyActivity,
DailyActivity,
WeekdayActivity,
MonthlyActivity,
YearlyActivity,
MessageTypeCount,
LengthDistribution,
} from './types'
interface TimeFilter {
startTs?: number
endTs?: number
memberId?: number | null
}
/**
* 构建时间和成员过滤条件
*/
function buildFilter(filter?: TimeFilter): { conditions: string; params: any[] } {
const parts: string[] = []
const params: any[] = []
if (filter?.startTs != null) {
parts.push('AND msg.ts >= ?')
params.push(filter.startTs)
}
if (filter?.endTs != null) {
parts.push('AND msg.ts <= ?')
params.push(filter.endTs)
}
if (filter?.memberId != null) {
parts.push('AND msg.sender_id = ?')
params.push(filter.memberId)
}
return { conditions: parts.join(' '), params }
}
/** 系统消息过滤条件(始终排除) */
const SYSTEM_FILTER = "AND COALESCE(m.account_name, '') != '系统消息'"
/** 获取消息类型分布 */
export async function queryMessageTypes(sessionId: string, timeFilter?: TimeFilter): Promise<MessageTypeCount[]> {
const { conditions, params } = buildFilter(timeFilter)
return window.chatApi.pluginQuery<MessageTypeCount>(
sessionId,
`SELECT msg.type, COUNT(*) as count
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE 1=1 ${SYSTEM_FILTER} ${conditions}
GROUP BY msg.type
ORDER BY count DESC`,
params
)
}
/** 获取每小时活跃度分布 */
export async function queryHourlyActivity(sessionId: string, timeFilter?: TimeFilter): Promise<HourlyActivity[]> {
const { conditions, params } = buildFilter(timeFilter)
return window.chatApi.pluginQuery<HourlyActivity>(
sessionId,
`SELECT
CAST(strftime('%H', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as hour,
COUNT(*) as messageCount
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE 1=1 ${SYSTEM_FILTER} ${conditions}
GROUP BY hour
ORDER BY hour`,
params
)
}
/** 获取每日活跃度趋势 */
export async function queryDailyActivity(sessionId: string, timeFilter?: TimeFilter): Promise<DailyActivity[]> {
const { conditions, params } = buildFilter(timeFilter)
return window.chatApi.pluginQuery<DailyActivity>(
sessionId,
`SELECT
strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date,
COUNT(*) as messageCount
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE 1=1 ${SYSTEM_FILTER} ${conditions}
GROUP BY date
ORDER BY date`,
params
)
}
/** 获取星期活跃度分布 */
export async function queryWeekdayActivity(sessionId: string, timeFilter?: TimeFilter): Promise<WeekdayActivity[]> {
const { conditions, params } = buildFilter(timeFilter)
return window.chatApi.pluginQuery<WeekdayActivity>(
sessionId,
`SELECT
CASE
WHEN CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) = 0 THEN 7
ELSE CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER)
END as weekday,
COUNT(*) as messageCount
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE 1=1 ${SYSTEM_FILTER} ${conditions}
GROUP BY weekday
ORDER BY weekday`,
params
)
}
/** 获取月份活跃度分布 */
export async function queryMonthlyActivity(sessionId: string, timeFilter?: TimeFilter): Promise<MonthlyActivity[]> {
const { conditions, params } = buildFilter(timeFilter)
return window.chatApi.pluginQuery<MonthlyActivity>(
sessionId,
`SELECT
CAST(strftime('%m', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as month,
COUNT(*) as messageCount
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE 1=1 ${SYSTEM_FILTER} ${conditions}
GROUP BY month
ORDER BY month`,
params
)
}
/** 获取年份活跃度分布 */
export async function queryYearlyActivity(sessionId: string, timeFilter?: TimeFilter): Promise<YearlyActivity[]> {
const { conditions, params } = buildFilter(timeFilter)
return window.chatApi.pluginQuery<YearlyActivity>(
sessionId,
`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
WHERE 1=1 ${SYSTEM_FILTER} ${conditions}
GROUP BY year
ORDER BY year`,
params
)
}
/** 获取消息长度分布(仅文字消息) */
export async function queryLengthDistribution(sessionId: string, timeFilter?: TimeFilter): Promise<LengthDistribution> {
const { conditions, params } = buildFilter(timeFilter)
const rows = await window.chatApi.pluginQuery<{ len: number; count: number }>(
sessionId,
`SELECT LENGTH(msg.content) as len, COUNT(*) as count
FROM message msg
JOIN member m ON msg.sender_id = m.id
WHERE 1=1 ${SYSTEM_FILTER} ${conditions}
AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 0
GROUP BY len
ORDER BY len`,
params
)
// 构建 detail1-25 逐字
const detail: Array<{ len: number; count: number }> = []
for (let i = 1; i <= 25; i++) {
const found = rows.find((r) => r.len === i)
detail.push({ len: i, count: found ? found.count : 0 })
}
// 构建 grouped:分段统计
const ranges = [
{ min: 1, max: 5, label: '1-5' },
{ min: 6, max: 10, label: '6-10' },
{ min: 11, max: 15, label: '11-15' },
{ min: 16, max: 20, label: '16-20' },
{ min: 21, max: 25, label: '21-25' },
{ min: 26, max: 30, label: '26-30' },
{ min: 31, max: 35, label: '31-35' },
{ min: 36, max: 40, label: '36-40' },
{ min: 41, max: 45, label: '41-45' },
{ min: 46, max: 50, label: '46-50' },
{ min: 51, max: 60, label: '51-60' },
{ min: 61, max: 70, label: '61-70' },
{ min: 71, max: 80, label: '71-80' },
{ min: 81, max: 100, label: '81-100' },
{ min: 101, max: Infinity, label: '100+' },
]
const grouped: Array<{ range: string; count: number }> = ranges.map((r) => ({
range: r.label,
count: rows.filter((row) => row.len >= r.min && row.len <= r.max).reduce((sum, row) => sum + row.count, 0),
}))
return { detail, grouped }
}
+96
View File
@@ -0,0 +1,96 @@
/**
* chart-message 插件本地类型定义
*/
/** 消息类型枚举 */
export enum MessageType {
TEXT = 0,
IMAGE = 1,
VOICE = 2,
VIDEO = 3,
FILE = 4,
EMOJI = 5,
LINK = 6,
LOCATION = 7,
RED_PACKET = 20,
TRANSFER = 21,
POKE = 22,
CALL = 30,
SHARE = 31,
REPLY = 32,
FORWARD = 33,
CONTACT = 34,
SYSTEM = 80,
RECALL = 81,
OTHER = 99,
}
/** 消息类型名称映射 */
const MESSAGE_TYPE_NAMES: Record<number, string> = {
[MessageType.TEXT]: '文字',
[MessageType.IMAGE]: '图片',
[MessageType.VOICE]: '语音',
[MessageType.VIDEO]: '视频',
[MessageType.FILE]: '文件',
[MessageType.EMOJI]: '表情',
[MessageType.LINK]: '链接',
[MessageType.LOCATION]: '位置',
[MessageType.RED_PACKET]: '红包',
[MessageType.TRANSFER]: '转账',
[MessageType.POKE]: '拍一拍',
[MessageType.CALL]: '通话',
[MessageType.SHARE]: '分享',
[MessageType.REPLY]: '回复',
[MessageType.FORWARD]: '转发',
[MessageType.CONTACT]: '名片',
[MessageType.SYSTEM]: '系统',
[MessageType.RECALL]: '撤回',
[MessageType.OTHER]: '其他',
}
/** 获取消息类型名称 */
export function getMessageTypeName(type: MessageType | number): string {
return MESSAGE_TYPE_NAMES[type] || '未知'
}
/** 小时活跃度 */
export interface HourlyActivity {
hour: number
messageCount: number
}
/** 日期活跃度 */
export interface DailyActivity {
date: string
messageCount: number
}
/** 星期活跃度 */
export interface WeekdayActivity {
weekday: number
messageCount: number
}
/** 月份活跃度 */
export interface MonthlyActivity {
month: number
messageCount: number
}
/** 年份活跃度 */
export interface YearlyActivity {
year: number
messageCount: number
}
/** 消息类型分布 */
export interface MessageTypeCount {
type: number
count: number
}
/** 消息长度分布 */
export interface LengthDistribution {
detail: Array<{ len: number; count: number }>
grouped: Array<{ range: string; count: number }>
}