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