Files
ChatLab/packages/chart-message/MessageProfileCard.vue
T
2026-04-23 00:04:27 +08:00

478 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDark } from '@vueuse/core'
import * as echarts from 'echarts/core'
import { PieChart, BarChart } from 'echarts/charts'
import { TooltipComponent, GridComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { ThemeCard } from '@/components/UI'
import { MessageType, getMessageTypeName } from './types'
import type { MessageTypeCount, HourlyActivity, WeekdayActivity, DailyActivity, TextStats } from './types'
import { queryLongMessageCount } from './queries'
import dayjs from 'dayjs'
echarts.use([PieChart, BarChart, TooltipComponent, GridComponent, CanvasRenderer])
const { t } = useI18n()
const isDark = useDark()
interface TimeFilter {
startTs?: number
endTs?: number
memberId?: number | null
}
const props = defineProps<{
sessionId: string
sessionName: string
messageTypes: MessageTypeCount[]
hourlyActivity: HourlyActivity[]
weekdayActivity: WeekdayActivity[]
dailyActivity: DailyActivity[]
textStats: TextStats
timeFilter?: TimeFilter
}>()
// ==================== 核心数字 ====================
const totalMessages = computed(() => props.messageTypes.reduce((sum, item) => sum + item.count, 0))
const mediaRatio = computed(() => {
if (totalMessages.value === 0) return 0
const nonText = totalMessages.value - (props.messageTypes.find((m) => m.type === MessageType.TEXT)?.count ?? 0)
return Math.round((nonText / totalMessages.value) * 100)
})
// ==================== 小作文爱好者(可调阈值) ====================
const essayThreshold = ref(30)
const essayCount = ref(0)
const isEssayLoading = ref(false)
const essayThresholdOptions = [
{ label: '20', value: 20 },
{ label: '30', value: 30 },
{ label: '50', value: 50 },
{ label: '80', value: 80 },
{ label: '100', value: 100 },
]
const essayThresholdModel = computed({
get: () => essayThreshold.value,
set: (val: number) => {
if (essayThreshold.value === val) return
essayThreshold.value = val
loadEssayCount()
},
})
async function loadEssayCount() {
if (!props.sessionId) return
isEssayLoading.value = true
try {
essayCount.value = await queryLongMessageCount(props.sessionId, props.timeFilter, essayThreshold.value)
} catch (error) {
console.error('[chart-message] Failed to load essay count:', error)
} finally {
isEssayLoading.value = false
}
}
watch(
() => [props.sessionId, props.timeFilter],
() => loadEssayCount(),
{ immediate: true, deep: true }
)
// ==================== 短消息达人 ====================
const shortRatio = computed(() => {
if (props.textStats.textCount === 0) return 0
return Math.round((props.textStats.shortCount / props.textStats.textCount) * 100)
})
// ==================== 媒体丰富度 ====================
function getTypeCount(type: MessageType): number {
return props.messageTypes.find((m) => m.type === type)?.count ?? 0
}
const mediaItems = computed(() => [
{ label: t('views.message.profile.images'), count: getTypeCount(MessageType.IMAGE) },
{ label: t('views.message.profile.emoji'), count: getTypeCount(MessageType.EMOJI) },
{
label: t('views.message.profile.voiceVideo'),
count: getTypeCount(MessageType.VOICE) + getTypeCount(MessageType.VIDEO),
},
])
// ==================== 巅峰记录 ====================
const peakDay = computed(() => {
if (props.dailyActivity.length === 0) return null
return props.dailyActivity.reduce((max, d) => (d.messageCount > max.messageCount ? d : max), props.dailyActivity[0])
})
// ==================== 最活跃时段 TOP3 ====================
const topHours = computed(() => {
if (props.hourlyActivity.length === 0) return []
return [...props.hourlyActivity].sort((a, b) => b.messageCount - a.messageCount).slice(0, 3)
})
// ==================== 文字表达力 ====================
const textExpressionValue = computed(() =>
t('views.message.profile.avgLengthUnit', { count: props.textStats.avgLength || 0 })
)
const textExpressionDesc = computed(() => {
if (props.textStats.maxLength > 0) {
return t('views.message.profile.textExpressionDesc', { count: props.textStats.maxLength })
}
return ''
})
// ==================== 聊天时间跨度 ====================
const dateRange = computed(() => {
if (props.dailyActivity.length === 0) return { first: '', last: '' }
const sorted = [...props.dailyActivity].sort((a, b) => a.date.localeCompare(b.date))
return {
first: dayjs(sorted[0].date).format('YYYY/MM/DD'),
last: dayjs(sorted[sorted.length - 1].date).format('YYYY/MM/DD'),
}
})
// ==================== 指标卡片数据 ====================
interface MetricItem {
icon: string
label: string
value: string
subtext: string
colorClass: string
slot?: string
}
const metricItems = computed<MetricItem[]>(() => [
{
icon: 'i-heroicons-pencil-square',
label: t('views.message.profile.textExpression'),
value: textExpressionValue.value,
subtext: textExpressionDesc.value,
colorClass: 'text-violet-600 dark:text-violet-400',
},
{
icon: 'i-heroicons-chat-bubble-bottom-center-text',
label: t('views.message.profile.shortMaster'),
value: `${shortRatio.value}%`,
subtext: t('views.message.profile.shortMasterDesc', { count: props.textStats.shortCount }),
colorClass: 'text-emerald-600 dark:text-emerald-400',
},
{
icon: 'i-heroicons-document-text',
label: t('views.message.profile.essayLover'),
value: essayCount.value.toLocaleString(),
subtext: t('views.message.profile.essayLoverDesc', { threshold: essayThreshold.value }),
colorClass: 'text-indigo-600 dark:text-indigo-400',
slot: 'essay-threshold',
},
{
icon: 'i-heroicons-photo',
label: t('views.message.profile.mediaRichness'),
value: `${mediaRatio.value}%`,
subtext:
mediaItems.value
.filter((m) => m.count > 0)
.map((m) => `${m.label} ${m.count}`)
.join(' · ') || '-',
colorClass: 'text-pink-600 dark:text-pink-400',
},
{
icon: 'i-heroicons-fire',
label: t('views.message.profile.peakRecord'),
value: peakDay.value ? dayjs(peakDay.value.date).format('MM/DD') : '-',
subtext: peakDay.value ? t('views.message.profile.peakRecordDesc', { count: peakDay.value.messageCount }) : '',
colorClass: 'text-red-600 dark:text-red-400',
},
{
icon: 'i-heroicons-clock',
label: t('views.message.profile.topHours'),
value: topHours.value.length > 0 ? `${topHours.value[0].hour}:00` : '-',
subtext: topHours.value.length >= 2 ? topHours.value.map((h) => `${h.hour}:00`).join(' > ') : '',
colorClass: 'text-cyan-600 dark:text-cyan-400',
},
])
// ==================== 迷你环形图 ====================
const donutRef = ref<HTMLElement | null>(null)
let donutInstance: echarts.ECharts | null = null
const typeColors = [
'#6366f1',
'#ec4899',
'#f97316',
'#22c55e',
'#06b6d4',
'#8b5cf6',
'#f43f5e',
'#eab308',
'#14b8a6',
'#3b82f6',
]
const donutData = computed(() => {
const sorted = [...props.messageTypes].sort((a, b) => b.count - a.count)
return sorted.slice(0, 6).map((item, i) => ({
name: getMessageTypeName(item.type, t),
value: item.count,
itemStyle: { color: typeColors[i % typeColors.length] },
}))
})
function initDonut() {
if (!donutRef.value) return
donutInstance = echarts.init(donutRef.value, undefined, { renderer: 'canvas' })
updateDonut()
}
function updateDonut() {
if (!donutInstance) return
donutInstance.setOption(
{
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
type: 'pie',
radius: ['50%', '78%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
padAngle: 2,
itemStyle: { borderRadius: 4 },
label: { show: false },
emphasis: { label: { show: false }, scaleSize: 4 },
data: donutData.value,
},
],
},
{ notMerge: true }
)
}
// ==================== 24h 迷你柱状图 ====================
const barRef = ref<HTMLElement | null>(null)
let barInstance: echarts.ECharts | null = null
function initBar() {
barInstance = echarts.init(barRef.value, undefined, { renderer: 'canvas' })
updateBar()
}
function updateBar() {
if (!barInstance) return
const hourMap = new Map(props.hourlyActivity.map((h) => [h.hour, h.messageCount]))
const data: number[] = []
for (let i = 0; i < 24; i++) data.push(hourMap.get(i) || 0)
const maxVal = Math.max(...data, 1)
const barColors = data.map((v) => {
const ratio = v / maxVal
if (ratio > 0.8) return isDark.value ? '#f472b6' : '#ec4899'
if (ratio > 0.5) return isDark.value ? '#a78bfa' : '#8b5cf6'
return isDark.value ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.08)'
})
barInstance.setOption(
{
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
formatter: (params: any) => `${params[0].axisValue}:00 — ${params[0].value}`,
},
grid: { left: 0, right: 0, top: 4, bottom: 16 },
xAxis: {
type: 'category',
data: Array.from({ length: 24 }, (_, i) => `${i}`),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
fontSize: 9,
color: isDark.value ? '#6b7280' : '#9ca3af',
interval: (idx: number) => idx % 6 === 0,
},
},
yAxis: { type: 'value', show: false },
series: [
{
type: 'bar',
data: data.map((v, i) => ({ value: v, itemStyle: { color: barColors[i] } })),
barWidth: '60%',
itemStyle: { borderRadius: [2, 2, 0, 0] },
},
],
},
{ notMerge: true }
)
}
// ==================== 生命周期 ====================
function handleResize() {
donutInstance?.resize()
barInstance?.resize()
}
watch(
() => props.messageTypes,
() => {
updateDonut()
updateBar()
}
)
watch(
() => props.hourlyActivity,
() => updateBar()
)
watch(isDark, () => {
donutInstance?.dispose()
barInstance?.dispose()
initDonut()
initBar()
})
onMounted(() => {
initDonut()
initBar()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
donutInstance?.dispose()
barInstance?.dispose()
})
</script>
<template>
<ThemeCard variant="elevated" decorative class="flex flex-col">
<!-- 主视觉区域 -->
<div class="relative z-10 px-6 pt-8 pb-4 sm:px-8">
<div class="flex items-start gap-6 sm:gap-10">
<!-- 左侧叙事文字 + 核心数字 -->
<div class="min-w-0 flex-1">
<div class="flex flex-col text-[15px] leading-relaxed text-gray-600 dark:text-gray-300">
<p class="mb-2 text-sm font-medium tracking-wide text-gray-500 dark:text-gray-400">
{{ dateRange.first }} {{ dateRange.last }}
</p>
<div class="mb-3 flex items-baseline gap-2">
<span class="text-xl font-medium text-gray-700 dark:text-gray-300">
{{ t('views.message.profile.heroLine1Prefix') }}
</span>
<span class="font-black text-5xl tracking-tight text-gray-900 dark:text-white">
{{ totalMessages.toLocaleString() }}
</span>
<span class="text-xl font-medium text-gray-700 dark:text-gray-300">
{{ t('views.message.profile.heroLine1Suffix') }}
</span>
</div>
<div class="flex items-baseline flex-wrap gap-x-1.5 gap-y-1">
<span class="text-base font-medium text-gray-600 dark:text-gray-300">
{{ t('views.message.profile.heroLine2Prefix') }}
</span>
<span class="font-black text-3xl text-pink-500 dark:text-pink-400">
{{ textStats.avgLength || 0 }}
</span>
<span class="text-base font-medium text-gray-600 dark:text-gray-300">
{{ t('views.message.profile.heroLine2Middle') }}
</span>
<span class="font-bold text-xl text-gray-900 dark:text-white">{{ mediaRatio }}%</span>
<span class="text-base font-medium text-gray-600 dark:text-gray-300">
{{ t('views.message.profile.heroLine2Suffix') }}
</span>
</div>
</div>
</div>
<!-- 右侧环形图 + 24h 柱状图 并排 -->
<div class="flex shrink-0 items-start gap-4">
<div class="flex flex-col items-center">
<div class="mb-1 text-[10px] font-bold text-gray-500 dark:text-gray-400">
{{ t('views.message.profile.typeDistribution') }}
</div>
<div ref="donutRef" style="width: 110px; height: 110px" />
</div>
<div class="flex flex-col items-center">
<div class="mb-1 text-[10px] font-bold text-gray-500 dark:text-gray-400">
{{ t('views.message.profile.hourlyDistribution') }}
</div>
<div ref="barRef" style="width: 180px; height: 100px" />
</div>
</div>
</div>
</div>
<!-- 指标卡片 -->
<div class="relative z-10 px-6 pb-6 pt-4 sm:px-8">
<div class="mb-3 flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-400 dark:text-gray-500">
Message Profile
</span>
</div>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-3">
<div
v-for="item in metricItems"
:key="item.icon + item.label"
class="flex items-start gap-2 rounded-lg bg-white/60 p-2.5 ring-1 ring-gray-900/5 dark:bg-white/5 dark:ring-white/10"
>
<UIcon :name="item.icon" class="mt-0.5 h-3.5 w-3.5 shrink-0" :class="item.colorClass" />
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-1">
<div class="truncate font-mono text-sm font-black leading-tight tabular-nums" :class="item.colorClass">
{{ item.value }}
</div>
<USelect
v-if="item.slot === 'essay-threshold'"
v-model="essayThresholdModel"
:items="essayThresholdOptions"
value-key="value"
size="xs"
class="relative z-120 w-16 shrink-0"
:ui="{ content: 'z-[121]' }"
:disabled="isEssayLoading"
/>
</div>
<div class="mt-0.5 truncate text-[10px] font-medium text-gray-500 dark:text-gray-400">
{{ item.label }}
</div>
<div class="mt-0.5 truncate text-[9px] text-gray-400 dark:text-gray-500">
{{ item.subtext }}
</div>
</div>
</div>
</div>
</div>
<!-- 水印 -->
<div
class="relative z-10 flex items-center justify-between px-6 pb-4 opacity-40 mix-blend-luminosity dark:opacity-30 sm:px-8 sm:pb-5"
>
<div class="flex items-center gap-1.5">
<UIcon name="i-heroicons-chat-bubble-left-right-solid" class="h-3.5 w-3.5" />
<span class="text-[10px] font-bold uppercase tracking-wider">ChatLab</span>
</div>
<span class="text-[9px] font-medium uppercase tracking-widest">
{{ t('views.message.profile.watermark') }}
</span>
</div>
</ThemeCard>
</template>