mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-20 21:30:28 +08:00
feat: 消息Tab下新增分享卡片
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
<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 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 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>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
queryMonthlyActivity,
|
||||
queryYearlyActivity,
|
||||
queryLengthDistribution,
|
||||
queryTextStats,
|
||||
} from './queries'
|
||||
import { getMessageTypeName } from './types'
|
||||
import type {
|
||||
@@ -21,7 +22,9 @@ import type {
|
||||
DailyActivity,
|
||||
YearlyActivity,
|
||||
MessageTypeCount,
|
||||
TextStats,
|
||||
} from './types'
|
||||
import MessageProfileCard from './MessageProfileCard.vue'
|
||||
|
||||
interface TimeFilter {
|
||||
startTs?: number
|
||||
@@ -31,6 +34,7 @@ interface TimeFilter {
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
sessionName?: string
|
||||
timeFilter?: TimeFilter
|
||||
}>()
|
||||
|
||||
@@ -46,6 +50,7 @@ const yearlyActivity = ref<YearlyActivity[]>([])
|
||||
const dailyActivity = ref<DailyActivity[]>([])
|
||||
const lengthDetail = ref<Array<{ len: number; count: number }>>([])
|
||||
const lengthGrouped = ref<Array<{ range: string; count: number }>>([])
|
||||
const textStats = ref<TextStats>({ textCount: 0, avgLength: 0, maxLength: 0, shortCount: 0 })
|
||||
|
||||
// 星期名称(按 1=周一 到 7=周日 的顺序)
|
||||
const weekdayNames = computed(() => [
|
||||
@@ -221,7 +226,7 @@ async function loadData() {
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [types, hourly, weekday, monthly, yearly, daily, lengthData] = await Promise.all([
|
||||
const [types, hourly, weekday, monthly, yearly, daily, lengthData, txtStats] = await Promise.all([
|
||||
queryMessageTypes(props.sessionId, props.timeFilter),
|
||||
queryHourlyActivity(props.sessionId, props.timeFilter),
|
||||
queryWeekdayActivity(props.sessionId, props.timeFilter),
|
||||
@@ -229,6 +234,7 @@ async function loadData() {
|
||||
queryYearlyActivity(props.sessionId, props.timeFilter),
|
||||
queryDailyActivity(props.sessionId, props.timeFilter),
|
||||
queryLengthDistribution(props.sessionId, props.timeFilter),
|
||||
queryTextStats(props.sessionId, props.timeFilter),
|
||||
])
|
||||
|
||||
messageTypes.value = types
|
||||
@@ -239,6 +245,7 @@ async function loadData() {
|
||||
dailyActivity.value = daily
|
||||
lengthDetail.value = lengthData.detail
|
||||
lengthGrouped.value = lengthData.grouped
|
||||
textStats.value = txtStats
|
||||
|
||||
if (calendarYears.value.length > 0) {
|
||||
selectedCalendarYear.value = calendarYears.value[0]
|
||||
@@ -266,6 +273,19 @@ watch(
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 消息画像卡 -->
|
||||
<MessageProfileCard
|
||||
v-if="messageTypes.length > 0"
|
||||
:session-id="sessionId"
|
||||
:session-name="sessionName || ''"
|
||||
:message-types="messageTypes"
|
||||
:hourly-activity="hourlyActivity"
|
||||
:weekday-activity="weekdayActivity"
|
||||
:daily-activity="dailyActivity"
|
||||
:text-stats="textStats"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
|
||||
<!-- 消息类型分布 -->
|
||||
<SectionCard :title="t('views.message.typeDistribution')" :show-divider="false">
|
||||
<div class="p-5">
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
YearlyActivity,
|
||||
MessageTypeCount,
|
||||
LengthDistribution,
|
||||
TextStats,
|
||||
} from './types'
|
||||
|
||||
interface TimeFilter {
|
||||
@@ -154,6 +155,48 @@ export async function queryYearlyActivity(sessionId: string, timeFilter?: TimeFi
|
||||
)
|
||||
}
|
||||
|
||||
/** 获取文字消息统计(数量、平均长度、最大长度) */
|
||||
export async function queryTextStats(sessionId: string, timeFilter?: TimeFilter): Promise<TextStats> {
|
||||
const { conditions, params } = buildFilter(timeFilter)
|
||||
|
||||
const rows = await window.chatApi.pluginQuery<TextStats>(
|
||||
sessionId,
|
||||
`SELECT
|
||||
COUNT(*) as textCount,
|
||||
ROUND(AVG(LENGTH(msg.content)), 1) as avgLength,
|
||||
MAX(LENGTH(msg.content)) as maxLength,
|
||||
SUM(CASE WHEN LENGTH(msg.content) <= 5 THEN 1 ELSE 0 END) as shortCount
|
||||
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`,
|
||||
params
|
||||
)
|
||||
|
||||
return rows[0] ?? { textCount: 0, avgLength: 0, maxLength: 0, shortCount: 0 }
|
||||
}
|
||||
|
||||
/** 获取长消息(小作文)数量,minLength 为字符阈值 */
|
||||
export async function queryLongMessageCount(
|
||||
sessionId: string,
|
||||
timeFilter?: TimeFilter,
|
||||
minLength = 30
|
||||
): Promise<number> {
|
||||
const { conditions, params } = buildFilter(timeFilter)
|
||||
|
||||
const rows = await window.chatApi.pluginQuery<{ cnt: number }>(
|
||||
sessionId,
|
||||
`SELECT COUNT(*) as cnt
|
||||
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) >= ?`,
|
||||
[...params, minLength]
|
||||
)
|
||||
|
||||
return rows[0]?.cnt ?? 0
|
||||
}
|
||||
|
||||
/** 获取消息长度分布(仅文字消息) */
|
||||
export async function queryLengthDistribution(sessionId: string, timeFilter?: TimeFilter): Promise<LengthDistribution> {
|
||||
const { conditions, params } = buildFilter(timeFilter)
|
||||
|
||||
@@ -104,3 +104,11 @@ export interface LengthDistribution {
|
||||
detail: Array<{ len: number; count: number }>
|
||||
grouped: Array<{ range: string; count: number }>
|
||||
}
|
||||
|
||||
/** 文字消息统计 */
|
||||
export interface TextStats {
|
||||
textCount: number
|
||||
avgLength: number
|
||||
maxLength: number
|
||||
shortCount: number
|
||||
}
|
||||
|
||||
@@ -43,7 +43,35 @@
|
||||
"lengthGroupedTitle": "Length Range Distribution",
|
||||
"lengthGroupedHint": "Grouped by 5",
|
||||
"noTextMessages": "No text messages",
|
||||
"noData": "No data"
|
||||
"noData": "No data",
|
||||
"profile": {
|
||||
"totalMessages": "Total",
|
||||
"textMessages": "Text Messages",
|
||||
"avgLength": "Avg Length",
|
||||
"avgLengthUnit": "{count} chars/msg",
|
||||
"mediaRatio": "Media Ratio",
|
||||
"textExpression": "Text Expression",
|
||||
"textExpressionDesc": "Longest message {count} chars",
|
||||
"mediaRichness": "Media Richness",
|
||||
"shortMaster": "Short Msg Master",
|
||||
"shortMasterDesc": "{count} msgs with ≤5 chars",
|
||||
"essayLover": "Essay Lover",
|
||||
"essayLoverDesc": "Messages with ≥{threshold} chars",
|
||||
"peakRecord": "Peak Record",
|
||||
"peakRecordDesc": "Max {count} msgs in a day",
|
||||
"topHours": "Top Active Hours",
|
||||
"images": "Images",
|
||||
"emoji": "Emoji",
|
||||
"voiceVideo": "Voice/Video",
|
||||
"typeDistribution": "Msg Types",
|
||||
"hourlyDistribution": "24h Activity",
|
||||
"watermark": "Message Report",
|
||||
"heroLine1Prefix": "A total of",
|
||||
"heroLine1Suffix": "messages exchanged",
|
||||
"heroLine2Prefix": "Averaging",
|
||||
"heroLine2Middle": "chars each, with",
|
||||
"heroLine2Suffix": "being non-text"
|
||||
}
|
||||
},
|
||||
"interaction": {
|
||||
"mentionGraph": "Mention Interaction Graph",
|
||||
|
||||
@@ -43,7 +43,35 @@
|
||||
"lengthGroupedTitle": "文字数区間分布",
|
||||
"lengthGroupedHint": "5文字ごとにグループ化",
|
||||
"noTextMessages": "テキストメッセージがありません",
|
||||
"noData": "データがありません"
|
||||
"noData": "データがありません",
|
||||
"profile": {
|
||||
"totalMessages": "総メッセージ",
|
||||
"textMessages": "テキストメッセージ",
|
||||
"avgLength": "平均文字数",
|
||||
"avgLengthUnit": "{count} 文字/件",
|
||||
"mediaRatio": "メディア比率",
|
||||
"textExpression": "テキスト表現力",
|
||||
"textExpressionDesc": "最長メッセージ {count} 文字",
|
||||
"mediaRichness": "メディアの豊かさ",
|
||||
"shortMaster": "短文マスター",
|
||||
"shortMasterDesc": "5文字以下 {count} 件",
|
||||
"essayLover": "長文好き",
|
||||
"essayLoverDesc": "{threshold}文字以上のメッセージ",
|
||||
"peakRecord": "ピーク記録",
|
||||
"peakRecordDesc": "1日最多 {count} 件",
|
||||
"topHours": "最もアクティブな時間帯",
|
||||
"images": "画像",
|
||||
"emoji": "スタンプ",
|
||||
"voiceVideo": "音声/動画",
|
||||
"typeDistribution": "メッセージ種類",
|
||||
"hourlyDistribution": "24h アクティビティ",
|
||||
"watermark": "メッセージレポート",
|
||||
"heroLine1Prefix": "合計で",
|
||||
"heroLine1Suffix": "件のメッセージを交わしました",
|
||||
"heroLine2Prefix": "1件平均",
|
||||
"heroLine2Middle": "文字、そのうち",
|
||||
"heroLine2Suffix": "がテキスト以外"
|
||||
}
|
||||
},
|
||||
"interaction": {
|
||||
"mentionGraph": "メンション関係図",
|
||||
|
||||
@@ -43,7 +43,35 @@
|
||||
"lengthGroupedTitle": "长度区间分布",
|
||||
"lengthGroupedHint": "每5字一组",
|
||||
"noTextMessages": "暂无文字消息",
|
||||
"noData": "暂无数据"
|
||||
"noData": "暂无数据",
|
||||
"profile": {
|
||||
"totalMessages": "总消息",
|
||||
"textMessages": "文字消息",
|
||||
"avgLength": "平均长度",
|
||||
"avgLengthUnit": "{count} 字/条",
|
||||
"mediaRatio": "媒体占比",
|
||||
"textExpression": "文字表达力",
|
||||
"textExpressionDesc": "最长消息 {count} 字",
|
||||
"mediaRichness": "媒体丰富度",
|
||||
"shortMaster": "短消息达人",
|
||||
"shortMasterDesc": "共 {count} 条 ≤5字",
|
||||
"essayLover": "小作文爱好者",
|
||||
"essayLoverDesc": "≥{threshold}字的消息",
|
||||
"peakRecord": "巅峰记录",
|
||||
"peakRecordDesc": "单日最多 {count} 条",
|
||||
"topHours": "最活跃时段",
|
||||
"images": "图片",
|
||||
"emoji": "表情",
|
||||
"voiceVideo": "语音/视频",
|
||||
"typeDistribution": "消息类型",
|
||||
"hourlyDistribution": "24h 活跃",
|
||||
"watermark": "消息报告",
|
||||
"heroLine1Prefix": "你们共留下了",
|
||||
"heroLine1Suffix": "条消息",
|
||||
"heroLine2Prefix": "每条平均",
|
||||
"heroLine2Middle": "个字,其中",
|
||||
"heroLine2Suffix": "是非文字消息"
|
||||
}
|
||||
},
|
||||
"interaction": {
|
||||
"mentionGraph": "艾特互动关系图",
|
||||
|
||||
@@ -43,7 +43,35 @@
|
||||
"lengthGroupedTitle": "長度區間分佈",
|
||||
"lengthGroupedHint": "每5字一組",
|
||||
"noTextMessages": "暫無文字訊息",
|
||||
"noData": "暫無資料"
|
||||
"noData": "暫無資料",
|
||||
"profile": {
|
||||
"totalMessages": "總訊息",
|
||||
"textMessages": "文字訊息",
|
||||
"avgLength": "平均長度",
|
||||
"avgLengthUnit": "{count} 字/則",
|
||||
"mediaRatio": "媒體佔比",
|
||||
"textExpression": "文字表達力",
|
||||
"textExpressionDesc": "最長訊息 {count} 字",
|
||||
"mediaRichness": "媒體豐富度",
|
||||
"shortMaster": "短訊息達人",
|
||||
"shortMasterDesc": "共 {count} 則 ≤5字",
|
||||
"essayLover": "小作文愛好者",
|
||||
"essayLoverDesc": "≥{threshold}字的訊息",
|
||||
"peakRecord": "巔峰紀錄",
|
||||
"peakRecordDesc": "單日最多 {count} 則",
|
||||
"topHours": "最活躍時段",
|
||||
"images": "圖片",
|
||||
"emoji": "表情",
|
||||
"voiceVideo": "語音/影片",
|
||||
"typeDistribution": "訊息類型",
|
||||
"hourlyDistribution": "24h 活躍",
|
||||
"watermark": "訊息報告",
|
||||
"heroLine1Prefix": "你們共留下了",
|
||||
"heroLine1Suffix": "則訊息",
|
||||
"heroLine2Prefix": "每則平均",
|
||||
"heroLine2Middle": "個字,其中",
|
||||
"heroLine2Suffix": "是非文字訊息"
|
||||
}
|
||||
},
|
||||
"interaction": {
|
||||
"mentionGraph": "艾特互動關係圖",
|
||||
|
||||
@@ -19,6 +19,7 @@ interface TimeFilter {
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
sessionName?: string
|
||||
timeFilter?: TimeFilter
|
||||
}>()
|
||||
|
||||
@@ -61,7 +62,7 @@ const viewTimeFilter = computed(() => ({
|
||||
<!-- 子 Tab 内容 -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<MessageView v-if="activeSubTab === 'message'" :session-id="props.sessionId" :time-filter="viewTimeFilter" />
|
||||
<MessageView v-if="activeSubTab === 'message'" :session-id="props.sessionId" :session-name="props.sessionName" :time-filter="viewTimeFilter" />
|
||||
<InteractionView
|
||||
v-else-if="activeSubTab === 'interaction'"
|
||||
:session-id="props.sessionId"
|
||||
|
||||
@@ -196,6 +196,7 @@ const { headerDescription } = useSessionHeaderDescription({
|
||||
v-else-if="activeTab === 'view'"
|
||||
:key="'view-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
<QuotesTab
|
||||
|
||||
@@ -15,6 +15,7 @@ interface TimeFilter {
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
sessionName?: string
|
||||
timeFilter?: TimeFilter
|
||||
}>()
|
||||
|
||||
@@ -47,7 +48,7 @@ const viewTimeFilter = computed(() => ({
|
||||
<!-- 子 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="viewTimeFilter" />
|
||||
<MessageView v-if="activeSubTab === 'message'" :session-id="props.sessionId" :session-name="props.sessionName" :time-filter="viewTimeFilter" />
|
||||
<RelationshipView
|
||||
v-else-if="activeSubTab === 'relationship'"
|
||||
:session-id="props.sessionId"
|
||||
|
||||
@@ -101,20 +101,10 @@ const overallInitiateRatio = computed(() => {
|
||||
const timeRangeString = computed(() => {
|
||||
if (!stats.value || stats.value.months.length === 0) return ''
|
||||
const sorted = [...stats.value.months].sort((a, b) => a.month.localeCompare(b.month))
|
||||
const first = sorted[0].month
|
||||
const last = sorted[sorted.length - 1].month
|
||||
const [firstYear, firstMonth] = first.split('-')
|
||||
const [lastYear, lastMonth] = last.split('-')
|
||||
const firstMonthText = t('views.relationship.hero.monthPart', {
|
||||
year: firstYear,
|
||||
month: Number.parseInt(firstMonth, 10),
|
||||
})
|
||||
const lastMonthText = t('views.relationship.hero.monthPart', {
|
||||
year: lastYear,
|
||||
month: Number.parseInt(lastMonth, 10),
|
||||
})
|
||||
if (first === last) return t('views.relationship.hero.timeRange.singleMonth', { month: firstMonthText })
|
||||
return t('views.relationship.hero.timeRange.range', { start: firstMonthText, end: lastMonthText })
|
||||
const first = sorted[0].month.replace('-', '/')
|
||||
const last = sorted[sorted.length - 1].month.replace('-', '/')
|
||||
if (first === last) return first
|
||||
return `${first} – ${last}`
|
||||
})
|
||||
|
||||
function getOverallLabel(): string {
|
||||
|
||||
@@ -205,6 +205,7 @@ const otherMemberAvatar = computed(() => {
|
||||
v-else-if="activeTab === 'view'"
|
||||
:key="'view-' + currentSessionId"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
<QuotesTab
|
||||
|
||||
Reference in New Issue
Block a user