添加埋点管理台
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, Clock3, Smartphone, Sparkles } from "lucide-react";
|
||||
|
||||
import { DashboardCharts } from "@/components/admin/dashboard-charts";
|
||||
import { getDashboardData, parseDashboardQuery } from "@/lib/analytics/server";
|
||||
import { formatDateTime, formatNumber } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ranges = [
|
||||
{ label: "24 小时", value: "1" },
|
||||
{ label: "7 天", value: "7" },
|
||||
{ label: "30 天", value: "30" },
|
||||
{ label: "90 天", value: "90" },
|
||||
] as const;
|
||||
|
||||
type AdminPageProps = {
|
||||
searchParams: Promise<{
|
||||
range?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function AdminPage({ searchParams }: AdminPageProps) {
|
||||
const rawParams = await searchParams;
|
||||
const query = parseDashboardQuery(rawParams);
|
||||
const data = await getDashboardData(query);
|
||||
|
||||
return (
|
||||
<main className="relative overflow-hidden px-6 py-8 sm:px-8 lg:px-10">
|
||||
<div className="hero-orb left-[-120px] top-[10%]" />
|
||||
<div className="hero-orb right-[-120px] top-[-40px] bg-[radial-gradient(circle,_rgba(29,111,104,0.16)_0%,_rgba(29,111,104,0)_72%)]" />
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||
<section className="soft-card p-7 sm:p-8">
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-[var(--surface-2)] px-4 py-2 text-sm text-[var(--ink-2)]">
|
||||
<Sparkles className="h-4 w-4 text-[var(--accent-1)]" />
|
||||
Android 陪伴数据台
|
||||
</div>
|
||||
<div>
|
||||
<p className="eyebrow">使用总览</p>
|
||||
<h1 className="font-[var(--font-display)] text-4xl leading-tight text-[var(--ink-1)] sm:text-5xl">
|
||||
最近谁在使用,哪里需要留意,一眼看清。
|
||||
</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-base leading-8 text-[var(--ink-2)]">
|
||||
这里持续汇总老年陪伴助手在 Android 应用中的使用情况,方便你查看哪些功能最常被打开、哪些设备最近还在使用,以及哪里出现了中断或失败。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 xl:items-end">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--line-soft)] bg-white/75 px-5 py-3 text-sm font-semibold text-[var(--ink-1)]"
|
||||
>
|
||||
返回首页
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-[var(--ink-2)]">
|
||||
<span className="pill-tag">Android 应用</span>
|
||||
<span className="pill-tag">数据自动更新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-5">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-[var(--ink-3)]">时间范围</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ranges.map((item) => (
|
||||
<Link
|
||||
key={item.value}
|
||||
href={`?range=${item.value}`}
|
||||
className={query.days === Number(item.value) ? "filter-pill filter-pill-active" : "filter-pill"}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{data.stats.map((stat) => (
|
||||
<article key={stat.label} className="stat-card">
|
||||
<p className="stat-label">{stat.label}</p>
|
||||
<strong className="stat-value">{formatNumber(stat.value)}</strong>
|
||||
<p className="stat-hint">{stat.hint}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<DashboardCharts activityTrend={data.activityTrend} topEvents={data.topEvents} />
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<article className="soft-card p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="eyebrow">常用功能</p>
|
||||
<h2 className="text-2xl font-semibold text-[var(--ink-1)]">最近更常打开哪里</h2>
|
||||
</div>
|
||||
<div className="pill-tag">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
近 {query.days} 天
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{data.topPaths.map((item, index) => (
|
||||
<div key={item.label} className="list-row">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="index-badge">{index + 1}</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--ink-3)]">功能页面</p>
|
||||
<p className="text-base font-semibold text-[var(--ink-1)]">{item.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
<strong className="text-lg font-semibold text-[var(--ink-1)]">{formatNumber(item.total)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="soft-card p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="eyebrow">最近活跃设备</p>
|
||||
<h2 className="text-2xl font-semibold text-[var(--ink-1)]">刚刚还在使用的是谁</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{data.recentSessions.map((session) => (
|
||||
<div key={session.sessionKey} className="rounded-[24px] border border-[var(--line-soft)] bg-white/80 px-4 py-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--ink-2)]">
|
||||
<Smartphone className="h-4 w-4 text-[var(--accent-2)]" />
|
||||
Android 设备
|
||||
</div>
|
||||
<span className="text-xs text-[var(--ink-3)]">{formatDateTime(session.lastSeenAt)}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-semibold text-[var(--ink-1)]">{session.deviceLabel}</p>
|
||||
<p className="text-sm text-[var(--ink-2)]">设备会话 {session.sessionKey.slice(0, 18)}...</p>
|
||||
<p className="text-sm text-[var(--ink-3)]">
|
||||
{formatNumber(session.eventCount)} 条事件
|
||||
{session.appVersion ? ` · 版本 ${session.appVersion}` : ""}
|
||||
{session.locale ? ` · ${session.locale}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="soft-card p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="eyebrow">最近记录</p>
|
||||
<h2 className="text-2xl font-semibold text-[var(--ink-1)]">刚刚发生了什么</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border-separate border-spacing-y-3">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-[var(--ink-3)]">
|
||||
<th className="px-4">时间</th>
|
||||
<th className="px-4">终端</th>
|
||||
<th className="px-4">动作</th>
|
||||
<th className="px-4">页面 / 功能</th>
|
||||
<th className="px-4">补充说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.recentEvents.map((event) => (
|
||||
<tr key={event.id} className="rounded-[24px] bg-white/80 text-sm text-[var(--ink-2)] shadow-[var(--shadow-card)]">
|
||||
<td className="rounded-l-[20px] px-4 py-4">{formatDateTime(event.occurredAt)}</td>
|
||||
<td className="px-4 py-4">Android</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className="inline-flex rounded-full bg-[var(--surface-2)] px-3 py-1 text-[var(--ink-1)]">
|
||||
{event.name}
|
||||
</span>
|
||||
<p className="mt-2 text-xs tracking-[0.18em] text-[var(--ink-3)]">{event.type}</p>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<p className="font-medium text-[var(--ink-1)]">{event.pathname ?? "未标记功能"}</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-3)]">{event.component ?? "无额外标签"}</p>
|
||||
</td>
|
||||
<td className="rounded-r-[20px] px-4 py-4">
|
||||
<p>{event.elementText ?? "无额外说明"}</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-3)]">设备会话 {event.sessionKey.slice(0, 18)}...</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { ingestAnalyticsBatch } from "@/lib/analytics/server";
|
||||
import { TRACK_API_PATH } from "@/lib/site";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const responseHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "content-type, authorization",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Cache-Control": "no-store",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(
|
||||
{
|
||||
ok: true,
|
||||
endpoint: TRACK_API_PATH,
|
||||
message: "Analytics ingestion endpoint is ready.",
|
||||
},
|
||||
{ headers: responseHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const payload = await request.json();
|
||||
const result = await ingestAnalyticsBatch(payload, request.headers);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
ok: true,
|
||||
accepted: result.accepted,
|
||||
sessionKey: result.sessionKey,
|
||||
},
|
||||
{ headers: responseHeaders },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "INVALID_ANALYTICS_PAYLOAD",
|
||||
issues: error.issues,
|
||||
},
|
||||
{ status: 400, headers: responseHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "UNKNOWN_ERROR",
|
||||
},
|
||||
{ status: 500, headers: responseHeaders },
|
||||
);
|
||||
}
|
||||
}
|
||||
+153
-14
@@ -1,26 +1,165 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--surface-0: #fff7f0;
|
||||
--surface-1: rgba(255, 251, 246, 0.84);
|
||||
--surface-2: #f8e6d7;
|
||||
--surface-3: #f2dcc7;
|
||||
--ink-1: #241915;
|
||||
--ink-2: #5f4a3d;
|
||||
--ink-3: #9a806d;
|
||||
--accent-1: #b6522d;
|
||||
--accent-2: #1d6f68;
|
||||
--accent-3: #e39657;
|
||||
--line-soft: rgba(86, 61, 39, 0.12);
|
||||
--shadow-card: 0 20px 50px rgba(81, 51, 30, 0.12);
|
||||
--shadow-soft: 0 28px 60px rgba(95, 55, 24, 0.08);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-background: var(--surface-0);
|
||||
--color-foreground: var(--ink-1);
|
||||
--font-sans: var(--font-body);
|
||||
--font-mono: var(--font-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(251, 219, 190, 0.75) 0%, rgba(251, 219, 190, 0) 28%),
|
||||
radial-gradient(circle at bottom right, rgba(194, 230, 221, 0.65) 0%, rgba(194, 230, 221, 0) 30%),
|
||||
linear-gradient(180deg, #fffaf5 0%, #fff7f0 32%, #f8efe5 100%);
|
||||
color: var(--ink-1);
|
||||
font-family: var(--font-body), "Noto Sans SC", sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(182, 82, 45, 0.18);
|
||||
}
|
||||
|
||||
.soft-card {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 32px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.hero-orb {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
height: 320px;
|
||||
width: 320px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(230, 150, 87, 0.18) 0%, rgba(230, 150, 87, 0) 72%);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--accent-1);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pill-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
padding: 0.55rem 0.9rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
padding: 1.35rem;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.86rem;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
margin-top: 0.7rem;
|
||||
font-family: var(--font-display), serif;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1;
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
.stat-hint {
|
||||
margin-top: 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
padding: 0.7rem 1rem;
|
||||
color: var(--ink-2);
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease;
|
||||
}
|
||||
|
||||
.filter-pill:hover {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.filter-pill-active {
|
||||
border-color: rgba(182, 82, 45, 0.18);
|
||||
background: linear-gradient(135deg, rgba(182, 82, 45, 0.14), rgba(182, 82, 45, 0.06));
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
.index-badge {
|
||||
display: inline-flex;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--accent-1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-radius: 26px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
+33
-10
@@ -1,20 +1,43 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Fraunces, IBM_Plex_Mono, Noto_Sans_SC } from "next/font/google";
|
||||
|
||||
import { SITE_NAME, SITE_URL } from "@/lib/site";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const bodyFont = Noto_Sans_SC({
|
||||
variable: "--font-body",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "700"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const displayFont = Fraunces({
|
||||
variable: "--font-display",
|
||||
subsets: ["latin"],
|
||||
weight: ["500", "700"],
|
||||
});
|
||||
|
||||
const monoFont = IBM_Plex_Mono({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: SITE_NAME,
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
description: "查看老年陪伴助手 Android 应用的使用情况、关键流程和异常提醒。",
|
||||
openGraph: {
|
||||
title: SITE_NAME,
|
||||
description: "聚焦老年陪伴助手 Android 应用的使用概况与运营观察。",
|
||||
url: SITE_URL,
|
||||
siteName: SITE_NAME,
|
||||
locale: "zh_CN",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +47,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
lang="zh-CN"
|
||||
className={`${bodyFont.variable} ${displayFont.variable} ${monoFont.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full bg-[var(--surface-0)] text-[var(--ink-1)]">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+155
-61
@@ -1,65 +1,159 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Activity, ArrowRight, Clock3, Smartphone, Sparkles } from "lucide-react";
|
||||
|
||||
import { getPublicAnalyticsSnapshot } from "@/lib/analytics/server";
|
||||
import { ADMIN_PATH } from "@/lib/site";
|
||||
import { formatNumber } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const focusCards = [
|
||||
{
|
||||
icon: Smartphone,
|
||||
title: "看哪些长辈还在持续使用",
|
||||
description: "活跃设备和最近打开的功能会持续更新,方便判断陪伴助手有没有被真正用起来。",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "看陪伴流程是否顺畅",
|
||||
description: "进入通话、开始互动、创建专属音色这些关键动作,都会按时间留下记录。",
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
title: "看哪里需要尽快处理",
|
||||
description: "当连接、播放、录音或资源准备失败时,后台会留下清晰的异常提醒。",
|
||||
},
|
||||
];
|
||||
|
||||
export default async function Home() {
|
||||
const snapshot = await getPublicAnalyticsSnapshot();
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<main className="relative overflow-hidden px-6 py-8 sm:px-8 lg:px-10">
|
||||
<div className="hero-orb left-[-120px] top-[-80px]" />
|
||||
<div className="hero-orb right-[-80px] top-[20%] bg-[radial-gradient(circle,_rgba(25,113,103,0.18)_0%,_rgba(25,113,103,0)_72%)]" />
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-8">
|
||||
<section className="soft-card relative overflow-hidden px-7 py-8 sm:px-10 sm:py-10">
|
||||
<div className="absolute inset-y-0 right-0 hidden w-1/3 bg-[radial-gradient(circle_at_center,_rgba(255,255,255,0.5)_0%,_rgba(255,255,255,0)_70%)] lg:block" />
|
||||
<div className="grid gap-8 lg:grid-cols-[1.15fr_0.85fr] lg:items-center">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-[var(--ink-2)]">
|
||||
<span className="pill-tag">老年陪伴助手</span>
|
||||
<span className="pill-tag">Android 使用概览</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="eyebrow">陪伴助手总览</p>
|
||||
<h1 className="max-w-3xl font-[var(--font-display)] text-4xl leading-tight text-[var(--ink-1)] sm:text-5xl lg:text-6xl">
|
||||
把每一次陪伴是否顺畅,看得更清楚一些。
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg leading-8 text-[var(--ink-2)]">
|
||||
在这里关注老年陪伴助手数字人应用里的真实使用情况,帮助你了解长辈最近是否在使用、常用哪些功能、有没有遇到卡住或失败。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link
|
||||
href={ADMIN_PATH}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent-1)] px-6 py-3 text-sm font-semibold text-white shadow-[var(--shadow-card)] transition-transform hover:-translate-y-0.5"
|
||||
>
|
||||
打开管理员面板
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<article className="stat-card">
|
||||
<p className="stat-label">24 小时互动记录</p>
|
||||
<strong className="stat-value">{formatNumber(snapshot.events24h)}</strong>
|
||||
<p className="stat-hint">最近一天里记录到的关键动作</p>
|
||||
</article>
|
||||
<article className="stat-card">
|
||||
<p className="stat-label">24 小时活跃设备</p>
|
||||
<strong className="stat-value">{formatNumber(snapshot.sessions24h)}</strong>
|
||||
<p className="stat-hint">最近一天里仍在使用的 Android 设备</p>
|
||||
</article>
|
||||
<article className="stat-card">
|
||||
<p className="stat-label">最近最常见动作</p>
|
||||
<strong className="stat-value text-[clamp(1.5rem,2vw,2rem)]">{snapshot.topAction}</strong>
|
||||
<p className="stat-hint">帮助快速判断大家最近最常用哪项能力</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-3">
|
||||
{focusCards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<article key={card.title} className="soft-card p-6">
|
||||
<div className="mb-5 inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--surface-2)] text-[var(--accent-1)]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="mb-3 text-2xl font-semibold text-[var(--ink-1)]">{card.title}</h2>
|
||||
<p className="text-base leading-7 text-[var(--ink-2)]">{card.description}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<article className="soft-card p-7">
|
||||
<p className="eyebrow">后台重点关注</p>
|
||||
<h2 className="mb-5 text-3xl font-semibold text-[var(--ink-1)]">这些关键过程会持续留痕</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{[
|
||||
"进入首页、通话页和音色克隆页",
|
||||
"选择形象、进入陪伴通话",
|
||||
"开始录音、重录、完成录音",
|
||||
"创建专属音色和开始体验",
|
||||
"陪伴通话连接、断开和结束",
|
||||
"资源准备、播放和摄像头异常",
|
||||
].map((item) => (
|
||||
<div key={item} className="rounded-[22px] border border-[var(--line-soft)] bg-white/80 px-4 py-4 text-[var(--ink-2)]">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="soft-card p-7">
|
||||
<p className="eyebrow">适合日常巡检</p>
|
||||
<h2 className="mb-5 text-3xl font-semibold text-[var(--ink-1)]">打开后台后,先看这三件事</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
icon: Clock3,
|
||||
title: "先看最近一天是不是还有人在使用",
|
||||
description: "如果活跃设备突然明显减少,就要尽快确认入口、资源和服务是不是出了问题。",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "再看最近最常见的动作有没有变化",
|
||||
description: "高频动作能反映长辈现在更依赖哪项能力,也能判断新功能有没有被接受。",
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
title: "最后看异常提醒有没有集中出现",
|
||||
description: "一旦某类失败突然增多,通常就说明某个陪伴流程需要尽快排查。",
|
||||
},
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.title} className="rounded-[22px] border border-[var(--line-soft)] bg-white/80 px-4 py-4">
|
||||
<div className="mb-3 inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--surface-2)] text-[var(--accent-1)]">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-[var(--ink-1)]">{item.title}</h3>
|
||||
<p className="text-sm leading-7 text-[var(--ink-2)]">{item.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user