diff --git a/app/admin/page.tsx b/app/admin/page.tsx index b7a6c8d..48dac55 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -2,8 +2,9 @@ import Link from "next/link"; import { ArrowUpRight, Clock3, Smartphone, Sparkles } from "lucide-react"; import { DashboardCharts } from "@/components/admin/dashboard-charts"; +import type { DashboardData } from "@/lib/analytics/contracts"; import { getDashboardData, parseDashboardQuery } from "@/lib/analytics/server"; -import { formatDateTime, formatNumber } from "@/lib/utils"; +import { formatDateTime, formatDurationMs, formatNumber } from "@/lib/utils"; export const dynamic = "force-dynamic"; @@ -14,6 +15,41 @@ const ranges = [ { label: "90 天", value: "90" }, ] as const; +type RecentEventItem = DashboardData["recentEvents"][number]; +type ConversationItem = DashboardData["recentConversations"][number]; + +function describeConversationMode(mode: string | null) { + if (mode === "clone") { + return "专属音色模式"; + } + if (mode === "standard") { + return "标准模式"; + } + return null; +} + +function buildEventNote(event: RecentEventItem) { + const notes = [ + event.durationMs && event.durationMs > 0 ? `耗时 ${formatDurationMs(event.durationMs)}` : null, + event.value?.trim() || null, + event.elementText?.trim() || null, + ].filter(Boolean); + + return notes[0] ?? "无额外说明"; +} + +function buildConversationNote(item: ConversationItem) { + const notes = [ + item.turnIndex ? `第 ${item.turnIndex} 轮` : null, + describeConversationMode(item.mode), + item.durationMs && item.durationMs > 0 + ? `${item.role === "用户发言" ? "识别完成" : "文本完成"} ${formatDurationMs(item.durationMs)}` + : null, + ].filter(Boolean); + + return notes.join(" · "); +} + type AdminPageProps = { searchParams: Promise<{ range?: string; @@ -44,7 +80,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {

- 这里持续汇总老年陪伴助手在 Android 应用中的使用情况,方便你查看哪些功能最常被打开、哪些设备最近还在使用,以及哪里出现了中断或失败。 + 这里持续汇总老年陪伴助手在 Android 应用中的使用情况,除了常用功能和异常提醒,也会单独整理回复耗时、连接速度和最近对话片段,方便你直接判断体验是不是顺畅。

@@ -93,6 +129,27 @@ export default async function AdminPage({ searchParams }: AdminPageProps) { +
+
+
+

性能概览

+

陪伴流程现在快不快

+
+

只看真实耗时,不看空洞的成功失败计数

+
+ +
+ {data.performanceMetrics.map((metric) => ( +
+

{metric.label}

+ {formatDurationMs(metric.valueMs)} +

{metric.hint}

+

{metric.sampleCount > 0 ? `基于 ${formatNumber(metric.sampleCount)} 次记录` : "暂无样本"}

+
+ ))} +
+
+
@@ -155,6 +212,38 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
+
+
+
+

最近对话

+

刚刚聊了些什么

+
+

聊天记录会自动上传,这里只展示最新片段

+
+ +
+ {data.recentConversations.length > 0 ? ( + data.recentConversations.map((item) => ( +
+
+ + {item.role} + + {formatDateTime(item.occurredAt)} +
+

{item.text}

+

{buildConversationNote(item) || "已自动归档到聊天记录"}

+

设备会话 {item.sessionKey.slice(0, 18)}...

+
+ )) + ) : ( +
+ 当前时间范围内还没有自动上传的聊天片段。 +
+ )} +
+
+
@@ -189,7 +278,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {

{event.component ?? "无额外标签"}

-

{event.elementText ?? "无额外说明"}

+

{buildEventNote(event)}

设备会话 {event.sessionKey.slice(0, 18)}...

diff --git a/lib/analytics/contracts.ts b/lib/analytics/contracts.ts index 3100933..1166949 100644 --- a/lib/analytics/contracts.ts +++ b/lib/analytics/contracts.ts @@ -65,6 +65,13 @@ export interface DashboardStat { hint: string; } +export interface DashboardPerformanceMetric { + label: string; + valueMs: number | null; + sampleCount: number; + hint: string; +} + export interface DashboardTrendPoint { bucket: string; total: number; @@ -85,10 +92,23 @@ export interface DashboardRecentEvent { pathname: string | null; component: string | null; elementText: string | null; + value: string | null; + durationMs: number | null; occurredAt: string; metadata: JsonValue | null; } +export interface DashboardConversationItem { + id: string; + sessionKey: string; + role: string; + text: string; + mode: string | null; + turnIndex: number | null; + durationMs: number | null; + occurredAt: string; +} + export interface DashboardRecentSession { sessionKey: string; platform: AnalyticsPlatform; @@ -101,10 +121,12 @@ export interface DashboardRecentSession { export interface DashboardData { stats: DashboardStat[]; + performanceMetrics: DashboardPerformanceMetric[]; activityTrend: DashboardTrendPoint[]; topEvents: DashboardTopItem[]; topPaths: DashboardTopItem[]; recentEvents: DashboardRecentEvent[]; + recentConversations: DashboardConversationItem[]; recentSessions: DashboardRecentSession[]; } diff --git a/lib/analytics/presenter.ts b/lib/analytics/presenter.ts index 57c6a9c..342cbbf 100644 --- a/lib/analytics/presenter.ts +++ b/lib/analytics/presenter.ts @@ -1,5 +1,6 @@ const EVENT_LABELS: Record = { screen_view: "打开页面", + digital_human_init_ready: "数字人准备完成", voice_clone_entry_tap: "进入音色克隆", avatar_selected: "选择数字人形象", call_page_opened: "进入陪伴通话", @@ -30,7 +31,7 @@ const EVENT_LABELS: Record = { ai_conversation_error: "陪伴通话异常", user_transcript_received: "收到用户语音文本", assistant_transcript_completed: "陪伴回复已生成", - assistant_response_done: "陪伴回复结束", + assistant_response_done: "整轮回复完成", ai_conversation_stopped: "陪伴通话已结束", clone_tts_started: "开始播放专属音色", clone_tts_finished: "专属音色播放完成", @@ -38,6 +39,9 @@ const EVENT_LABELS: Record = { camera_enabled: "打开摄像头", camera_disabled: "关闭摄像头", camera_capture_error: "摄像头采集失败", + camera_first_frame_ready: "摄像头画面就绪", + user_message_uploaded: "用户聊天记录已上传", + assistant_message_uploaded: "助手聊天记录已上传", }; const PATH_LABELS: Record = { @@ -79,9 +83,24 @@ export function describeAnalyticsType(type: string) { return "页面浏览"; case "interaction": return "操作记录"; + case "performance": + return "性能指标"; + case "conversation": + return "聊天记录"; case "error": return "异常提醒"; default: return humanizeFallback(type); } +} + +export function describeConversationRole(role: string | null | undefined) { + switch ((role ?? "").toLowerCase()) { + case "assistant": + return "助手回复"; + case "user": + return "用户发言"; + default: + return "对话记录"; + } } \ No newline at end of file diff --git a/lib/analytics/server.ts b/lib/analytics/server.ts index 813676a..9958d36 100644 --- a/lib/analytics/server.ts +++ b/lib/analytics/server.ts @@ -12,6 +12,7 @@ import type { } from "@/lib/analytics/contracts"; import { ANALYTICS_RANGE_OPTIONS } from "@/lib/analytics/contracts"; import { + describeConversationRole, describeAnalyticsEventName, describeAnalyticsPath, describeAnalyticsType, @@ -165,6 +166,14 @@ function countResult(rows: Array<{ total: bigint | number | string | null | unde return Number(total ?? 0); } +function averageResult(rows: Array<{ average: number | null; total: bigint | number | string | null | undefined }>) { + const average = rows[0]?.average; + return { + average: average == null ? null : Number(average), + total: Number(rows[0]?.total ?? 0), + }; +} + function formatBucket(bucket: Date, days: number) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", @@ -188,6 +197,43 @@ function buildDeviceLabel(session: { return browser || "未标记设备"; } +function readMetadataObject(metadata: unknown) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return null; + } + + return metadata as Record; +} + +function readMetadataString(metadata: unknown, key: string) { + const object = readMetadataObject(metadata); + const value = object?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function readMetadataNumber(metadata: unknown, key: string) { + const object = readMetadataObject(metadata); + const value = object?.[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function compactText(value: string, max = 220) { + const normalized = value.trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + + return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized; +} + export function parseDashboardQuery(raw: { range?: string }): DashboardQuery { const parsedDays = Number(raw.range ?? 7); const days = ANALYTICS_RANGE_OPTIONS.includes(parsedDays as (typeof ANALYTICS_RANGE_OPTIONS)[number]) @@ -303,6 +349,7 @@ export async function getDashboardData(query: DashboardQuery): Promise>(` SELECT COUNT(*)::bigint AS total @@ -333,11 +385,6 @@ export async function getDashboardData(query: DashboardQuery): Promise>(` - SELECT COUNT(*)::bigint AS total - FROM "AnalyticsEvent" - WHERE ${eventFilter} - `), prisma.$queryRawUnsafe>(` SELECT COUNT(*)::bigint AS total FROM "AnalyticsEvent" @@ -348,11 +395,50 @@ export async function getDashboardData(query: DashboardQuery): Promise>(` + SELECT AVG("durationMs")::double precision AS average, + COUNT(*)::bigint AS total + FROM "AnalyticsEvent" + WHERE ${eventFilter} + AND "name" = 'assistant_response_done' + AND "durationMs" IS NOT NULL + `), + prisma.$queryRawUnsafe>(` + SELECT AVG("durationMs")::double precision AS average, + COUNT(*)::bigint AS total + FROM "AnalyticsEvent" + WHERE ${eventFilter} + AND "name" = 'ai_conversation_connected' + AND "durationMs" IS NOT NULL + `), + prisma.$queryRawUnsafe>(` + SELECT AVG("durationMs")::double precision AS average, + COUNT(*)::bigint AS total + FROM "AnalyticsEvent" + WHERE ${eventFilter} + AND "name" = 'assistant_message_uploaded' + AND "durationMs" IS NOT NULL + `), + prisma.$queryRawUnsafe>(` + SELECT AVG(NULLIF("metadata"->>'firstAudioLatencyMs', '')::double precision) AS average, + COUNT(*) FILTER (WHERE NULLIF("metadata"->>'firstAudioLatencyMs', '') IS NOT NULL)::bigint AS total + FROM "AnalyticsEvent" + WHERE ${eventFilter} + AND "name" = 'assistant_response_done' + `), + prisma.$queryRawUnsafe>(` + SELECT AVG("durationMs")::double precision AS average, + COUNT(*)::bigint AS total + FROM "AnalyticsEvent" + WHERE ${eventFilter} + AND "name" = 'voice_clone_create_succeeded' + AND "durationMs" IS NOT NULL + `), prisma.$queryRawUnsafe>(` SELECT date_trunc('${query.days <= 2 ? "hour" : "day"}', "occurredAt") AS bucket, COUNT(*)::bigint AS total FROM "AnalyticsEvent" - WHERE ${eventFilter} + WHERE ${usageEventFilter} GROUP BY 1 ORDER BY 1 ASC `), @@ -361,7 +447,7 @@ export async function getDashboardData(query: DashboardQuery): Promise 0 ? `平均每台设备 ${(eventCount / sessionCount).toFixed(1)} 条` : "当前还没有数据", + label: "平均回复耗时(ms)", + value: responseDuration.average == null ? 0 : Math.round(responseDuration.average), + hint: + responseDuration.total > 0 + ? `基于 ${responseDuration.total} 次已完成回复,从老人说完到整轮回复结束` + : "还没有可计算的回复耗时", }, { label: "异常提醒", @@ -439,6 +555,32 @@ export async function getDashboardData(query: DashboardQuery): Promise ({ bucket: formatBucket(row.bucket, query.days), total: Number(row.total), @@ -461,6 +603,8 @@ export async function getDashboardData(query: DashboardQuery): Promise ({ @@ -472,9 +616,37 @@ export async function getDashboardData(query: DashboardQuery): Promise { + const role = readMetadataString(event.metadata, "role") ?? (event.name.startsWith("assistant") ? "assistant" : "user"); + const fullText = readMetadataString(event.metadata, "text") ?? event.elementText ?? event.value ?? ""; + const mode = readMetadataString(event.metadata, "mode"); + const turnIndex = readMetadataNumber(event.metadata, "turnIndex"); + + return { + id: event.id, + sessionKey: event.sessionKey, + role: describeConversationRole(role), + text: compactText(fullText), + mode, + turnIndex: turnIndex == null ? null : Math.round(turnIndex), + durationMs: event.durationMs, + occurredAt: event.occurredAt.toISOString(), + }; + }), recentSessions: recentSessions.map((session: { sessionKey: string; platform: AnalyticsPlatform; @@ -501,12 +673,13 @@ export async function getDashboardData(query: DashboardQuery): Promise { const rangeStart = new Date(Date.now() - 24 * 60 * 60 * 1000); const eventFilter = buildRangeFilter("occurredAt", rangeStart); + const usageEventFilter = `${eventFilter} AND lower("type") NOT IN ('conversation', 'performance')`; const sessionFilter = buildRangeFilter("lastSeenAt", rangeStart); const [eventRows, sessionRows, topActionRows] = await Promise.all([ prisma.$queryRawUnsafe>(` SELECT COUNT(*)::bigint AS total FROM "AnalyticsEvent" - WHERE ${eventFilter} + WHERE ${usageEventFilter} `), prisma.$queryRawUnsafe>(` SELECT COUNT(*)::bigint AS total @@ -516,7 +689,7 @@ export async function getPublicAnalyticsSnapshot(): Promise>(` SELECT "name" AS label, COUNT(*)::bigint AS total FROM "AnalyticsEvent" - WHERE ${eventFilter} + WHERE ${usageEventFilter} GROUP BY 1 ORDER BY total DESC LIMIT 1 diff --git a/lib/utils.ts b/lib/utils.ts index d8eed9a..9d30c13 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -15,4 +15,24 @@ export function formatDateTime(value: string | Date) { hour: "2-digit", minute: "2-digit", }).format(new Date(value)); +} + +export function formatDurationMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) { + return "暂无"; + } + + if (value < 1000) { + return `${Math.round(value)} ms`; + } + + const seconds = value / 1000; + if (seconds < 60) { + const rounded = seconds >= 10 ? seconds.toFixed(1) : seconds.toFixed(2); + return `${rounded.replace(/\.0+$/, "")} s`; + } + + const minutes = Math.floor(seconds / 60); + const remainSeconds = Math.round(seconds % 60); + return `${minutes} 分 ${remainSeconds} 秒`; } \ No newline at end of file