feat: 消息Tab下新增分享卡片

This commit is contained in:
digua
2026-04-12 00:20:39 +08:00
committed by digua
parent 92d9533839
commit 5b96fec7e1
13 changed files with 675 additions and 21 deletions
@@ -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>
+21 -1
View File
@@ -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">
+43
View File
@@ -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)
+8
View File
@@ -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
}
+29 -1
View File
@@ -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",
+29 -1
View File
@@ -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": "メンション関係図",
+29 -1
View File
@@ -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": "艾特互动关系图",
+29 -1
View File
@@ -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": "艾特互動關係圖",
+2 -1
View File
@@ -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"
+1
View File
@@ -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 {
+1
View File
@@ -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