Implement live analysis achievements and admin console

这个提交包含在:
cryptocommuniums-afk
2026-03-15 01:39:34 +08:00
父节点 d1b6603061
当前提交 edc66ea5bc
修改 23 个文件,包含 4033 行新增1022 行删除

查看文件

@@ -1,6 +1,6 @@
# Tennis Training Hub
网球训练管理与分析应用,提供训练计划、姿势分析、实时摄像头分析、在线视频录制与视频库管理。当前版本在媒体服务之外新增数据库驱动的后台任务系统,用于承接训练计划生成、动作纠正、多模态分析录制归档这类高延迟任务。
网球训练管理与分析应用,提供训练计划、姿势分析、实时摄像头分析、在线视频录制、成就系统、管理员工作台与视频库管理。当前版本在媒体服务之外新增数据库驱动的后台任务系统,用于承接训练计划生成、动作纠正、多模态分析录制归档与每日 NTRP 刷新这类高延迟任务。
## Architecture
@@ -11,6 +11,16 @@
- `docker-compose.yml`: 单机部署编排
- `deploy/nginx.te.hao.work.conf`: `te.hao.work` 的宿主机 nginx 入口配置
## Realtime Analysis
实时分析页现在采用“识别 + 录制 + 落库”一体化流程:
- 浏览器端基于 MediaPipe Pose 自动识别 `forehand / backhand / serve / volley / overhead / slice / lob / unknown`
- 连续同类动作会自动合并为片段,最长单段不超过 10 秒
- 停止分析后会自动保存动作区间、评分维度、反馈摘要和可选本地录制视频
- 实时分析结果会自动回写训练记录、日训练聚合、成就进度与 NTRP 评分链路
- 移动端支持竖屏最大化预览,主要操作按钮固定在侧边
## Online Recording
在线录制模块采用双链路设计:
@@ -32,10 +42,12 @@
- `analysis_corrections`
- `pose_correction_multimodal`
- `media_finalize`
- `ntrp_refresh_user`
- `ntrp_refresh_all`
前端提供全局任务中心,页面本地也会显示任务提交、执行中、完成或失败状态。训练页、分析页和录制页都可以在用户离开页面后继续完成后台任务。
另外提供独立日志页 `/logs`,用于查看后台任务历史、失败原因与通知记录。
另外提供独立日志页 `/logs`,用于查看后台任务历史、失败原因与通知记录;管理员工作台 `/admin` 可集中查看用户、后台任务、实时分析会话、应用设置和审计日志
## Multimodal LLM
@@ -46,6 +58,7 @@
- 系统内置“视觉标准图库”页面 `/vision-lab`,可把公网网球参考图入库并保存每次识别结果
- `ADMIN_USERNAMES` 可指定哪些用户名账号拥有 admin 视角,例如 `H1`
- 用户名登录支持直接进入系统;仅首次创建新用户时需要填写 `REGISTRATION_INVITE_CODE`
- 新用户首次登录时只需提交一次用户名;若用户名不存在才需要额外填写邀请码
## Quick Start
@@ -54,6 +67,7 @@
```bash
pnpm install
cp .env.example .env
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
pnpm dev
```
@@ -89,6 +103,12 @@ go build ./...
pnpm exec playwright install chromium
```
若本地数据库是空库或刚新增了 schema,先执行
```bash
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
```
## Production Deployment
单机部署推荐:
@@ -149,3 +169,4 @@ pnpm test:llm -- "你好,做个自我介绍"
- 浏览器兼容目标以 Chrome 为主
- 录制文件优先产出 WebM,MP4 为服务端可选归档产物
- 存储策略当前为本地卷优先,适合单机 Compose 部署
- 浏览器测试会启动真实 Node 服务,因此要求本地测试库已完成 Drizzle 迁移

查看文件

@@ -21,6 +21,7 @@ import Tutorials from "./pages/Tutorials";
import Reminders from "./pages/Reminders";
import VisionLab from "./pages/VisionLab";
import Logs from "./pages/Logs";
import AdminConsole from "./pages/AdminConsole";
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
return (
@@ -59,6 +60,9 @@ function Router() {
<Route path="/checkin">
<DashboardRoute component={Checkin} />
</Route>
<Route path="/achievements">
<DashboardRoute component={Checkin} />
</Route>
<Route path="/live-camera">
<DashboardRoute component={LiveCamera} />
</Route>
@@ -77,6 +81,9 @@ function Router() {
<Route path="/vision-lab">
<DashboardRoute component={VisionLab} />
</Route>
<Route path="/admin">
<DashboardRoute component={AdminConsole} />
</Route>
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>

查看文件

@@ -23,7 +23,7 @@ import { useIsMobile } from "@/hooks/useMobile";
import {
LayoutDashboard, LogOut, PanelLeft, Target, Video,
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot,
BookOpen, Bell, Microscope, ScrollText
BookOpen, Bell, Microscope, ScrollText, Shield
} from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation, Redirect } from "wouter";
@@ -41,7 +41,7 @@ type MenuItem = {
const menuItems: MenuItem[] = [
{ icon: LayoutDashboard, label: "仪表盘", path: "/dashboard", group: "main" },
{ icon: Target, label: "训练计划", path: "/training", group: "main" },
{ icon: Flame, label: "每日打卡", path: "/checkin", group: "main" },
{ icon: Flame, label: "成就系统", path: "/checkin", group: "main" },
{ icon: Camera, label: "实时分析", path: "/live-camera", group: "analysis" },
{ icon: CircleDot, label: "在线录制", path: "/recorder", group: "analysis" },
{ icon: Video, label: "视频分析", path: "/analysis", group: "analysis" },
@@ -53,6 +53,7 @@ const menuItems: MenuItem[] = [
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
{ icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" },
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
{ icon: Shield, label: "管理系统", path: "/admin", group: "learn", adminOnly: true },
];
const mobileNavItems = [

查看文件

@@ -0,0 +1,316 @@
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react";
export default function AdminConsole() {
const { user } = useAuth();
const utils = trpc.useUtils();
const usersQuery = trpc.admin.users.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
const tasksQuery = trpc.admin.tasks.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
const liveSessionsQuery = trpc.admin.liveSessions.useQuery({ limit: 50 }, { enabled: user?.role === "admin" });
const settingsQuery = trpc.admin.settings.useQuery(undefined, { enabled: user?.role === "admin" });
const auditQuery = trpc.admin.auditLogs.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
const [settingsDrafts, setSettingsDrafts] = useState<Record<string, string>>({});
const refreshAllMutation = trpc.admin.refreshAllNtrp.useMutation({
onSuccess: () => {
toast.success("已提交全量 NTRP 刷新任务");
utils.admin.tasks.invalidate();
},
onError: (error) => toast.error(`提交失败: ${error.message}`),
});
const refreshUserMutation = trpc.admin.refreshUserNtrp.useMutation({
onSuccess: () => {
toast.success("已提交用户 NTRP 刷新任务");
utils.admin.tasks.invalidate();
},
onError: (error) => toast.error(`提交失败: ${error.message}`),
});
const refreshUserNowMutation = trpc.admin.refreshUserNtrpNow.useMutation({
onSuccess: () => {
toast.success("用户 NTRP 已即时刷新");
utils.admin.users.invalidate();
utils.admin.auditLogs.invalidate();
},
onError: (error) => toast.error(`即时刷新失败: ${error.message}`),
});
const updateSettingMutation = trpc.admin.updateSetting.useMutation({
onSuccess: () => {
toast.success("设置已更新");
utils.admin.settings.invalidate();
utils.admin.auditLogs.invalidate();
},
onError: (error) => toast.error(`设置更新失败: ${error.message}`),
});
useEffect(() => {
const drafts: Record<string, string> = {};
(settingsQuery.data || []).forEach((item: any) => {
drafts[item.settingKey] = JSON.stringify(item.value ?? null);
});
setSettingsDrafts(drafts);
}, [settingsQuery.data]);
const totals = useMemo(() => ({
users: (usersQuery.data || []).length,
tasks: (tasksQuery.data || []).length,
sessions: (liveSessionsQuery.data || []).length,
}), [liveSessionsQuery.data, tasksQuery.data, usersQuery.data]);
if (user?.role !== "admin") {
return (
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>访</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_30%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
H1
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={() => refreshAllMutation.mutate()} disabled={refreshAllMutation.isPending} className="gap-2">
<RefreshCw className="h-4 w-4" />
NTRP
</Button>
</div>
</div>
</section>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-emerald-700" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{totals.users}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-sky-700" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{totals.tasks}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Sparkles className="h-5 w-5 text-orange-700" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{totals.sessions}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs defaultValue="users" className="space-y-4">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="users"></TabsTrigger>
<TabsTrigger value="tasks"></TabsTrigger>
<TabsTrigger value="sessions"></TabsTrigger>
<TabsTrigger value="settings"></TabsTrigger>
<TabsTrigger value="audit"></TabsTrigger>
</TabsList>
<TabsContent value="users">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription> NTRP</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(usersQuery.data || []).map((item: any) => (
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{item.name}</span>
<Badge variant="outline">{item.role}</Badge>
<Badge variant="outline">NTRP {Number(item.ntrpRating || 1.5).toFixed(1)}</Badge>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{item.totalSessions || 0} · {item.totalMinutes || 0} · {item.currentStreak || 0}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => refreshUserMutation.mutate({ userId: item.id })}>
</Button>
<Button size="sm" onClick={() => refreshUserNowMutation.mutate({ userId: item.id })}>
</Button>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tasks">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(tasksQuery.data || []).map((task: any) => (
<div key={task.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{task.title}</span>
<Badge variant="outline">{task.type}</Badge>
<Badge variant={task.status === "failed" ? "destructive" : task.status === "succeeded" ? "secondary" : "outline"}>
{task.status}
</Badge>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{task.userName || task.userId} · {new Date(task.createdAt).toLocaleString("zh-CN")}
</div>
</div>
<div className="min-w-[180px]">
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{task.message || "无描述"}</span>
<span>{task.progress || 0}%</span>
</div>
<div className="h-2 rounded-full bg-muted">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${task.progress || 0}%` }} />
</div>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="sessions">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(liveSessionsQuery.data || []).map((session: any) => (
<div key={session.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{session.title}</span>
<Badge variant="outline">{session.userName || session.userId}</Badge>
<Badge variant="outline">{session.sessionMode}</Badge>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{session.dominantAction || "unknown"} · {session.effectiveSegments || 0}/{session.totalSegments || 0}
</div>
</div>
<div className="text-sm text-muted-foreground">
{Math.round(session.overallScore || 0)} · {Math.round((session.durationMs || 0) / 1000)}
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Settings2 className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription> JSON </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(settingsQuery.data || []).map((setting: any) => (
<div key={setting.settingKey} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="min-w-0 flex-1">
<div className="font-medium">{setting.label}</div>
<div className="mt-1 text-sm text-muted-foreground">{setting.description}</div>
<Input
value={settingsDrafts[setting.settingKey] || ""}
onChange={(event) => setSettingsDrafts((current) => ({ ...current, [setting.settingKey]: event.target.value }))}
className="mt-3 h-11 rounded-2xl"
/>
</div>
<Button
className="gap-2"
onClick={() => {
try {
const parsed = JSON.parse(settingsDrafts[setting.settingKey] || "null");
updateSettingMutation.mutate({ settingKey: setting.settingKey, value: parsed });
} catch {
toast.error("设置值必须是合法 JSON");
}
}}
>
<Database className="h-4 w-4" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="audit">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(auditQuery.data || []).map((item: any) => (
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{item.actionType}</span>
<Badge variant="outline">{item.entityType}</Badge>
{item.targetUserId ? <Badge variant="outline"> {item.targetUserId}</Badge> : null}
</div>
<div className="mt-2 text-sm text-muted-foreground">
{item.adminName || item.adminUserId} · {new Date(item.createdAt).toLocaleString("zh-CN")}
</div>
</div>
{item.entityId ? <div className="text-sm text-muted-foreground"> {item.entityId}</div> : null}
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

查看文件

@@ -1,239 +1,289 @@
import { trpc } from "@/lib/trpc";
import { useMemo } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { trpc } from "@/lib/trpc";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { Flame, Calendar, Award, CheckCircle2, Lock, Star, Trophy, Zap } from "lucide-react";
import { useState, useMemo } from "react";
import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-react";
const categoryLabels: Record<string, { label: string; color: string }> = {
milestone: { label: "里程碑", color: "bg-blue-100 text-blue-700" },
training: { label: "训练", color: "bg-green-100 text-green-700" },
video: { label: "视频", color: "bg-purple-100 text-purple-700" },
analysis: { label: "分析", color: "bg-orange-100 text-orange-700" },
streak: { label: "连续打卡", color: "bg-red-100 text-red-700" },
rating: { label: "评分", color: "bg-yellow-100 text-yellow-700" },
const CATEGORY_META: Record<string, { label: string; tone: string }> = {
consistency: { label: "稳定性", tone: "bg-rose-500/10 text-rose-700" },
volume: { label: "训练", tone: "bg-emerald-500/10 text-emerald-700" },
technique: { label: "动作质量", tone: "bg-sky-500/10 text-sky-700" },
recording: { label: "录制归档", tone: "bg-amber-500/10 text-amber-700" },
analysis: { label: "分析进度", tone: "bg-indigo-500/10 text-indigo-700" },
quality: { label: "高分片段", tone: "bg-fuchsia-500/10 text-fuchsia-700" },
rating: { label: "评分", tone: "bg-violet-500/10 text-violet-700" },
pk: { label: "训练 PK", tone: "bg-orange-500/10 text-orange-700" },
plan: { label: "计划匹配", tone: "bg-cyan-500/10 text-cyan-700" },
};
function getProgressText(item: any) {
if (item.unlockedAt) {
return `已于 ${new Date(item.unlockedAt).toLocaleDateString("zh-CN")} 解锁`;
}
return `${Math.round(item.currentValue || 0)} / ${Math.round(item.targetValue || 0)}`;
}
export default function Checkin() {
const { user } = useAuth();
const [notes, setNotes] = useState("");
const [checkinDone, setCheckinDone] = useState(false);
const achievementQuery = trpc.achievement.list.useQuery();
const statsQuery = trpc.profile.stats.useQuery();
const { data: todayCheckin, isLoading: loadingToday } = trpc.checkin.today.useQuery();
const { data: checkinHistory } = trpc.checkin.history.useQuery({ limit: 60 });
const { data: badges, isLoading: loadingBadges, refetch: refetchBadges } = trpc.badge.list.useQuery();
const achievements = useMemo(() => achievementQuery.data ?? [], [achievementQuery.data]);
const stats = statsQuery.data;
const utils = trpc.useUtils();
const checkinMutation = trpc.checkin.do.useMutation({
onSuccess: (data) => {
if (data.alreadyCheckedIn) {
toast.info("今天已经打卡过了!");
} else {
toast.success(`打卡成功!连续 ${data.streak} 天 🔥`);
if (data.newBadges && data.newBadges.length > 0) {
data.newBadges.forEach((key: string) => {
toast.success(`🏆 获得新徽章!`, { duration: 5000 });
});
}
setCheckinDone(true);
}
utils.checkin.today.invalidate();
utils.checkin.history.invalidate();
refetchBadges();
},
onError: () => toast.error("打卡失败,请重试"),
});
const handleCheckin = () => {
checkinMutation.mutate({ notes: notes || undefined });
};
const alreadyCheckedIn = !!todayCheckin || checkinDone;
// Build calendar heatmap for last 60 days
const heatmapData = useMemo(() => {
const map = new Map<string, number>();
(checkinHistory || []).forEach((c: any) => {
map.set(c.checkinDate, c.streakCount);
});
const days = [];
for (let i = 59; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400000);
const key = d.toISOString().slice(0, 10);
days.push({ date: key, checked: map.has(key), streak: map.get(key) || 0, day: d.getDate() });
}
return days;
}, [checkinHistory]);
const earnedCount = badges?.filter((b: any) => b.earned).length || 0;
const totalCount = badges?.length || 0;
// Group badges by category
const groupedBadges = useMemo(() => {
const groupedAchievements = useMemo(() => {
const groups: Record<string, any[]> = {};
(badges || []).forEach((b: any) => {
if (!groups[b.category]) groups[b.category] = [];
groups[b.category].push(b);
achievements.forEach((item: any) => {
const key = item.category || "other";
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
return groups;
}, [badges]);
}, [achievements]);
if (loadingToday || loadingBadges) {
const unlockedCount = achievements.filter((item: any) => item.unlocked).length;
const nextTarget = achievements
.filter((item: any) => !item.unlocked)
.sort((a: any, b: any) => (b.progressPct || 0) - (a.progressPct || 0))[0];
const heatmapDays = useMemo(() => {
const dayMap = new Map<string, any>();
(stats?.dailyTraining || []).forEach((day: any) => dayMap.set(day.trainingDate, day));
const days = [];
for (let offset = 34; offset >= 0; offset -= 1) {
const current = new Date(Date.now() - offset * 24 * 60 * 60 * 1000);
const key = current.toISOString().slice(0, 10);
const entry = dayMap.get(key);
days.push({
date: key,
sessions: entry?.sessionCount || 0,
minutes: entry?.totalMinutes || 0,
score: entry?.averageScore || 0,
day: current.getDate(),
});
}
return days;
}, [stats?.dailyTraining]);
if (achievementQuery.isLoading || statsQuery.isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
<Skeleton className="h-40 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-80 w-full" />
</div>
);
}
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(244,63,94,0.12),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground text-sm mt-1"></p>
</div>
{/* Check-in card */}
<Card className={`border-0 shadow-sm ${alreadyCheckedIn ? "bg-green-50/50" : "bg-gradient-to-br from-primary/5 to-primary/10"}`}>
<CardContent className="py-6">
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
<div className={`h-20 w-20 rounded-full flex items-center justify-center shrink-0 ${
alreadyCheckedIn ? "bg-green-100" : "bg-primary/10"
}`}>
{alreadyCheckedIn ? (
<CheckCircle2 className="h-10 w-10 text-green-600" />
) : (
<Flame className="h-10 w-10 text-primary" />
)}
</div>
<div className="flex-1 text-center sm:text-left">
<h2 className="text-xl font-bold">
{alreadyCheckedIn ? "今日已打卡 ✅" : "今日尚未打卡"}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{alreadyCheckedIn
? `当前连续打卡 ${todayCheckin?.streakCount || (checkinHistory?.[0] as any)?.streakCount || 1}`
: "记录今天的训练,保持连续打卡!"
}
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
</p>
{!alreadyCheckedIn && (
<div className="mt-3 space-y-2">
<Textarea
placeholder="今天练了什么?(可选)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="max-w-md text-sm resize-none"
rows={2}
/>
<Button
onClick={handleCheckin}
disabled={checkinMutation.isPending}
className="gap-2"
size="lg"
>
<Flame className="h-4 w-4" />
{checkinMutation.isPending ? "打卡中..." : "立即打卡"}
</Button>
</div>
)}
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
<div className="text-muted-foreground"></div>
<div className="mt-2 text-xl font-semibold">{unlockedCount}</div>
</div>
<div className="grid grid-cols-2 gap-3 shrink-0">
<div className="text-center px-3 py-2 rounded-lg bg-white/80">
<p className="text-2xl font-bold text-primary">{user?.currentStreak || todayCheckin?.streakCount || 0}</p>
<p className="text-[10px] text-muted-foreground"></p>
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
<div className="text-muted-foreground"></div>
<div className="mt-2 text-xl font-semibold">{user?.currentStreak || 0}</div>
</div>
<div className="text-center px-3 py-2 rounded-lg bg-white/80">
<p className="text-2xl font-bold text-orange-500">{user?.longestStreak || 0}</p>
<p className="text-[10px] text-muted-foreground"></p>
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
<div className="text-muted-foreground"></div>
<div className="mt-2 text-xl font-semibold">{user?.longestStreak || 0}</div>
</div>
</div>
</div>
</CardContent>
</Card>
</section>
{/* Calendar heatmap */}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.9fr)]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="h-4 w-4 text-primary" />
60
</CardTitle>
<CardDescription> 35 </CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-10 sm:grid-cols-15 lg:grid-cols-20 gap-1">
{heatmapData.map((d, i) => (
<div
key={i}
title={`${d.date}${d.checked ? ` · 连续${d.streak}` : ""}`}
className={`aspect-square rounded-sm text-[9px] flex items-center justify-center transition-colors ${
d.checked
? d.streak >= 7 ? "bg-green-600 text-white" : d.streak >= 3 ? "bg-green-400 text-white" : "bg-green-200 text-green-800"
: "bg-muted/50 text-muted-foreground/50"
}`}
>
{d.day}
</div>
))}
</div>
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-muted/50" /></div>
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-200" />1-2</div>
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-400" />3-6</div>
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-600" />7+</div>
</div>
</CardContent>
</Card>
{/* Badges section */}
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<Award className="h-5 w-5 text-primary" />
</h2>
<p className="text-sm text-muted-foreground"> {earnedCount}/{totalCount}</p>
</div>
<div className="h-2 w-32 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${totalCount > 0 ? (earnedCount / totalCount) * 100 : 0}%` }} />
</div>
</div>
{Object.entries(groupedBadges).map(([category, items]) => {
const catInfo = categoryLabels[category] || { label: category, color: "bg-gray-100 text-gray-700" };
<div className="grid grid-cols-7 gap-2 sm:grid-cols-10 lg:grid-cols-7 xl:grid-cols-10">
{heatmapDays.map((day) => {
const level =
day.sessions === 0 ? "bg-muted/45 text-muted-foreground/50" :
day.minutes >= 45 ? "bg-emerald-600 text-white" :
day.minutes >= 20 ? "bg-emerald-400 text-white" :
"bg-emerald-200 text-emerald-900";
return (
<div key={category} className="mb-4">
<Badge className={`${catInfo.color} mb-2 text-xs`}>{catInfo.label}</Badge>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{items.map((badge: any) => (
<Card key={badge.key} className={`border-0 shadow-sm transition-all ${
badge.earned ? "bg-white" : "bg-muted/30 opacity-60"
}`}>
<CardContent className="p-3 text-center">
<div className="text-3xl mb-1">{badge.icon}</div>
<p className="text-xs font-medium truncate">{badge.name}</p>
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">{badge.description}</p>
{badge.earned ? (
<p className="text-[10px] text-green-600 mt-1">
{new Date(badge.earnedAt).toLocaleDateString("zh-CN")}
</p>
) : (
<div className="flex items-center justify-center gap-1 mt-1 text-[10px] text-muted-foreground">
<Lock className="h-2.5 w-2.5" />
</div>
)}
</CardContent>
</Card>
))}
</div>
<div
key={day.date}
title={`${day.date} · ${day.minutes} 分钟 · ${day.sessions}`}
className={`aspect-square rounded-xl text-[11px] transition-colors flex items-center justify-center ${level}`}
>
{day.day}
</div>
);
})}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-muted/45" /></span>
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-200" /></span>
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-400" /></span>
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-600" /></span>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{nextTarget ? (
<>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold">{nextTarget.name}</div>
<div className="mt-1 text-sm text-muted-foreground">{nextTarget.description}</div>
</div>
<Badge className={CATEGORY_META[nextTarget.category]?.tone || "bg-muted text-foreground"}>
{CATEGORY_META[nextTarget.category]?.label || nextTarget.category}
</Badge>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">{Math.round(nextTarget.progressPct || 0)}%</span>
</div>
<Progress value={nextTarget.progressPct || 0} className="h-2" />
<div className="mt-2 text-xs text-muted-foreground">{getProgressText(nextTarget)}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Trophy className="h-4 w-4" />
</div>
<div className="mt-2 font-medium">{nextTarget.rarity || "common"}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Award className="h-4 w-4" />
</div>
<div className="mt-2 font-medium">Tier {nextTarget.tier || 1}</div>
</div>
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
</div>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{Object.entries(groupedAchievements).map(([category, items]) => (
<section key={category} className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge className={CATEGORY_META[category]?.tone || "bg-muted text-foreground"}>
{CATEGORY_META[category]?.label || category}
</Badge>
<span className="text-sm text-muted-foreground">
{(items as any[]).filter((item) => item.unlocked).length}/{(items as any[]).length}
</span>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{(items as any[]).map((item) => (
<div
key={item.key}
className={`rounded-2xl border p-4 transition-colors ${item.unlocked ? "border-emerald-200 bg-emerald-50/70" : "border-border/60 bg-muted/20"}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="text-2xl">{item.icon || "🎾"}</span>
<div className="font-medium">{item.name}</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">{item.description}</div>
</div>
<Badge variant={item.unlocked ? "secondary" : "outline"}>
{item.unlocked ? "已解锁" : "进行中"}
</Badge>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{getProgressText(item)}</span>
<span>{Math.round(item.progressPct || 0)}%</span>
</div>
<Progress value={item.progressPct || 0} className="h-2" />
</div>
</div>
))}
</div>
</section>
))}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Flame className="h-5 w-5 text-rose-600" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{user?.currentStreak || 0}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Radar className="h-5 w-5 text-sky-600" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{(stats?.recentLiveSessions || []).length}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Swords className="h-5 w-5 text-orange-600" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{(stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5).toFixed(1)}</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

查看文件

@@ -1,30 +1,24 @@
import { useMemo } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Target, Video, Activity, TrendingUp, Award, Clock,
Zap, BarChart3, ChevronRight
} from "lucide-react";
import { Activity, Award, ChevronRight, Clock3, Sparkles, Swords, Target, Video } from "lucide-react";
import { useLocation } from "wouter";
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, AreaChart, Area } from "recharts";
function NTRPBadge({ rating }: { rating: number }) {
let level = "初学者";
let color = "bg-gray-100 text-gray-700";
if (rating >= 4.0) { level = "高级竞技"; color = "bg-purple-100 text-purple-700"; }
else if (rating >= 3.0) { level = "中高级"; color = "bg-blue-100 text-blue-700"; }
else if (rating >= 2.5) { level = "中级"; color = "bg-green-100 text-green-700"; }
else if (rating >= 2.0) { level = "初中级"; color = "bg-yellow-100 text-yellow-700"; }
else if (rating >= 1.5) { level = "初级"; color = "bg-orange-100 text-orange-700"; }
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${color}`}>
NTRP {rating.toFixed(1)} · {level}
</span>
);
const level =
rating >= 4.0 ? "高级竞技" :
rating >= 3.5 ? "高级" :
rating >= 3.0 ? "中高级" :
rating >= 2.5 ? "中级" :
rating >= 2.0 ? "初中级" :
rating >= 1.5 ? "初级" :
"入门";
return <Badge className="bg-emerald-500/10 text-emerald-700">NTRP {rating.toFixed(1)} · {level}</Badge>;
}
export default function Dashboard() {
@@ -32,247 +26,218 @@ export default function Dashboard() {
const { data: stats, isLoading } = trpc.profile.stats.useQuery();
const [, setLocation] = useLocation();
const unlockedAchievements = useMemo(
() => (stats?.achievements || []).filter((item: any) => item.unlocked).length,
[stats?.achievements],
);
const recentTrainingDays = useMemo(
() => [...(stats?.dailyTraining || [])].slice(-7).reverse(),
[stats?.dailyTraining],
);
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-32 w-full" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <Skeleton key={i} className="h-28" />)}
<div className="space-y-4">
<Skeleton className="h-40 w-full" />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{[1, 2, 3, 4].map((index) => <Skeleton key={index} className="h-32" />)}
</div>
<Skeleton className="h-80 w-full" />
</div>
);
}
const ratingData = stats?.ratingHistory?.map((r: any) => ({
date: new Date(r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
rating: r.rating,
...((r.dimensionScores as any) || {}),
})) || [];
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_30%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight" data-testid="dashboard-title">
<h1 className="text-2xl font-semibold tracking-tight" data-testid="dashboard-title">
{user?.name || "未命名用户"}
</h1>
<div className="flex items-center gap-3 mt-2">
<NTRPBadge rating={stats?.ntrpRating || 1.5} />
<span className="text-sm text-muted-foreground">
{stats?.totalSessions || 0}
</span>
<div className="mt-3 flex flex-wrap items-center gap-2">
<NTRPBadge rating={stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5} />
<Badge variant="outline"> {stats?.totalSessions || 0} </Badge>
<Badge variant="outline"> {unlockedAchievements} </Badge>
</div>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button data-testid="dashboard-training-button" onClick={() => setLocation("/training")} className="gap-2">
<Target className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => setLocation("/live-camera")} className="gap-2">
<Sparkles className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => setLocation("/analysis")} className="gap-2">
<Video className="h-4 w-4" />
</Button>
</div>
</div>
</section>
{/* Stats cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-0 shadow-sm bg-gradient-to-br from-green-50 to-emerald-50">
<CardContent className="pt-5 pb-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground">NTRP评分</p>
<p className="text-2xl font-bold text-primary mt-1">
{(stats?.ntrpRating || 1.5).toFixed(1)}
</p>
<div className="text-sm text-muted-foreground"> NTRP</div>
<div className="mt-2 text-2xl font-semibold">{(stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5).toFixed(1)}</div>
</div>
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Award className="h-5 w-5 text-primary" />
<div className="rounded-2xl bg-emerald-500/10 p-3 text-emerald-700">
<Award className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5 pb-4">
<CardContent className="pt-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold mt-1">{stats?.totalSessions || 0}</p>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-2xl font-semibold">{stats?.totalMinutes || 0}<span className="ml-1 text-sm font-normal text-muted-foreground"></span></div>
</div>
<div className="h-10 w-10 rounded-xl bg-blue-50 flex items-center justify-center">
<Activity className="h-5 w-5 text-blue-600" />
<div className="rounded-2xl bg-sky-500/10 p-3 text-sky-700">
<Clock3 className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5 pb-4">
<CardContent className="pt-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold mt-1">{stats?.totalMinutes || 0}<span className="text-sm font-normal text-muted-foreground ml-1"></span></p>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-2xl font-semibold">{stats?.totalShots || 0}</div>
</div>
<div className="h-10 w-10 rounded-xl bg-orange-50 flex items-center justify-center">
<Clock className="h-5 w-5 text-orange-600" />
<div className="rounded-2xl bg-amber-500/10 p-3 text-amber-700">
<Activity className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5 pb-4">
<CardContent className="pt-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold mt-1">{stats?.totalShots || 0}</p>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-2xl font-semibold">{(stats?.recentLiveSessions || []).length}</div>
</div>
<div className="h-10 w-10 rounded-xl bg-purple-50 flex items-center justify-center">
<Zap className="h-5 w-5 text-purple-600" />
<div className="rounded-2xl bg-rose-500/10 p-3 text-rose-700">
<Swords className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Rating trend chart */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.85fr)]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary" />
NTRP评分趋势
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => setLocation("/rating")} className="text-xs gap-1">
<ChevronRight className="h-3 w-3" />
<div>
<CardTitle className="text-base"> 7 </CardTitle>
<CardDescription></CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => setLocation("/progress")} className="gap-1 text-xs">
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</CardHeader>
<CardContent>
{ratingData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={ratingData}>
<defs>
<linearGradient id="ratingGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis domain={[1, 5]} tick={{ fontSize: 11 }} />
<Tooltip />
<Area type="monotone" dataKey="rating" stroke="oklch(0.55 0.16 145)" fill="url(#ratingGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
<CardContent className="space-y-3">
{recentTrainingDays.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<BarChart3 className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
recentTrainingDays.map((day: any) => (
<div key={day.trainingDate} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="font-medium">{day.trainingDate}</div>
<div className="mt-1 text-sm text-muted-foreground">
{day.sessionCount || 0} · {day.totalMinutes || 0} · {day.effectiveActions || 0}
</div>
</div>
<div className="min-w-[150px]">
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span></span>
<span>{Math.round(day.averageScore || 0)}</span>
</div>
<Progress value={day.averageScore || 0} className="h-2" />
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
{/* Recent analyses */}
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Video className="h-4 w-4 text-primary" />
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => setLocation("/videos")} className="text-xs gap-1">
<ChevronRight className="h-3 w-3" />
</Button>
</div>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{(stats?.recentAnalyses?.length || 0) > 0 ? (
<div className="space-y-3">
{stats!.recentAnalyses.slice(0, 4).map((a: any) => (
<div key={a.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-primary/5 flex items-center justify-center text-xs font-bold text-primary">
{Math.round(a.overallScore || 0)}
<CardContent className="space-y-3">
{(stats?.recentLiveSessions || []).length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
</div>
) : (
(stats?.recentLiveSessions || []).slice(-4).reverse().map((session: any) => (
<div key={session.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">{a.exerciseType || "综合分析"}</p>
<p className="text-xs text-muted-foreground">
{new Date(a.createdAt).toLocaleDateString("zh-CN")}
{a.shotCount ? ` · ${a.shotCount}次击球` : ""}
</p>
<div className="font-medium">{session.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{new Date(session.createdAt).toLocaleString("zh-CN")}</div>
</div>
<Badge variant="outline">{Math.round(session.overallScore || 0)} </Badge>
</div>
<div className="mt-3 text-sm text-muted-foreground">
{session.totalSegments || 0} · {session.effectiveSegments || 0}
</div>
</div>
<div className="flex items-center gap-2">
<Progress value={a.overallScore || 0} className="w-16 h-1.5" />
<span className="text-xs text-muted-foreground">{Math.round(a.overallScore || 0)}</span>
))
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(stats?.achievements || []).slice(0, 4).map((item: any) => (
<div key={item.key} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium">{item.name}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.description}</div>
</div>
<Badge variant={item.unlocked ? "secondary" : "outline"}>
{item.unlocked ? "已解锁" : `${Math.round(item.progressPct || 0)}%`}
</Badge>
</div>
</div>
))}
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<Video className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
)}
<Button variant="outline" className="w-full gap-2" onClick={() => setLocation("/checkin")}>
<ChevronRight className="h-4 w-4" />
</Button>
</CardContent>
</Card>
</div>
{/* Quick actions */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base font-semibold"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={() => setLocation("/training")}
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
>
<div className="h-10 w-10 rounded-xl bg-green-100 flex items-center justify-center shrink-0">
<Target className="h-5 w-5 text-green-700" />
</div>
<div>
<p className="font-medium text-sm"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
</button>
<button
onClick={() => setLocation("/analysis")}
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
>
<div className="h-10 w-10 rounded-xl bg-blue-100 flex items-center justify-center shrink-0">
<Video className="h-5 w-5 text-blue-700" />
</div>
<div>
<p className="font-medium text-sm"></p>
<p className="text-xs text-muted-foreground">姿</p>
</div>
</button>
<button
onClick={() => setLocation("/rating")}
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
>
<div className="h-10 w-10 rounded-xl bg-purple-100 flex items-center justify-center shrink-0">
<Award className="h-5 w-5 text-purple-700" />
</div>
<div>
<p className="font-medium text-sm">NTRP评分</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</button>
</div>
</CardContent>
</Card>
</div>
);
}

文件差异内容过多而无法显示 加载差异

查看文件

@@ -1,228 +1,271 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { Award, TrendingUp, Target, Zap, Footprints, Activity, Wind } from "lucide-react";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { toast } from "sonner";
import { Activity, Award, Loader2, RefreshCw, Radar, TrendingUp } from "lucide-react";
import {
ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis,
PolarRadiusAxis, Radar, AreaChart, Area, XAxis, YAxis,
CartesianGrid, Tooltip, Legend
Area,
AreaChart,
CartesianGrid,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar as RadarChartShape,
RadarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
const NTRP_LEVELS = [
{ min: 1.0, max: 1.5, label: "初学者", desc: "刚开始学习网球,正在学习基本击球动作", color: "bg-gray-100 text-gray-700" },
{ min: 1.5, max: 2.0, label: "初级", desc: "能够进行简单的来回击球,但缺乏一致性", color: "bg-orange-100 text-orange-700" },
{ min: 2.0, max: 2.5, label: "初中级", desc: "击球更加稳定,开始理解基本策略", color: "bg-yellow-100 text-yellow-700" },
{ min: 2.5, max: 3.0, label: "中级", desc: "能够稳定地进行中速击球,具备基本的网前技术", color: "bg-green-100 text-green-700" },
{ min: 3.0, max: 3.5, label: "中高级", desc: "击球力量和控制力增强,开始使用旋转", color: "bg-blue-100 text-blue-700" },
{ min: 3.5, max: 4.0, label: "高级", desc: "具备全面的技术,能够在比赛中运用战术", color: "bg-indigo-100 text-indigo-700" },
{ min: 4.0, max: 4.5, label: "高级竞技", desc: "技术精湛,具备强大的进攻和防守能力", color: "bg-purple-100 text-purple-700" },
{ min: 4.5, max: 5.0, label: "专业水平", desc: "接近职业水平,全面的技术和战术能力", color: "bg-red-100 text-red-700" },
{ min: 1.0, max: 1.5, label: "入门" },
{ min: 1.5, max: 2.0, label: "初级" },
{ min: 2.0, max: 2.5, label: "初中级" },
{ min: 2.5, max: 3.0, label: "中级" },
{ min: 3.0, max: 3.5, label: "中高级" },
{ min: 3.5, max: 4.0, label: "高级" },
{ min: 4.0, max: 4.5, label: "高级竞技" },
{ min: 4.5, max: 5.1, label: "接近专业" },
];
function getNTRPLevel(rating: number) {
return NTRP_LEVELS.find(l => rating >= l.min && rating < l.max) || NTRP_LEVELS[0];
function getLevel(rating: number) {
return NTRP_LEVELS.find((item) => rating >= item.min && rating < item.max)?.label || "入门";
}
export default function Rating() {
const { user } = useAuth();
const { data: ratingData } = trpc.rating.current.useQuery();
const { data: history, isLoading } = trpc.rating.history.useQuery();
const { data: stats } = trpc.profile.stats.useQuery();
const [taskId, setTaskId] = useState<string | null>(null);
const currentQuery = trpc.rating.current.useQuery();
const historyQuery = trpc.rating.history.useQuery();
const refreshMineMutation = trpc.rating.refreshMine.useMutation({
onSuccess: (data) => {
setTaskId(data.taskId);
toast.success("NTRP 刷新任务已加入后台队列");
},
onError: (error) => toast.error(`NTRP 刷新失败: ${error.message}`),
});
const taskQuery = useBackgroundTask(taskId);
const currentRating = ratingData?.rating || 1.5;
const level = getNTRPLevel(currentRating);
const currentRating = currentQuery.data?.rating || 1.5;
const latestSnapshot = currentQuery.data?.latestSnapshot as any;
const history = historyQuery.data ?? [];
// Get latest dimension scores
const latestWithDimensions = history?.find((h: any) => h.dimensionScores);
const dimensions = (latestWithDimensions as any)?.dimensionScores || {};
const radarData = [
{ dimension: "姿势准确", value: dimensions.poseAccuracy || 0, fullMark: 100 },
{ dimension: "击球一致", value: dimensions.strokeConsistency || 0, fullMark: 100 },
{ dimension: "脚步移动", value: dimensions.footwork || 0, fullMark: 100 },
{ dimension: "动作流畅", value: dimensions.fluidity || 0, fullMark: 100 },
{ dimension: "力量", value: dimensions.power || 0, fullMark: 100 },
const radarData = useMemo(() => {
const scores = latestSnapshot?.dimensionScores || {};
return [
{ dimension: "姿态", value: scores.poseAccuracy || 0 },
{ dimension: "一致性", value: scores.strokeConsistency || 0 },
{ dimension: "脚步", value: scores.footwork || 0 },
{ dimension: "流畅度", value: scores.fluidity || 0 },
{ dimension: "时机", value: scores.timing || 0 },
{ dimension: "比赛准备", value: scores.matchReadiness || 0 },
];
}, [latestSnapshot?.dimensionScores]);
const trendData = (history || []).map((h: any) => ({
date: new Date(h.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
rating: h.rating,
}));
const trendData = useMemo(
() => history.map((item: any) => ({
date: new Date(item.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
rating: item.rating,
})).reverse(),
[history],
);
if (isLoading) {
if (currentQuery.isLoading || historyQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-60 w-full" />
<Skeleton className="h-80 w-full" />
</div>
);
}
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_32%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">NTRP评分系</h1>
<p className="text-muted-foreground text-sm mt-1"></p>
<h1 className="text-2xl font-semibold tracking-tight">NTRP </h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
</p>
</div>
<Button onClick={() => refreshMineMutation.mutate()} disabled={refreshMineMutation.isPending} className="gap-2">
{refreshMineMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
NTRP
</Button>
</div>
</section>
{/* Current rating card */}
<Card className="border-0 shadow-sm overflow-hidden">
<div className="bg-gradient-to-r from-primary/10 via-primary/5 to-transparent p-6">
<div className="flex flex-col sm:flex-row sm:items-center gap-6">
<div className="flex items-center gap-4">
<div className="h-20 w-20 rounded-2xl bg-primary/10 flex items-center justify-center">
<span className="text-3xl font-bold text-primary">{currentRating.toFixed(1)}</span>
</div>
<div>
<h2 className="text-xl font-bold">{level.label}</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-md">{level.desc}</p>
<div className="flex items-center gap-2 mt-2">
<Award className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">NTRP {currentRating.toFixed(1)}</span>
</div>
</div>
</div>
</div>
</div>
</Card>
{(taskQuery.data?.status === "queued" || taskQuery.data?.status === "running") ? (
<Alert>
<RefreshCw className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>{taskQuery.data.message || "NTRP 刷新任务正在后台执行。"}</AlertDescription>
</Alert>
) : null}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Radar chart */}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(320px,360px)]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Target className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{Object.keys(dimensions).length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<RadarChart data={radarData}>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 12 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 10 }} />
<Radar
name="能力值"
dataKey="value"
stroke="oklch(0.55 0.16 145)"
fill="oklch(0.55 0.16 145)"
fillOpacity={0.3}
strokeWidth={2}
/>
</RadarChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<Target className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
<CardContent className="pt-6">
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-3">
<div className="rounded-3xl bg-emerald-500/10 px-5 py-4 text-4xl font-semibold text-emerald-700">
{currentRating.toFixed(1)}
</div>
<div>
<div className="text-lg font-semibold">{getLevel(currentRating)}</div>
<div className="mt-1 text-sm text-muted-foreground"></div>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge className="bg-emerald-500/10 text-emerald-700">
<Award className="mr-1 h-3.5 w-3.5" />
NTRP {currentRating.toFixed(1)}
</Badge>
{latestSnapshot?.triggerType ? <Badge variant="outline"> {latestSnapshot.triggerType}</Badge> : null}
{latestSnapshot?.createdAt ? (
<Badge variant="outline">
{new Date(latestSnapshot.createdAt).toLocaleString("zh-CN")}
</Badge>
) : null}
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-muted-foreground"></div>
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.activeDays || 0}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-muted-foreground"></div>
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.totalEffectiveActions || 0}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-muted-foreground"></div>
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.liveSessions || 0}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-muted-foreground">PK </div>
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.totalPk || 0}</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Rating trend */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{radarData.map((item) => (
<div key={item.dimension}>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-muted-foreground">{item.dimension}</span>
<span className="font-medium">{Math.round(item.value)}</span>
</div>
<div className="h-2 rounded-full bg-muted/70">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${item.value}%` }} />
</div>
</div>
))}
</CardContent>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<TrendingUp className="h-4 w-4 text-primary" />
NTRP
</CardTitle>
<CardDescription>NTRP评分随时间的变化</CardDescription>
</CardHeader>
<CardContent>
{trendData.length > 0 ? (
{trendData.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-14 text-center text-sm text-muted-foreground">
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={trendData}>
<defs>
<linearGradient id="ratingGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0} />
<linearGradient id="rating-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.26} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis domain={[1, 5]} tick={{ fontSize: 11 }} />
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis domain={[1, 5]} tick={{ fontSize: 12 }} />
<Tooltip />
<Area type="monotone" dataKey="rating" stroke="oklch(0.55 0.16 145)" fill="url(#ratingGrad)" strokeWidth={2} />
<Area type="monotone" dataKey="rating" stroke="#10b981" strokeWidth={2} fill="url(#rating-fill)" />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Radar className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription> NTRP </CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={280}>
<RadarChart data={radarData}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 12 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} />
<RadarChartShape dataKey="value" stroke="#10b981" fill="#10b981" fillOpacity={0.25} />
</RadarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{history.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<TrendingUp className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Dimension details */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base"></CardTitle>
<CardDescription>NTRP评分由以下五个维度加权计算</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{[
{ icon: Target, label: "姿势准确性", weight: "30%", desc: "关节角度与标准动作的匹配度", value: dimensions.poseAccuracy },
{ icon: Zap, label: "击球一致性", weight: "25%", desc: "多次击球动作的稳定性", value: dimensions.strokeConsistency },
{ icon: Footprints, label: "脚步移动", weight: "20%", desc: "步法灵活性和重心转移", value: dimensions.footwork },
{ icon: Wind, label: "动作流畅性", weight: "15%", desc: "动作连贯性和平滑度", value: dimensions.fluidity },
{ icon: Activity, label: "力量", weight: "10%", desc: "挥拍速度和爆发力", value: dimensions.power },
].map(item => (
<div key={item.label} className="p-4 rounded-xl border bg-card">
<div className="flex items-center gap-2 mb-2">
<item.icon className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{item.label}</span>
</div>
<p className="text-2xl font-bold">{item.value ? Math.round(item.value) : "--"}</p>
<p className="text-xs text-muted-foreground mt-1"> {item.weight}</p>
<p className="text-xs text-muted-foreground">{item.desc}</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* NTRP level reference */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">NTRP等级参考</CardTitle>
<CardDescription>(USTA)</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{NTRP_LEVELS.map(l => (
<div
key={l.label}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
currentRating >= l.min && currentRating < l.max
? "bg-primary/5 border border-primary/20"
: "hover:bg-muted/50"
}`}
>
<Badge className={`${l.color} border shrink-0`}>
{l.min.toFixed(1)}-{l.max.toFixed(1)}
</Badge>
history.map((item: any) => (
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="text-sm font-medium">{l.label}</span>
<p className="text-xs text-muted-foreground">{l.desc}</p>
<div className="flex items-center gap-2">
<span className="font-medium">NTRP {Number(item.rating || 0).toFixed(1)}</span>
<Badge variant="outline">{item.triggerType}</Badge>
</div>
{currentRating >= l.min && currentRating < l.max && (
<Badge variant="default" className="ml-auto shrink-0"></Badge>
<div className="mt-1 text-xs text-muted-foreground">{new Date(item.createdAt).toLocaleString("zh-CN")}</div>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Activity className="h-4 w-4" />
{item.sourceSummary?.analyses || 0}
</span>
<span> {item.sourceSummary?.liveSessions || 0}</span>
<span> {item.sourceSummary?.totalEffectiveActions || 0}</span>
</div>
</div>
</div>
))
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>

查看文件

@@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
@@ -169,6 +170,7 @@ export default function Recorder() {
const [cameraActive, setCameraActive] = useState(false);
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
const [durationMs, setDurationMs] = useState(0);
const [sessionMode, setSessionMode] = useState<"practice" | "pk">("practice");
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [queuedSegments, setQueuedSegments] = useState(0);
@@ -637,13 +639,15 @@ export default function Recorder() {
sessionId: session.id,
title: title.trim() || session.title,
exerciseType: "recording",
sessionMode,
durationMinutes: Math.max(1, Math.round((Date.now() - recordingStartedAtRef.current) / 60000)),
});
toast.success("录制已提交,后台正在整理回放文件");
} catch (error: any) {
toast.error(`结束录制失败: ${error?.message || "未知错误"}`);
setMode("recording");
}
}, [closePeer, finalizeTaskMutation, flushPendingSegments, stopCamera, stopRecorder, syncSessionState, title]);
}, [closePeer, finalizeTaskMutation, flushPendingSegments, sessionMode, stopCamera, stopRecorder, syncSessionState, title]);
const resetRecorder = useCallback(async () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
@@ -948,6 +952,10 @@ export default function Recorder() {
<MonitorUp className="h-3.5 w-3.5" />
WebRTC
</Badge>
<Badge variant="outline" className="gap-1.5 border-white/15 bg-white/5 text-white/80">
<Sparkles className="h-3.5 w-3.5" />
{sessionMode === "practice" ? "练习会话" : "训练 PK"}
</Badge>
</div>
<div>
@@ -1045,13 +1053,22 @@ export default function Recorder() {
</div>
<div className="border-t border-border/60 bg-card/80 p-4">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_auto]">
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="本次训练录制标题"
className="h-12 rounded-2xl border-border/60"
/>
<Select value={sessionMode} onValueChange={(value) => setSessionMode(value as "practice" | "pk")} disabled={mode !== "idle" && mode !== "archived"}>
<SelectTrigger className="h-12 rounded-2xl border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="practice"></SelectItem>
<SelectItem value="pk"> PK</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
{renderPrimaryActions()}
</div>

查看文件

@@ -10,14 +10,18 @@
### 用户与训练
- 用户名登录:无需注册,输入用户名即可进入训练工作台
- 新用户邀请:首次创建用户名账号需要邀请码 `CA2026`
- 训练计划:按技能等级和训练周期生成训练计划,改为后台异步生成
- 训练进度:展示训练次数、时长、评分趋势、最近分析结果
- 每日打卡与提醒:支持训练打卡、提醒、通知记录
- 成就系统与提醒:训练日聚合、成就进度、连练统计、提醒、通知记录
### 视频与分析
- 视频上传分析:上传 `webm/mp4` 视频进入视频库并触发分析流程
- 实时摄像头分析:浏览器端调用 MediaPipe,进行姿势识别和反馈展示
- 实时摄像头分析:浏览器端调用 MediaPipe,自动识别 `forehand/backhand/serve/volley/overhead/slice/lob/unknown`
- 连续动作片段:自动聚合连续同类动作区间,单段不超过 10 秒,并保存得分、置信度与反馈摘要
- 实时分析录制:分析阶段可同步保留浏览器端本地录制视频,停止分析后自动登记到系统
- 训练数据回写:实时分析与录制数据自动写入训练记录、日训练聚合、成就系统和 NTRP 评分
- 动作纠正:支持文本纠正和多模态纠正两条链路,统一通过后台任务执行
- 多模态图片输入:上传关键帧后会转换为公网可访问的绝对 URL,再提交给视觉模型
- 视觉标准图库:内置网球公网参考图,可直接发起视觉识别测试并保存结果
@@ -36,6 +40,15 @@
- 视频库登记:归档完成后由 app worker 自动写回现有视频库
- 上传稳定性:媒体分段上传遇到 `502/503/504` 会自动重试
### 评分、成就与管理
- 每日异步 NTRP系统会在每日零点后自动排队全量 NTRP 刷新任务
- 用户手动刷新:普通用户可刷新自己的 NTRP;管理员可刷新任意用户或全量用户
- NTRP 快照:每次刷新都会生成可追踪的快照,保存维度评分和数据来源摘要
- 成就定义表:成就系统已独立于旧徽章表,支持大规模扩展、分层、隐藏成就与分类
- 管理系统:`/admin` 提供用户管理、任务列表、实时分析会话列表、应用设置和审计日志
- H1 管理能力:当 `H1` 被配置为 admin 后,可查看全部视觉测试数据与后台管理数据
## 前端能力
### 移动端
@@ -46,6 +59,7 @@
- 横屏视频优先布局
- 录制页和分析页防下拉刷新干扰
- 录制时按设备场景自动调整码率和控件密度
- 实时分析页支持竖屏最大化预览,主要操作按钮放在侧边
### 桌面端
@@ -76,6 +90,7 @@
- 当前 PC 剪辑仍处于基础媒体域准备阶段,未交付完整多轨编辑器
- 当前存储策略为本地卷优先,未接入对象存储归档
- 当前 `.env` 配置的视觉网关若忽略 `LLM_VISION_MODEL`,系统会回退到文本纠正;代码已支持独立视觉模型配置,但上游网关能力仍需单独确认
- 当前实时动作识别仍基于姿态启发式分类,不是专门训练的动作识别模型
## 后续增强方向

查看文件

@@ -11,6 +11,7 @@
## Recommended loop
```bash
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
pnpm check
pnpm test
pnpm test:go
@@ -31,11 +32,12 @@ git commit -m "..."
如果业务开发中被打断,恢复时按以下顺序:
1. `git status` 查看当前工作树
2. 先跑 `pnpm check`
3. 再跑 `pnpm test`
4. 若涉及媒体链路,再跑 `pnpm test:go`
5. 最后`pnpm test:e2e`
6. 若当前分支包含部署改动,再执行 `docker compose config` 与基础 smoke check
2. 若 schema 或环境变量改动过,先执行 `set -a && source .env && set +a && pnpm exec drizzle-kit migrate`
3. 再跑 `pnpm check`
4. 再跑 `pnpm test`
5. 若涉及媒体链路,再`pnpm test:go`
6. 最后跑 `pnpm test:e2e`
7. 若当前分支包含部署改动,再执行 `docker compose config` 与基础 smoke check
不要在一半状态下长时间保留“能编译但主流程不可用”的改动。
@@ -55,6 +57,7 @@ git commit -m "..."
- `client/src/lib/media.ts`
- `client/src/pages/Recorder.tsx`
- `client/src/pages/LiveCamera.tsx`
- `media/main.go`
- `server/routers.ts`
- `server/_core/mediaProxy.ts`
@@ -65,6 +68,7 @@ git commit -m "..."
- marker 写入
- finalize
- 视频库登记
- 实时分析停止后的会话保存和训练数据回写
## Documentation discipline

查看文件

@@ -19,6 +19,7 @@
当前覆盖:
- Node/tRPC 路由输入校验与权限检查
- 实时分析会话保存、管理员权限与异步 NTRP 刷新入队
- LLM 模块请求配置与环境变量回退逻辑
- 视觉模型 per-request model override 能力
- 视觉标准图库路由与 admin/H1 全量可见性逻辑
@@ -41,11 +42,18 @@
使用 Playwright。为保证稳定性
- 启动本地测试服务器 `pnpm dev:test`
- 测试服务器启动前要求数据库已完成 Drizzle 迁移
- 通过路由拦截模拟 tRPC 和 `/media` 接口
- 注入假媒体设备、假 `MediaRecorder` 和假 `RTCPeerConnection`
这样可以自动验证前端主流程,而不依赖数据库、真实摄像头权限和真实 WebRTC 网络环境。
当前 E2E 已覆盖新的后台任务流和任务中心依赖的接口 mock。
这样可以自动验证前端主流程,而不依赖真实摄像头权限和真实 WebRTC 网络环境。
当前 E2E 已覆盖新的后台任务流、实时分析入口、录制焦点视图和任务中心依赖的接口 mock。
首次在新库或新 schema 上执行前,先跑:
```bash
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
```
## Unified verification
@@ -109,7 +117,10 @@ pnpm test:llm
- 打开 `https://te.hao.work/`
- 打开 `https://te.hao.work/login`
- 打开 `https://te.hao.work/checkin`
- 打开 `https://te.hao.work/admin`(管理员)
- 打开 `https://te.hao.work/recorder`
- 打开 `https://te.hao.work/live-camera`
- 确认没有 `pageerror` 或首屏 `console.error`
## Local browser prerequisites
@@ -125,6 +136,7 @@ pnpm exec playwright install chromium
- E2E 目前验证的是“模块主流程是否正常”,不是媒体编码质量本身
- 若需要真实录制验证,可额外用本地 Chrome 和真实摄像头做手工联调
-`pnpm test:e2e` 失败,优先检查:
- 本地数据库是否已执行最新 Drizzle 迁移
- `PORT=3100` 是否被占用
- 浏览器依赖是否安装
- 前端路由或测试标识是否被改动

查看文件

@@ -1,12 +1,13 @@
# Verified Features
本文档记录当前已经通过自动化验证或构建验证的项目。更新时间2026-03-15 00:52 CST。
本文档记录当前已经通过自动化验证或构建验证的项目。更新时间2026-03-15 01:39 CST。
## 最新完整验证记录
- 通过命令:`pnpm verify`
- 验证时间2026-03-15 00:51 CST
- 结果摘要:`pnpm check` 通过,`pnpm test` 通过90/90),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过6/6`pnpm test:llm` 通过
- 验证时间2026-03-15 01:38 - 01:39 CST
- 结果摘要:`pnpm check` 通过,`pnpm test` 通过95/95),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过6/6
- 数据库状态:已执行 `set -a && source .env && set +a && pnpm exec drizzle-kit migrate``0007_grounded_live_ops` 已成功应用
## 生产部署联测
@@ -44,9 +45,12 @@
| plan | `pnpm test` | 通过 |
| video | `pnpm test` | 通过 |
| analysis | `pnpm test` | 通过 |
| live analysis 会话保存 | `pnpm test` | 通过 |
| record | `pnpm test` | 通过 |
| rating | `pnpm test` | 通过 |
| checkin | `pnpm test` | 通过 |
| achievement | `pnpm test` | 通过 |
| admin | `pnpm test` | 通过 |
| checkin 兼容路由 | `pnpm test` | 通过 |
| badge | `pnpm test` | 通过 |
| leaderboard | `pnpm test` | 通过 |
| tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 |
@@ -95,6 +99,7 @@
- Playwright 运行依赖 mocked media/network,不等价于真机摄像头、真实弱网和真实 WebRTC 质量验收
- 当前上游视觉网关可能忽略 `LLM_VISION_MODEL` 并回退为文本模型;服务端已实现自动降级,任务不会因此直接失败
- 2026-03-15 的真实标准图测试中,正手 / 反手 / 发球三条记录均以 `fallback` 完成,说明当前上游视觉网关仍未稳定返回结构化视觉结果
- 开发服务器启动阶段仍会打印 `OAUTH_SERVER_URL` 未配置提示;当前用户名登录、mock auth 和自动化测试不受影响
## 当前未纳入自动验证的内容

查看文件

@@ -0,0 +1,159 @@
ALTER TABLE `training_records`
ADD `exerciseType` varchar(64),
ADD `sourceType` varchar(32) DEFAULT 'manual',
ADD `sourceId` varchar(64),
ADD `videoId` int,
ADD `linkedPlanId` int,
ADD `matchConfidence` float,
ADD `actionCount` int DEFAULT 0,
ADD `metadata` json;
--> statement-breakpoint
CREATE TABLE `live_analysis_sessions` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`title` varchar(256) NOT NULL,
`sessionMode` enum('practice','pk') NOT NULL DEFAULT 'practice',
`status` enum('active','completed','aborted') NOT NULL DEFAULT 'completed',
`startedAt` timestamp NOT NULL DEFAULT (now()),
`endedAt` timestamp,
`durationMs` int NOT NULL DEFAULT 0,
`dominantAction` varchar(64),
`overallScore` float,
`postureScore` float,
`balanceScore` float,
`techniqueScore` float,
`footworkScore` float,
`consistencyScore` float,
`unknownActionRatio` float,
`totalSegments` int NOT NULL DEFAULT 0,
`effectiveSegments` int NOT NULL DEFAULT 0,
`totalActionCount` int NOT NULL DEFAULT 0,
`videoId` int,
`videoUrl` text,
`summary` text,
`feedback` json,
`metrics` json,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `live_analysis_sessions_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `live_action_segments` (
`id` int AUTO_INCREMENT NOT NULL,
`sessionId` int NOT NULL,
`actionType` varchar(64) NOT NULL,
`isUnknown` int NOT NULL DEFAULT 0,
`startMs` int NOT NULL,
`endMs` int NOT NULL,
`durationMs` int NOT NULL,
`confidenceAvg` float,
`score` float,
`peakScore` float,
`frameCount` int NOT NULL DEFAULT 0,
`issueSummary` json,
`keyFrames` json,
`clipLabel` varchar(128),
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `live_action_segments_id` PRIMARY KEY(`id`),
CONSTRAINT `live_action_segments_session_start_idx` UNIQUE(`sessionId`,`startMs`)
);
--> statement-breakpoint
CREATE TABLE `daily_training_aggregates` (
`id` int AUTO_INCREMENT NOT NULL,
`dayKey` varchar(32) NOT NULL,
`userId` int NOT NULL,
`trainingDate` varchar(10) NOT NULL,
`totalMinutes` int NOT NULL DEFAULT 0,
`sessionCount` int NOT NULL DEFAULT 0,
`analysisCount` int NOT NULL DEFAULT 0,
`liveAnalysisCount` int NOT NULL DEFAULT 0,
`recordingCount` int NOT NULL DEFAULT 0,
`pkCount` int NOT NULL DEFAULT 0,
`totalActions` int NOT NULL DEFAULT 0,
`effectiveActions` int NOT NULL DEFAULT 0,
`unknownActions` int NOT NULL DEFAULT 0,
`totalScore` float NOT NULL DEFAULT 0,
`averageScore` float NOT NULL DEFAULT 0,
`metadata` json,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `daily_training_aggregates_id` PRIMARY KEY(`id`),
CONSTRAINT `daily_training_aggregates_dayKey_unique` UNIQUE(`dayKey`)
);
--> statement-breakpoint
CREATE TABLE `ntrp_snapshots` (
`id` int AUTO_INCREMENT NOT NULL,
`snapshotKey` varchar(64) NOT NULL,
`userId` int NOT NULL,
`snapshotDate` varchar(10) NOT NULL,
`rating` float NOT NULL,
`triggerType` enum('analysis','daily','manual') NOT NULL DEFAULT 'daily',
`taskId` varchar(64),
`dimensionScores` json,
`sourceSummary` json,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `ntrp_snapshots_id` PRIMARY KEY(`id`),
CONSTRAINT `ntrp_snapshots_snapshotKey_unique` UNIQUE(`snapshotKey`)
);
--> statement-breakpoint
CREATE TABLE `achievement_definitions` (
`id` int AUTO_INCREMENT NOT NULL,
`key` varchar(64) NOT NULL,
`name` varchar(128) NOT NULL,
`description` text,
`category` varchar(32) NOT NULL,
`rarity` varchar(16) NOT NULL DEFAULT 'common',
`icon` varchar(16) NOT NULL DEFAULT '🎾',
`metricKey` varchar(64) NOT NULL,
`targetValue` float NOT NULL,
`tier` int NOT NULL DEFAULT 1,
`isHidden` int NOT NULL DEFAULT 0,
`isActive` int NOT NULL DEFAULT 1,
`sortOrder` int NOT NULL DEFAULT 0,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `achievement_definitions_id` PRIMARY KEY(`id`),
CONSTRAINT `achievement_definitions_key_unique` UNIQUE(`key`)
);
--> statement-breakpoint
CREATE TABLE `user_achievements` (
`id` int AUTO_INCREMENT NOT NULL,
`progressKey` varchar(96) NOT NULL,
`userId` int NOT NULL,
`achievementKey` varchar(64) NOT NULL,
`currentValue` float NOT NULL DEFAULT 0,
`progressPct` float NOT NULL DEFAULT 0,
`unlockedAt` timestamp,
`lastEvaluatedAt` timestamp NOT NULL DEFAULT (now()),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `user_achievements_id` PRIMARY KEY(`id`),
CONSTRAINT `user_achievements_progressKey_unique` UNIQUE(`progressKey`)
);
--> statement-breakpoint
ALTER TABLE `background_tasks`
MODIFY COLUMN `type` enum('media_finalize','training_plan_generate','training_plan_adjust','analysis_corrections','pose_correction_multimodal','ntrp_refresh_user','ntrp_refresh_all') NOT NULL;
--> statement-breakpoint
CREATE TABLE `admin_audit_logs` (
`id` int AUTO_INCREMENT NOT NULL,
`adminUserId` int NOT NULL,
`actionType` varchar(64) NOT NULL,
`entityType` varchar(64) NOT NULL,
`entityId` varchar(96),
`targetUserId` int,
`payload` json,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `admin_audit_logs_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `app_settings` (
`id` int AUTO_INCREMENT NOT NULL,
`settingKey` varchar(64) NOT NULL,
`label` varchar(128) NOT NULL,
`description` text,
`value` json,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `app_settings_id` PRIMARY KEY(`id`),
CONSTRAINT `app_settings_settingKey_unique` UNIQUE(`settingKey`)
);

查看文件

@@ -50,6 +50,13 @@
"when": 1773510000000,
"tag": "0006_solid_vision_library",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1773543600000,
"tag": "0007_grounded_live_ops",
"breakpoints": true
}
]
}

查看文件

@@ -1,4 +1,4 @@
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float } from "drizzle-orm/mysql-core";
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float, uniqueIndex } from "drizzle-orm/mysql-core";
/**
* Core user table - supports both OAuth and simple username login
@@ -152,6 +152,18 @@ export const trainingRecords = mysqlTable("training_records", {
planId: int("planId"),
/** Exercise name/type */
exerciseName: varchar("exerciseName", { length: 128 }).notNull(),
exerciseType: varchar("exerciseType", { length: 64 }),
/** Source of the training fact */
sourceType: varchar("sourceType", { length: 32 }).default("manual"),
/** Reference id from source system */
sourceId: varchar("sourceId", { length: 64 }),
/** Optional linked video */
videoId: int("videoId"),
/** Optional linked plan match */
linkedPlanId: int("linkedPlanId"),
matchConfidence: float("matchConfidence"),
actionCount: int("actionCount").default(0),
metadata: json("metadata"),
/** Duration in minutes */
durationMinutes: int("durationMinutes"),
/** Completion status */
@@ -168,6 +180,94 @@ export const trainingRecords = mysqlTable("training_records", {
export type TrainingRecord = typeof trainingRecords.$inferSelect;
export type InsertTrainingRecord = typeof trainingRecords.$inferInsert;
/**
* Live analysis sessions captured from the realtime camera workflow.
*/
export const liveAnalysisSessions = mysqlTable("live_analysis_sessions", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
title: varchar("title", { length: 256 }).notNull(),
sessionMode: mysqlEnum("sessionMode", ["practice", "pk"]).default("practice").notNull(),
status: mysqlEnum("status", ["active", "completed", "aborted"]).default("completed").notNull(),
startedAt: timestamp("startedAt").defaultNow().notNull(),
endedAt: timestamp("endedAt"),
durationMs: int("durationMs").default(0).notNull(),
dominantAction: varchar("dominantAction", { length: 64 }),
overallScore: float("overallScore"),
postureScore: float("postureScore"),
balanceScore: float("balanceScore"),
techniqueScore: float("techniqueScore"),
footworkScore: float("footworkScore"),
consistencyScore: float("consistencyScore"),
unknownActionRatio: float("unknownActionRatio"),
totalSegments: int("totalSegments").default(0).notNull(),
effectiveSegments: int("effectiveSegments").default(0).notNull(),
totalActionCount: int("totalActionCount").default(0).notNull(),
videoId: int("videoId"),
videoUrl: text("videoUrl"),
summary: text("summary"),
feedback: json("feedback"),
metrics: json("metrics"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type LiveAnalysisSession = typeof liveAnalysisSessions.$inferSelect;
export type InsertLiveAnalysisSession = typeof liveAnalysisSessions.$inferInsert;
/**
* Action segments extracted from a realtime analysis session.
*/
export const liveActionSegments = mysqlTable("live_action_segments", {
id: int("id").autoincrement().primaryKey(),
sessionId: int("sessionId").notNull(),
actionType: varchar("actionType", { length: 64 }).notNull(),
isUnknown: int("isUnknown").default(0).notNull(),
startMs: int("startMs").notNull(),
endMs: int("endMs").notNull(),
durationMs: int("durationMs").notNull(),
confidenceAvg: float("confidenceAvg"),
score: float("score"),
peakScore: float("peakScore"),
frameCount: int("frameCount").default(0).notNull(),
issueSummary: json("issueSummary"),
keyFrames: json("keyFrames"),
clipLabel: varchar("clipLabel", { length: 128 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
sessionIndex: uniqueIndex("live_action_segments_session_start_idx").on(table.sessionId, table.startMs),
}));
export type LiveActionSegment = typeof liveActionSegments.$inferSelect;
export type InsertLiveActionSegment = typeof liveActionSegments.$inferInsert;
/**
* Daily training aggregate used for streaks, achievements and daily NTRP refresh.
*/
export const dailyTrainingAggregates = mysqlTable("daily_training_aggregates", {
id: int("id").autoincrement().primaryKey(),
dayKey: varchar("dayKey", { length: 32 }).notNull().unique(),
userId: int("userId").notNull(),
trainingDate: varchar("trainingDate", { length: 10 }).notNull(),
totalMinutes: int("totalMinutes").default(0).notNull(),
sessionCount: int("sessionCount").default(0).notNull(),
analysisCount: int("analysisCount").default(0).notNull(),
liveAnalysisCount: int("liveAnalysisCount").default(0).notNull(),
recordingCount: int("recordingCount").default(0).notNull(),
pkCount: int("pkCount").default(0).notNull(),
totalActions: int("totalActions").default(0).notNull(),
effectiveActions: int("effectiveActions").default(0).notNull(),
unknownActions: int("unknownActions").default(0).notNull(),
totalScore: float("totalScore").default(0).notNull(),
averageScore: float("averageScore").default(0).notNull(),
metadata: json("metadata"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type DailyTrainingAggregate = typeof dailyTrainingAggregates.$inferSelect;
export type InsertDailyTrainingAggregate = typeof dailyTrainingAggregates.$inferInsert;
/**
* NTRP Rating history - tracks rating changes over time
*/
@@ -188,6 +288,25 @@ export const ratingHistory = mysqlTable("rating_history", {
export type RatingHistory = typeof ratingHistory.$inferSelect;
export type InsertRatingHistory = typeof ratingHistory.$inferInsert;
/**
* Daily NTRP snapshots generated by async refresh jobs.
*/
export const ntrpSnapshots = mysqlTable("ntrp_snapshots", {
id: int("id").autoincrement().primaryKey(),
snapshotKey: varchar("snapshotKey", { length: 64 }).notNull().unique(),
userId: int("userId").notNull(),
snapshotDate: varchar("snapshotDate", { length: 10 }).notNull(),
rating: float("rating").notNull(),
triggerType: mysqlEnum("triggerType", ["analysis", "daily", "manual"]).default("daily").notNull(),
taskId: varchar("taskId", { length: 64 }),
dimensionScores: json("dimensionScores"),
sourceSummary: json("sourceSummary"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type NtrpSnapshot = typeof ntrpSnapshots.$inferSelect;
export type InsertNtrpSnapshot = typeof ntrpSnapshots.$inferInsert;
/**
* Daily check-in records for streak tracking
*/
@@ -223,6 +342,49 @@ export const userBadges = mysqlTable("user_badges", {
export type UserBadge = typeof userBadges.$inferSelect;
export type InsertUserBadge = typeof userBadges.$inferInsert;
/**
* Achievement definitions that can scale beyond the legacy badge system.
*/
export const achievementDefinitions = mysqlTable("achievement_definitions", {
id: int("id").autoincrement().primaryKey(),
key: varchar("key", { length: 64 }).notNull().unique(),
name: varchar("name", { length: 128 }).notNull(),
description: text("description"),
category: varchar("category", { length: 32 }).notNull(),
rarity: varchar("rarity", { length: 16 }).default("common").notNull(),
icon: varchar("icon", { length: 16 }).default("🎾").notNull(),
metricKey: varchar("metricKey", { length: 64 }).notNull(),
targetValue: float("targetValue").notNull(),
tier: int("tier").default(1).notNull(),
isHidden: int("isHidden").default(0).notNull(),
isActive: int("isActive").default(1).notNull(),
sortOrder: int("sortOrder").default(0).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type AchievementDefinition = typeof achievementDefinitions.$inferSelect;
export type InsertAchievementDefinition = typeof achievementDefinitions.$inferInsert;
/**
* User achievement progress and unlock records.
*/
export const userAchievements = mysqlTable("user_achievements", {
id: int("id").autoincrement().primaryKey(),
progressKey: varchar("progressKey", { length: 96 }).notNull().unique(),
userId: int("userId").notNull(),
achievementKey: varchar("achievementKey", { length: 64 }).notNull(),
currentValue: float("currentValue").default(0).notNull(),
progressPct: float("progressPct").default(0).notNull(),
unlockedAt: timestamp("unlockedAt"),
lastEvaluatedAt: timestamp("lastEvaluatedAt").defaultNow().notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type UserAchievement = typeof userAchievements.$inferSelect;
export type InsertUserAchievement = typeof userAchievements.$inferInsert;
/**
* Tutorial video library - professional coaching reference videos
*/
@@ -313,6 +475,8 @@ export const backgroundTasks = mysqlTable("background_tasks", {
"training_plan_adjust",
"analysis_corrections",
"pose_correction_multimodal",
"ntrp_refresh_user",
"ntrp_refresh_all",
]).notNull(),
status: mysqlEnum("status", ["queued", "running", "succeeded", "failed"]).notNull().default("queued"),
title: varchar("title", { length: 256 }).notNull(),
@@ -335,6 +499,39 @@ export const backgroundTasks = mysqlTable("background_tasks", {
export type BackgroundTask = typeof backgroundTasks.$inferSelect;
export type InsertBackgroundTask = typeof backgroundTasks.$inferInsert;
/**
* Admin audit trail for privileged actions.
*/
export const adminAuditLogs = mysqlTable("admin_audit_logs", {
id: int("id").autoincrement().primaryKey(),
adminUserId: int("adminUserId").notNull(),
actionType: varchar("actionType", { length: 64 }).notNull(),
entityType: varchar("entityType", { length: 64 }).notNull(),
entityId: varchar("entityId", { length: 96 }),
targetUserId: int("targetUserId"),
payload: json("payload"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type AdminAuditLog = typeof adminAuditLogs.$inferSelect;
export type InsertAdminAuditLog = typeof adminAuditLogs.$inferInsert;
/**
* App settings editable from the admin console.
*/
export const appSettings = mysqlTable("app_settings", {
id: int("id").autoincrement().primaryKey(),
settingKey: varchar("settingKey", { length: 64 }).notNull().unique(),
label: varchar("label", { length: 128 }).notNull(),
description: text("description"),
value: json("value"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type AppSetting = typeof appSettings.$inferSelect;
export type InsertAppSetting = typeof appSettings.$inferInsert;
/**
* Vision reference library - canonical public tennis images used for multimodal evaluation
*/

查看文件

@@ -9,7 +9,39 @@ import { appRouter } from "../routers";
import { createContext } from "./context";
import { registerMediaProxy } from "./mediaProxy";
import { serveStatic } from "./static";
import { seedTutorials, seedVisionReferenceImages } from "../db";
import { createBackgroundTask, getAdminUserId, hasRecentBackgroundTaskOfType, seedAchievementDefinitions, seedAppSettings, seedTutorials, seedVisionReferenceImages } from "../db";
import { nanoid } from "nanoid";
async function scheduleDailyNtrpRefresh() {
const now = new Date();
if (now.getHours() !== 0 || now.getMinutes() > 5) {
return;
}
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const exists = await hasRecentBackgroundTaskOfType("ntrp_refresh_all", midnight);
if (exists) {
return;
}
const adminUserId = await getAdminUserId();
if (!adminUserId) {
return;
}
const taskId = nanoid();
await createBackgroundTask({
id: taskId,
userId: adminUserId,
type: "ntrp_refresh_all",
title: "每日 NTRP 刷新",
message: "系统已自动创建每日 NTRP 刷新任务",
payload: { source: "scheduler", scheduledAt: now.toISOString() },
progress: 0,
maxAttempts: 3,
});
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
@@ -33,6 +65,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
async function startServer() {
await seedTutorials();
await seedVisionReferenceImages();
await seedAchievementDefinitions();
await seedAppSettings();
const app = express();
const server = createServer(app);
@@ -73,6 +107,12 @@ async function startServer() {
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
});
setInterval(() => {
void scheduleDailyNtrpRefresh().catch((error) => {
console.error("[scheduler] failed to schedule NTRP refresh", error);
});
}, 60_000);
}
startServer().catch(console.error);

查看文件

@@ -1,4 +1,4 @@
import { eq, desc, and, asc, lte, sql } from "drizzle-orm";
import { eq, desc, and, asc, lte, gte, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser, users,
@@ -7,14 +7,22 @@ import {
trainingVideos, InsertTrainingVideo,
poseAnalyses, InsertPoseAnalysis,
trainingRecords, InsertTrainingRecord,
liveAnalysisSessions, InsertLiveAnalysisSession,
liveActionSegments, InsertLiveActionSegment,
dailyTrainingAggregates, InsertDailyTrainingAggregate,
ratingHistory, InsertRatingHistory,
ntrpSnapshots, InsertNtrpSnapshot,
dailyCheckins, InsertDailyCheckin,
userBadges, InsertUserBadge,
achievementDefinitions, InsertAchievementDefinition,
userAchievements, InsertUserAchievement,
tutorialVideos, InsertTutorialVideo,
tutorialProgress, InsertTutorialProgress,
trainingReminders, InsertTrainingReminder,
notificationLog, InsertNotificationLog,
backgroundTasks, InsertBackgroundTask,
adminAuditLogs, InsertAdminAuditLog,
appSettings, InsertAppSetting,
visionReferenceImages, InsertVisionReferenceImage,
visionTestRuns, InsertVisionTestRun,
} from "../drizzle/schema";
@@ -22,6 +30,72 @@ import { ENV } from './_core/env';
let _db: ReturnType<typeof drizzle> | null = null;
const APP_TIMEZONE = process.env.TZ || "Asia/Shanghai";
function getDateFormatter() {
return new Intl.DateTimeFormat("en-CA", {
timeZone: APP_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
export function getDateKey(date = new Date()) {
return getDateFormatter().format(date);
}
function toDayKey(userId: number, trainingDate: string) {
return `${userId}:${trainingDate}`;
}
export const DEFAULT_APP_SETTINGS: Omit<InsertAppSetting, "id" | "createdAt" | "updatedAt">[] = [
{
settingKey: "action_unknown_confidence_threshold",
label: "未知动作阈值",
description: "当动作识别置信度低于此值时归类为未知动作。",
value: { value: 0.45, type: "number" },
},
{
settingKey: "action_merge_gap_ms",
label: "动作合并间隔",
description: "相邻同类动作小于该间隔时会合并为同一片段。",
value: { value: 500, type: "number" },
},
{
settingKey: "action_segment_max_ms",
label: "动作片段最长时长",
description: "单个动作片段最长持续时间。",
value: { value: 10000, type: "number" },
},
{
settingKey: "ntrp_daily_refresh_hour",
label: "NTRP 每日刷新小时",
description: "每天异步刷新 NTRP 的小时数。",
value: { value: 0, type: "number" },
},
];
export const ACHIEVEMENT_DEFINITION_SEED_DATA: Omit<InsertAchievementDefinition, "id" | "createdAt" | "updatedAt">[] = [
{ key: "training_day_1", name: "开练", description: "完成首个训练日", category: "consistency", rarity: "common", icon: "🎾", metricKey: "training_days", targetValue: 1, tier: 1, sortOrder: 1, isHidden: 0, isActive: 1 },
{ key: "training_day_3", name: "三日连练", description: "连续训练 3 天", category: "consistency", rarity: "common", icon: "🔥", metricKey: "current_streak", targetValue: 3, tier: 2, sortOrder: 2, isHidden: 0, isActive: 1 },
{ key: "training_day_7", name: "一周稳定", description: "连续训练 7 天", category: "consistency", rarity: "rare", icon: "⭐", metricKey: "current_streak", targetValue: 7, tier: 3, sortOrder: 3, isHidden: 0, isActive: 1 },
{ key: "training_minutes_60", name: "首个小时", description: "累计训练 60 分钟", category: "volume", rarity: "common", icon: "⏱️", metricKey: "total_minutes", targetValue: 60, tier: 1, sortOrder: 10, isHidden: 0, isActive: 1 },
{ key: "training_minutes_300", name: "五小时达标", description: "累计训练 300 分钟", category: "volume", rarity: "rare", icon: "🕐", metricKey: "total_minutes", targetValue: 300, tier: 2, sortOrder: 11, isHidden: 0, isActive: 1 },
{ key: "training_minutes_1000", name: "千分钟训练者", description: "累计训练 1000 分钟", category: "volume", rarity: "epic", icon: "⏰", metricKey: "total_minutes", targetValue: 1000, tier: 3, sortOrder: 12, isHidden: 0, isActive: 1 },
{ key: "effective_actions_50", name: "动作起步", description: "累计完成 50 个有效动作", category: "technique", rarity: "common", icon: "🏓", metricKey: "effective_actions", targetValue: 50, tier: 1, sortOrder: 20, isHidden: 0, isActive: 1 },
{ key: "effective_actions_200", name: "动作累积", description: "累计完成 200 个有效动作", category: "technique", rarity: "rare", icon: "💥", metricKey: "effective_actions", targetValue: 200, tier: 2, sortOrder: 21, isHidden: 0, isActive: 1 },
{ key: "recordings_1", name: "录像开启", description: "完成首个录制归档", category: "recording", rarity: "common", icon: "🎥", metricKey: "recording_count", targetValue: 1, tier: 1, sortOrder: 30, isHidden: 0, isActive: 1 },
{ key: "analyses_1", name: "分析首秀", description: "完成首个分析会话", category: "analysis", rarity: "common", icon: "🧠", metricKey: "analysis_count", targetValue: 1, tier: 1, sortOrder: 31, isHidden: 0, isActive: 1 },
{ key: "live_analysis_5", name: "实时观察者", description: "完成 5 次实时分析", category: "analysis", rarity: "rare", icon: "📹", metricKey: "live_analysis_count", targetValue: 5, tier: 2, sortOrder: 32, isHidden: 0, isActive: 1 },
{ key: "score_80", name: "高分动作", description: "任意训练得分达到 80", category: "quality", rarity: "rare", icon: "🏅", metricKey: "best_score", targetValue: 80, tier: 1, sortOrder: 40, isHidden: 0, isActive: 1 },
{ key: "score_90", name: "精确击球", description: "任意训练得分达到 90", category: "quality", rarity: "epic", icon: "🥇", metricKey: "best_score", targetValue: 90, tier: 2, sortOrder: 41, isHidden: 0, isActive: 1 },
{ key: "ntrp_2_5", name: "NTRP 2.5", description: "综合评分达到 2.5", category: "rating", rarity: "rare", icon: "📈", metricKey: "ntrp_rating", targetValue: 2.5, tier: 1, sortOrder: 50, isHidden: 0, isActive: 1 },
{ key: "ntrp_3_0", name: "NTRP 3.0", description: "综合评分达到 3.0", category: "rating", rarity: "epic", icon: "🚀", metricKey: "ntrp_rating", targetValue: 3.0, tier: 2, sortOrder: 51, isHidden: 0, isActive: 1 },
{ key: "pk_session_1", name: "训练 PK", description: "完成首个 PK 会话", category: "pk", rarity: "rare", icon: "⚔️", metricKey: "pk_count", targetValue: 1, tier: 1, sortOrder: 60, isHidden: 0, isActive: 1 },
{ key: "plan_link_5", name: "按计划训练", description: "累计 5 次训练匹配训练计划", category: "plan", rarity: "rare", icon: "🗂️", metricKey: "plan_matches", targetValue: 5, tier: 1, sortOrder: 70, isHidden: 0, isActive: 1 },
];
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
@@ -34,6 +108,150 @@ export async function getDb() {
return _db;
}
export async function seedAppSettings() {
const db = await getDb();
if (!db) return;
for (const setting of DEFAULT_APP_SETTINGS) {
const existing = await db.select().from(appSettings).where(eq(appSettings.settingKey, setting.settingKey)).limit(1);
if (existing.length === 0) {
await db.insert(appSettings).values(setting);
}
}
}
export async function listAppSettings() {
const db = await getDb();
if (!db) return [];
return db.select().from(appSettings).orderBy(asc(appSettings.settingKey));
}
export async function updateAppSetting(settingKey: string, value: unknown) {
const db = await getDb();
if (!db) return;
await db.update(appSettings).set({ value }).where(eq(appSettings.settingKey, settingKey));
}
export async function seedAchievementDefinitions() {
const db = await getDb();
if (!db) return;
for (const definition of ACHIEVEMENT_DEFINITION_SEED_DATA) {
const existing = await db.select().from(achievementDefinitions).where(eq(achievementDefinitions.key, definition.key)).limit(1);
if (existing.length === 0) {
await db.insert(achievementDefinitions).values(definition);
}
}
}
export async function listAchievementDefinitions() {
const db = await getDb();
if (!db) return [];
return db.select().from(achievementDefinitions)
.where(eq(achievementDefinitions.isActive, 1))
.orderBy(asc(achievementDefinitions.sortOrder), asc(achievementDefinitions.id));
}
export async function listAllAchievementDefinitions() {
const db = await getDb();
if (!db) return [];
return db.select().from(achievementDefinitions)
.orderBy(asc(achievementDefinitions.sortOrder), asc(achievementDefinitions.id));
}
export async function createAdminAuditLog(entry: InsertAdminAuditLog) {
const db = await getDb();
if (!db) return;
await db.insert(adminAuditLogs).values(entry);
}
export async function listAdminAuditLogs(limit = 100) {
const db = await getDb();
if (!db) return [];
return db.select({
id: adminAuditLogs.id,
adminUserId: adminAuditLogs.adminUserId,
adminName: users.name,
actionType: adminAuditLogs.actionType,
entityType: adminAuditLogs.entityType,
entityId: adminAuditLogs.entityId,
targetUserId: adminAuditLogs.targetUserId,
payload: adminAuditLogs.payload,
createdAt: adminAuditLogs.createdAt,
}).from(adminAuditLogs)
.leftJoin(users, eq(users.id, adminAuditLogs.adminUserId))
.orderBy(desc(adminAuditLogs.createdAt))
.limit(limit);
}
export async function listUsersForAdmin(limit = 100) {
const db = await getDb();
if (!db) return [];
return db.select({
id: users.id,
name: users.name,
role: users.role,
ntrpRating: users.ntrpRating,
totalSessions: users.totalSessions,
totalMinutes: users.totalMinutes,
totalShots: users.totalShots,
currentStreak: users.currentStreak,
longestStreak: users.longestStreak,
createdAt: users.createdAt,
lastSignedIn: users.lastSignedIn,
}).from(users).orderBy(desc(users.lastSignedIn)).limit(limit);
}
export async function getAdminUserId() {
const db = await getDb();
if (!db) return null;
const [admin] = await db.select().from(users).where(eq(users.role, "admin")).orderBy(desc(users.lastSignedIn)).limit(1);
return admin?.id ?? null;
}
export async function listAllBackgroundTasks(limit = 100) {
const db = await getDb();
if (!db) return [];
return db.select({
id: backgroundTasks.id,
userId: backgroundTasks.userId,
userName: users.name,
type: backgroundTasks.type,
status: backgroundTasks.status,
title: backgroundTasks.title,
message: backgroundTasks.message,
progress: backgroundTasks.progress,
payload: backgroundTasks.payload,
result: backgroundTasks.result,
error: backgroundTasks.error,
attempts: backgroundTasks.attempts,
maxAttempts: backgroundTasks.maxAttempts,
createdAt: backgroundTasks.createdAt,
updatedAt: backgroundTasks.updatedAt,
completedAt: backgroundTasks.completedAt,
}).from(backgroundTasks)
.leftJoin(users, eq(users.id, backgroundTasks.userId))
.orderBy(desc(backgroundTasks.createdAt))
.limit(limit);
}
export async function hasRecentBackgroundTaskOfType(
type: "ntrp_refresh_user" | "ntrp_refresh_all",
since: Date,
) {
const db = await getDb();
if (!db) return false;
const result = await db.select({ count: sql<number>`count(*)` }).from(backgroundTasks)
.where(and(eq(backgroundTasks.type, type), gte(backgroundTasks.createdAt, since)));
return (result[0]?.count || 0) > 0;
}
export async function listUserIds() {
const db = await getDb();
if (!db) return [];
return db.select({ id: users.id }).from(users).orderBy(asc(users.id));
}
// ===== USER OPERATIONS =====
export async function upsertUser(user: InsertUser): Promise<void> {
@@ -175,6 +393,41 @@ export async function updateTrainingPlan(planId: number, data: Partial<InsertTra
await db.update(trainingPlans).set(data).where(eq(trainingPlans.id, planId));
}
const PLAN_KEYWORDS: Record<string, string[]> = {
forehand: ["正手", "forehand"],
backhand: ["反手", "backhand"],
serve: ["发球", "serve"],
volley: ["截击", "volley"],
overhead: ["高压", "overhead"],
slice: ["切削", "slice"],
lob: ["挑高", "lob"],
unknown: ["综合", "基础", "训练"],
};
export async function matchActivePlanForExercise(userId: number, exerciseType?: string | null) {
const activePlan = await getActivePlan(userId);
if (!activePlan || !exerciseType) {
return null;
}
const keywords = PLAN_KEYWORDS[exerciseType] ?? [exerciseType];
const exercises = Array.isArray(activePlan.exercises) ? activePlan.exercises as Array<Record<string, unknown>> : [];
const matched = exercises.find((exercise) => {
const haystack = JSON.stringify(exercise).toLowerCase();
return keywords.some(keyword => haystack.includes(keyword.toLowerCase()));
});
if (!matched) {
return null;
}
return {
planId: activePlan.id,
confidence: 0.72,
matchedExercise: matched,
};
}
// ===== VIDEO OPERATIONS =====
export async function createVideo(video: InsertTrainingVideo) {
@@ -255,6 +508,173 @@ export async function markRecordCompleted(recordId: number, poseScore?: number)
await db.update(trainingRecords).set({ completed: 1, poseScore: poseScore ?? null }).where(eq(trainingRecords.id, recordId));
}
export async function upsertTrainingRecordBySource(
record: InsertTrainingRecord & { sourceType: string; sourceId: string; userId: number }
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const existing = await db.select().from(trainingRecords)
.where(and(
eq(trainingRecords.userId, record.userId),
eq(trainingRecords.sourceType, record.sourceType),
eq(trainingRecords.sourceId, record.sourceId),
))
.limit(1);
if (existing.length > 0) {
await db.update(trainingRecords).set(record).where(eq(trainingRecords.id, existing[0].id));
return { recordId: existing[0].id, isNew: false };
}
const result = await db.insert(trainingRecords).values(record);
return { recordId: result[0].insertId, isNew: true };
}
export async function upsertDailyTrainingAggregate(input: {
userId: number;
trainingDate: string;
deltaMinutes?: number;
deltaSessions?: number;
deltaAnalysisCount?: number;
deltaLiveAnalysisCount?: number;
deltaRecordingCount?: number;
deltaPkCount?: number;
deltaTotalActions?: number;
deltaEffectiveActions?: number;
deltaUnknownActions?: number;
score?: number | null;
metadata?: Record<string, unknown>;
}) {
const db = await getDb();
if (!db) return;
const dayKey = toDayKey(input.userId, input.trainingDate);
const [existing] = await db.select().from(dailyTrainingAggregates)
.where(eq(dailyTrainingAggregates.dayKey, dayKey))
.limit(1);
if (!existing) {
const totalScore = input.score ?? 0;
await db.insert(dailyTrainingAggregates).values({
dayKey,
userId: input.userId,
trainingDate: input.trainingDate,
totalMinutes: input.deltaMinutes ?? 0,
sessionCount: input.deltaSessions ?? 0,
analysisCount: input.deltaAnalysisCount ?? 0,
liveAnalysisCount: input.deltaLiveAnalysisCount ?? 0,
recordingCount: input.deltaRecordingCount ?? 0,
pkCount: input.deltaPkCount ?? 0,
totalActions: input.deltaTotalActions ?? 0,
effectiveActions: input.deltaEffectiveActions ?? 0,
unknownActions: input.deltaUnknownActions ?? 0,
totalScore,
averageScore: totalScore > 0 ? totalScore / Math.max(1, input.deltaSessions ?? 1) : 0,
metadata: input.metadata ?? null,
});
} else {
const nextSessionCount = (existing.sessionCount || 0) + (input.deltaSessions ?? 0);
const nextTotalScore = (existing.totalScore || 0) + (input.score ?? 0);
await db.update(dailyTrainingAggregates).set({
totalMinutes: (existing.totalMinutes || 0) + (input.deltaMinutes ?? 0),
sessionCount: nextSessionCount,
analysisCount: (existing.analysisCount || 0) + (input.deltaAnalysisCount ?? 0),
liveAnalysisCount: (existing.liveAnalysisCount || 0) + (input.deltaLiveAnalysisCount ?? 0),
recordingCount: (existing.recordingCount || 0) + (input.deltaRecordingCount ?? 0),
pkCount: (existing.pkCount || 0) + (input.deltaPkCount ?? 0),
totalActions: (existing.totalActions || 0) + (input.deltaTotalActions ?? 0),
effectiveActions: (existing.effectiveActions || 0) + (input.deltaEffectiveActions ?? 0),
unknownActions: (existing.unknownActions || 0) + (input.deltaUnknownActions ?? 0),
totalScore: nextTotalScore,
averageScore: nextSessionCount > 0 ? nextTotalScore / nextSessionCount : 0,
metadata: input.metadata ? { ...(existing.metadata as Record<string, unknown> | null ?? {}), ...input.metadata } : existing.metadata,
}).where(eq(dailyTrainingAggregates.id, existing.id));
}
await refreshUserTrainingSummary(input.userId);
}
export async function listDailyTrainingAggregates(userId: number, limit = 30) {
const db = await getDb();
if (!db) return [];
return db.select().from(dailyTrainingAggregates)
.where(eq(dailyTrainingAggregates.userId, userId))
.orderBy(desc(dailyTrainingAggregates.trainingDate))
.limit(limit);
}
export async function refreshUserTrainingSummary(userId: number) {
const db = await getDb();
if (!db) return;
const records = await db.select().from(trainingRecords)
.where(and(eq(trainingRecords.userId, userId), eq(trainingRecords.completed, 1)));
const aggregates = await db.select().from(dailyTrainingAggregates)
.where(eq(dailyTrainingAggregates.userId, userId))
.orderBy(desc(dailyTrainingAggregates.trainingDate));
const totalSessions = records.length;
const totalMinutes = records.reduce((sum, item) => sum + (item.durationMinutes || 0), 0);
const totalShots = aggregates.reduce((sum, item) => sum + (item.effectiveActions || 0), 0);
let currentStreak = 0;
const sortedDays = aggregates
.filter(item => (item.sessionCount || 0) > 0)
.map(item => item.trainingDate)
.sort((a, b) => a < b ? 1 : -1);
let cursor = new Date(`${getDateKey()}T00:00:00`);
for (const day of sortedDays) {
const normalized = new Date(`${day}T00:00:00`);
const diffDays = Math.round((cursor.getTime() - normalized.getTime()) / 86400000);
if (diffDays === 0 || diffDays === 1) {
currentStreak += 1;
cursor = normalized;
continue;
}
if (currentStreak > 0) {
break;
}
cursor = normalized;
currentStreak = 1;
}
const longestStreak = Math.max(currentStreak, records.length > 0 ? (await getLongestTrainingStreak(userId)) : 0);
await db.update(users).set({
totalSessions,
totalMinutes,
totalShots,
currentStreak,
longestStreak,
}).where(eq(users.id, userId));
}
async function getLongestTrainingStreak(userId: number) {
const db = await getDb();
if (!db) return 0;
const aggregates = await db.select().from(dailyTrainingAggregates)
.where(eq(dailyTrainingAggregates.userId, userId))
.orderBy(asc(dailyTrainingAggregates.trainingDate));
let longest = 0;
let current = 0;
let prev: Date | null = null;
for (const item of aggregates) {
if ((item.sessionCount || 0) <= 0) continue;
const currentDate = new Date(`${item.trainingDate}T00:00:00`);
if (!prev) {
current = 1;
} else {
const diff = Math.round((currentDate.getTime() - prev.getTime()) / 86400000);
current = diff === 1 ? current + 1 : 1;
}
longest = Math.max(longest, current);
prev = currentDate;
}
return longest;
}
// ===== RATING HISTORY OPERATIONS =====
export async function createRatingEntry(entry: InsertRatingHistory) {
@@ -270,6 +690,109 @@ export async function getUserRatingHistory(userId: number, limit = 30) {
return db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(limit);
}
export async function createNtrpSnapshot(snapshot: InsertNtrpSnapshot) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const existing = await db.select().from(ntrpSnapshots)
.where(eq(ntrpSnapshots.snapshotKey, snapshot.snapshotKey))
.limit(1);
if (existing.length > 0) {
await db.update(ntrpSnapshots).set(snapshot).where(eq(ntrpSnapshots.id, existing[0].id));
return existing[0].id;
}
const result = await db.insert(ntrpSnapshots).values(snapshot);
return result[0].insertId;
}
export async function getLatestNtrpSnapshot(userId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(ntrpSnapshots)
.where(eq(ntrpSnapshots.userId, userId))
.orderBy(desc(ntrpSnapshots.createdAt))
.limit(1);
return result[0];
}
export async function listNtrpSnapshots(userId: number, limit = 30) {
const db = await getDb();
if (!db) return [];
return db.select().from(ntrpSnapshots)
.where(eq(ntrpSnapshots.userId, userId))
.orderBy(desc(ntrpSnapshots.createdAt))
.limit(limit);
}
export async function createLiveAnalysisSession(session: InsertLiveAnalysisSession) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(liveAnalysisSessions).values(session);
return result[0].insertId;
}
export async function createLiveActionSegments(segments: InsertLiveActionSegment[]) {
const db = await getDb();
if (!db || segments.length === 0) return;
await db.insert(liveActionSegments).values(segments);
}
export async function listLiveAnalysisSessions(userId: number, limit = 20) {
const db = await getDb();
if (!db) return [];
return db.select().from(liveAnalysisSessions)
.where(eq(liveAnalysisSessions.userId, userId))
.orderBy(desc(liveAnalysisSessions.createdAt))
.limit(limit);
}
export async function listAdminLiveAnalysisSessions(limit = 50) {
const db = await getDb();
if (!db) return [];
return db.select({
id: liveAnalysisSessions.id,
userId: liveAnalysisSessions.userId,
userName: users.name,
title: liveAnalysisSessions.title,
sessionMode: liveAnalysisSessions.sessionMode,
status: liveAnalysisSessions.status,
dominantAction: liveAnalysisSessions.dominantAction,
overallScore: liveAnalysisSessions.overallScore,
durationMs: liveAnalysisSessions.durationMs,
effectiveSegments: liveAnalysisSessions.effectiveSegments,
totalSegments: liveAnalysisSessions.totalSegments,
videoUrl: liveAnalysisSessions.videoUrl,
createdAt: liveAnalysisSessions.createdAt,
}).from(liveAnalysisSessions)
.leftJoin(users, eq(users.id, liveAnalysisSessions.userId))
.orderBy(desc(liveAnalysisSessions.createdAt))
.limit(limit);
}
export async function getLiveAnalysisSessionById(sessionId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(liveAnalysisSessions)
.where(eq(liveAnalysisSessions.id, sessionId))
.limit(1);
return result[0];
}
export async function getLiveActionSegmentsBySessionId(sessionId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(liveActionSegments)
.where(eq(liveActionSegments.sessionId, sessionId))
.orderBy(asc(liveActionSegments.startMs));
}
export async function getAchievementProgress(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(userAchievements)
.where(eq(userAchievements.userId, userId))
.orderBy(desc(userAchievements.unlockedAt), asc(userAchievements.achievementKey));
}
// ===== DAILY CHECK-IN OPERATIONS =====
export async function checkinToday(userId: number, notes?: string, minutesTrained?: number) {
@@ -329,6 +852,118 @@ export async function getTodayCheckin(userId: number) {
return result.length > 0 ? result[0] : null;
}
function metricValueFromContext(metricKey: string, context: {
trainingDays: number;
currentStreak: number;
totalMinutes: number;
effectiveActions: number;
recordingCount: number;
analysisCount: number;
liveAnalysisCount: number;
bestScore: number;
ntrpRating: number;
pkCount: number;
planMatches: number;
}) {
const metricMap: Record<string, number> = {
training_days: context.trainingDays,
current_streak: context.currentStreak,
total_minutes: context.totalMinutes,
effective_actions: context.effectiveActions,
recording_count: context.recordingCount,
analysis_count: context.analysisCount,
live_analysis_count: context.liveAnalysisCount,
best_score: context.bestScore,
ntrp_rating: context.ntrpRating,
pk_count: context.pkCount,
plan_matches: context.planMatches,
};
return metricMap[metricKey] ?? 0;
}
export async function refreshAchievementsForUser(userId: number) {
const db = await getDb();
if (!db) return [];
const definitions = await listAchievementDefinitions();
const progressRows = await getAchievementProgress(userId);
const records = await db.select().from(trainingRecords).where(and(eq(trainingRecords.userId, userId), eq(trainingRecords.completed, 1)));
const aggregates = await db.select().from(dailyTrainingAggregates).where(eq(dailyTrainingAggregates.userId, userId));
const liveSessions = await db.select().from(liveAnalysisSessions).where(eq(liveAnalysisSessions.userId, userId));
const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
const bestScore = Math.max(
0,
...records.map((record) => record.poseScore || 0),
...liveSessions.map((session) => session.overallScore || 0),
);
const planMatches = records.filter((record) => record.linkedPlanId != null).length;
const context = {
trainingDays: aggregates.filter(item => (item.sessionCount || 0) > 0).length,
currentStreak: userRow?.currentStreak || 0,
totalMinutes: userRow?.totalMinutes || 0,
effectiveActions: userRow?.totalShots || 0,
recordingCount: records.filter(record => record.sourceType === "recording").length,
analysisCount: records.filter(record => record.sourceType === "analysis_upload").length,
liveAnalysisCount: records.filter(record => record.sourceType === "live_analysis").length,
bestScore,
ntrpRating: userRow?.ntrpRating || 1.5,
pkCount: records.filter(record => ((record.metadata as Record<string, unknown> | null)?.sessionMode) === "pk").length,
planMatches,
};
const unlockedKeys: string[] = [];
for (const definition of definitions) {
const currentValue = metricValueFromContext(definition.metricKey, context);
const progressPct = definition.targetValue > 0 ? Math.min(100, (currentValue / definition.targetValue) * 100) : 0;
const progressKey = `${userId}:${definition.key}`;
const existing = progressRows.find((row) => row.achievementKey === definition.key);
const unlockedAt = currentValue >= definition.targetValue ? (existing?.unlockedAt ?? new Date()) : null;
if (!existing) {
await db.insert(userAchievements).values({
progressKey,
userId,
achievementKey: definition.key,
currentValue,
progressPct,
unlockedAt,
});
if (unlockedAt) unlockedKeys.push(definition.key);
} else {
await db.update(userAchievements).set({
currentValue,
progressPct,
unlockedAt: existing.unlockedAt ?? unlockedAt,
lastEvaluatedAt: new Date(),
}).where(eq(userAchievements.id, existing.id));
if (!existing.unlockedAt && unlockedAt) unlockedKeys.push(definition.key);
}
}
return unlockedKeys;
}
export async function listUserAchievements(userId: number) {
const db = await getDb();
if (!db) return [];
const definitions = await listAllAchievementDefinitions();
const progress = await getAchievementProgress(userId);
const progressMap = new Map(progress.map(item => [item.achievementKey, item]));
return definitions.map((definition) => {
const row = progressMap.get(definition.key);
return {
...definition,
currentValue: row?.currentValue ?? 0,
progressPct: row?.progressPct ?? 0,
unlockedAt: row?.unlockedAt ?? null,
unlocked: Boolean(row?.unlockedAt),
};
});
}
// ===== BADGE OPERATIONS =====
// Badge definitions
@@ -1073,13 +1708,21 @@ export async function getUserStats(userId: number) {
const records = await db.select().from(trainingRecords).where(eq(trainingRecords.userId, userId));
const videos = await db.select().from(trainingVideos).where(eq(trainingVideos.userId, userId));
const ratings = await db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(30);
const daily = await db.select().from(dailyTrainingAggregates).where(eq(dailyTrainingAggregates.userId, userId)).orderBy(desc(dailyTrainingAggregates.trainingDate)).limit(30);
const liveSessions = await db.select().from(liveAnalysisSessions).where(eq(liveAnalysisSessions.userId, userId)).orderBy(desc(liveAnalysisSessions.createdAt)).limit(10);
const latestSnapshot = await getLatestNtrpSnapshot(userId);
const achievements = await listUserAchievements(userId);
const completedRecords = records.filter(r => r.completed === 1);
const totalShots = analyses.reduce((sum, a) => sum + (a.shotCount || 0), 0);
const totalShots = Math.max(
analyses.reduce((sum, a) => sum + (a.shotCount || 0), 0),
daily.reduce((sum, item) => sum + (item.effectiveActions || 0), 0),
userRow.totalShots || 0,
);
const avgScore = analyses.length > 0 ? analyses.reduce((sum, a) => sum + (a.overallScore || 0), 0) / analyses.length : 0;
return {
ntrpRating: userRow.ntrpRating || 1.5,
ntrpRating: userRow.ntrpRating || latestSnapshot?.rating || 1.5,
totalSessions: completedRecords.length,
totalMinutes: records.reduce((sum, r) => sum + (r.durationMinutes || 0), 0),
totalVideos: videos.length,
@@ -1088,5 +1731,9 @@ export async function getUserStats(userId: number) {
averageScore: Math.round(avgScore * 10) / 10,
ratingHistory: ratings.reverse(),
recentAnalyses: analyses.slice(0, 10),
recentLiveSessions: liveSessions,
dailyTraining: daily.reverse(),
achievements,
latestNtrpSnapshot: latestSnapshot ?? null,
};
}

查看文件

@@ -3,6 +3,7 @@ import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
import * as db from "./db";
import * as trainingAutomation from "./trainingAutomation";
import { ENV } from "./_core/env";
import { sdk } from "./_core/sdk";
@@ -957,3 +958,173 @@ describe("vision.seedLibrary", () => {
await expect(caller.vision.seedLibrary()).rejects.toThrow();
});
});
describe("achievement.list", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns achievement progress for authenticated users", async () => {
const user = createTestUser({ id: 12 });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
const listSpy = vi.spyOn(db, "listUserAchievements").mockResolvedValueOnce([
{
id: 1,
key: "training_day_1",
name: "开练",
description: "完成首个训练日",
category: "consistency",
rarity: "common",
icon: "🎾",
metricKey: "training_days",
targetValue: 1,
tier: 1,
isHidden: 0,
isActive: 1,
sortOrder: 1,
createdAt: new Date(),
updatedAt: new Date(),
currentValue: 1,
progressPct: 100,
unlockedAt: new Date(),
unlocked: true,
},
] as any);
const result = await caller.achievement.list();
expect(listSpy).toHaveBeenCalledWith(12);
expect(result).toHaveLength(1);
expect((result[0] as any).key).toBe("training_day_1");
});
});
describe("analysis.liveSessionSave", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("persists a live session and syncs training data", async () => {
const user = createTestUser({ id: 5 });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
const createSessionSpy = vi.spyOn(db, "createLiveAnalysisSession").mockResolvedValueOnce(101);
const createSegmentsSpy = vi.spyOn(db, "createLiveActionSegments").mockResolvedValueOnce();
const syncSpy = vi.spyOn(trainingAutomation, "syncLiveTrainingData").mockResolvedValueOnce({
recordId: 88,
unlocked: ["training_day_1"],
});
const result = await caller.analysis.liveSessionSave({
title: "实时分析 正手",
sessionMode: "practice",
startedAt: Date.now() - 4_000,
endedAt: Date.now(),
durationMs: 4_000,
dominantAction: "forehand",
overallScore: 84,
postureScore: 82,
balanceScore: 78,
techniqueScore: 86,
footworkScore: 75,
consistencyScore: 80,
totalActionCount: 3,
effectiveSegments: 2,
totalSegments: 3,
unknownSegments: 1,
feedback: ["节奏稳定"],
metrics: { sampleCount: 12 },
segments: [
{
actionType: "forehand",
isUnknown: false,
startMs: 500,
endMs: 2_500,
durationMs: 2_000,
confidenceAvg: 0.82,
score: 84,
peakScore: 90,
frameCount: 24,
issueSummary: ["击球点前移"],
keyFrames: [500, 1500, 2500],
clipLabel: "正手挥拍 00:00 - 00:02",
},
],
});
expect(createSessionSpy).toHaveBeenCalledTimes(1);
expect(createSegmentsSpy).toHaveBeenCalledTimes(1);
expect(syncSpy).toHaveBeenCalledWith(expect.objectContaining({
userId: 5,
sessionId: 101,
dominantAction: "forehand",
sessionMode: "practice",
}));
expect(result).toEqual({ sessionId: 101, trainingRecordId: 88 });
});
});
describe("rating.refreshMine", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("creates an async NTRP refresh task for the current user", async () => {
const user = createTestUser({ id: 22 });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
const createTaskSpy = vi.spyOn(db, "createBackgroundTask").mockResolvedValueOnce();
const result = await caller.rating.refreshMine();
expect(createTaskSpy).toHaveBeenCalledWith(expect.objectContaining({
userId: 22,
type: "ntrp_refresh_user",
payload: { targetUserId: 22 },
}));
expect(result.taskId).toBeTruthy();
});
});
describe("admin.users", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("rejects non-admin users", async () => {
const user = createTestUser({ role: "user" });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
await expect(caller.admin.users({ limit: 20 })).rejects.toThrow();
});
it("returns user list for admin users", async () => {
const admin = createTestUser({ id: 1, role: "admin", name: "H1" });
const { ctx } = createMockContext(admin);
const caller = appRouter.createCaller(ctx);
const usersSpy = vi.spyOn(db, "listUsersForAdmin").mockResolvedValueOnce([
{
id: 1,
name: "H1",
role: "admin",
ntrpRating: 3.4,
totalSessions: 10,
totalMinutes: 320,
totalShots: 240,
currentStreak: 6,
longestStreak: 12,
createdAt: new Date(),
lastSignedIn: new Date(),
},
] as any);
const result = await caller.admin.users({ limit: 20 });
expect(usersSpy).toHaveBeenCalledWith(20);
expect(result).toHaveLength(1);
expect((result[0] as any).name).toBe("H1");
});
});

查看文件

@@ -12,10 +12,11 @@ import { nanoid } from "nanoid";
import { getRemoteMediaSession } from "./mediaService";
import { prepareCorrectionImageUrls } from "./taskWorker";
import { toPublicUrl } from "./publicUrl";
import { ACTION_LABELS, refreshUserNtrp, syncAnalysisTrainingData, syncLiveTrainingData } from "./trainingAutomation";
async function enqueueTask(params: {
userId: number;
type: "media_finalize" | "training_plan_generate" | "training_plan_adjust" | "analysis_corrections" | "pose_correction_multimodal";
type: "media_finalize" | "training_plan_generate" | "training_plan_adjust" | "analysis_corrections" | "pose_correction_multimodal" | "ntrp_refresh_user" | "ntrp_refresh_all";
title: string;
payload: Record<string, unknown>;
message: string;
@@ -36,6 +37,24 @@ async function enqueueTask(params: {
return { taskId, task };
}
async function auditAdminAction(params: {
adminUserId: number;
actionType: string;
entityType: string;
entityId?: string | null;
targetUserId?: number | null;
payload?: Record<string, unknown>;
}) {
await db.createAdminAuditLog({
adminUserId: params.adminUserId,
actionType: params.actionType,
entityType: params.entityType,
entityId: params.entityId ?? null,
targetUserId: params.targetUserId ?? null,
payload: params.payload ?? null,
});
}
export const appRouter = router({
system: systemRouter,
@@ -234,11 +253,16 @@ export const appRouter = router({
userId: ctx.user.id,
});
await db.updateVideoStatus(input.videoId, "completed");
const syncResult = await syncAnalysisTrainingData({
userId: ctx.user.id,
videoId: input.videoId,
exerciseType: input.exerciseType,
overallScore: input.overallScore,
shotCount: input.shotCount,
framesAnalyzed: input.framesAnalyzed,
});
// Auto-update NTRP rating after analysis
await recalculateNTRPRating(ctx.user.id, analysisId);
return { analysisId };
return { analysisId, trainingRecordId: syncResult.recordId };
}),
getByVideo: protectedProcedure
@@ -251,6 +275,120 @@ export const appRouter = router({
return db.getUserAnalyses(ctx.user.id);
}),
liveSessionSave: protectedProcedure
.input(z.object({
title: z.string().min(1).max(256),
sessionMode: z.enum(["practice", "pk"]).default("practice"),
startedAt: z.number(),
endedAt: z.number(),
durationMs: z.number().min(0),
dominantAction: z.string().optional(),
overallScore: z.number().optional(),
postureScore: z.number().optional(),
balanceScore: z.number().optional(),
techniqueScore: z.number().optional(),
footworkScore: z.number().optional(),
consistencyScore: z.number().optional(),
totalActionCount: z.number().default(0),
effectiveSegments: z.number().default(0),
totalSegments: z.number().default(0),
unknownSegments: z.number().default(0),
feedback: z.array(z.string()).default([]),
metrics: z.any().optional(),
segments: z.array(z.object({
actionType: z.string(),
isUnknown: z.boolean().default(false),
startMs: z.number(),
endMs: z.number(),
durationMs: z.number(),
confidenceAvg: z.number().optional(),
score: z.number().optional(),
peakScore: z.number().optional(),
frameCount: z.number().default(0),
issueSummary: z.array(z.string()).optional(),
keyFrames: z.array(z.number()).optional(),
clipLabel: z.string().optional(),
})).default([]),
videoId: z.number().optional(),
videoUrl: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const sessionId = await db.createLiveAnalysisSession({
userId: ctx.user.id,
title: input.title,
sessionMode: input.sessionMode,
status: "completed",
startedAt: new Date(input.startedAt),
endedAt: new Date(input.endedAt),
durationMs: input.durationMs,
dominantAction: input.dominantAction ?? "unknown",
overallScore: input.overallScore ?? null,
postureScore: input.postureScore ?? null,
balanceScore: input.balanceScore ?? null,
techniqueScore: input.techniqueScore ?? null,
footworkScore: input.footworkScore ?? null,
consistencyScore: input.consistencyScore ?? null,
unknownActionRatio: input.totalSegments > 0 ? input.unknownSegments / input.totalSegments : 0,
totalSegments: input.totalSegments,
effectiveSegments: input.effectiveSegments,
totalActionCount: input.totalActionCount,
videoId: input.videoId ?? null,
videoUrl: input.videoUrl ?? null,
summary: `${ACTION_LABELS[input.dominantAction ?? "unknown"] ?? input.dominantAction ?? "未知动作"} · ${input.effectiveSegments} 个有效片段`,
feedback: input.feedback,
metrics: input.metrics ?? null,
});
await db.createLiveActionSegments(input.segments.map((segment) => ({
sessionId,
actionType: segment.actionType,
isUnknown: segment.isUnknown ? 1 : 0,
startMs: segment.startMs,
endMs: segment.endMs,
durationMs: segment.durationMs,
confidenceAvg: segment.confidenceAvg ?? null,
score: segment.score ?? null,
peakScore: segment.peakScore ?? null,
frameCount: segment.frameCount,
issueSummary: segment.issueSummary ?? null,
keyFrames: segment.keyFrames ?? null,
clipLabel: segment.clipLabel ?? null,
})));
const syncResult = await syncLiveTrainingData({
userId: ctx.user.id,
sessionId,
title: input.title,
sessionMode: input.sessionMode,
dominantAction: input.dominantAction ?? "unknown",
durationMs: input.durationMs,
overallScore: input.overallScore ?? null,
effectiveSegments: input.effectiveSegments,
totalSegments: input.totalSegments,
unknownSegments: input.unknownSegments,
videoId: input.videoId ?? null,
});
return { sessionId, trainingRecordId: syncResult.recordId };
}),
liveSessionList: protectedProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(20) }).optional())
.query(async ({ ctx, input }) => {
return db.listLiveAnalysisSessions(ctx.user.id, input?.limit ?? 20);
}),
liveSessionGet: protectedProcedure
.input(z.object({ sessionId: z.number() }))
.query(async ({ ctx, input }) => {
const session = await db.getLiveAnalysisSessionById(input.sessionId);
if (!session || session.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND", message: "实时分析记录不存在" });
}
const segments = await db.getLiveActionSegmentsBySessionId(input.sessionId);
return { session, segments };
}),
// Generate AI correction suggestions
getCorrections: protectedProcedure
.input(z.object({
@@ -412,6 +550,8 @@ export const appRouter = router({
sessionId: z.string().min(1),
title: z.string().min(1).max(256),
exerciseType: z.string().optional(),
sessionMode: z.enum(["practice", "pk"]).default("practice"),
durationMinutes: z.number().min(1).max(720).optional(),
}))
.mutation(async ({ ctx, input }) => {
const session = await getRemoteMediaSession(input.sessionId);
@@ -476,11 +616,21 @@ export const appRouter = router({
// Rating system
rating: router({
history: protectedProcedure.query(async ({ ctx }) => {
return db.getUserRatingHistory(ctx.user.id);
return db.listNtrpSnapshots(ctx.user.id);
}),
current: protectedProcedure.query(async ({ ctx }) => {
const user = await db.getUserByOpenId(ctx.user.openId);
return { rating: user?.ntrpRating || 1.5 };
const latestSnapshot = await db.getLatestNtrpSnapshot(ctx.user.id);
return { rating: latestSnapshot?.rating || user?.ntrpRating || 1.5, latestSnapshot };
}),
refreshMine: protectedProcedure.mutation(async ({ ctx }) => {
return enqueueTask({
userId: ctx.user.id,
type: "ntrp_refresh_user",
title: "我的 NTRP 刷新",
message: "NTRP 刷新任务已加入后台队列",
payload: { targetUserId: ctx.user.id },
});
}),
}),
@@ -507,6 +657,15 @@ export const appRouter = router({
}),
}),
achievement: router({
list: protectedProcedure.query(async ({ ctx }) => {
return db.listUserAchievements(ctx.user.id);
}),
definitions: publicProcedure.query(async () => {
return db.listAchievementDefinitions();
}),
}),
// Badge system
badge: router({
list: protectedProcedure.query(async ({ ctx }) => {
@@ -531,6 +690,92 @@ export const appRouter = router({
}),
}),
admin: router({
users: adminProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(100) }).optional())
.query(async ({ input }) => db.listUsersForAdmin(input?.limit ?? 100)),
tasks: adminProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(100) }).optional())
.query(async ({ input }) => db.listAllBackgroundTasks(input?.limit ?? 100)),
liveSessions: adminProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(50) }).optional())
.query(async ({ input }) => db.listAdminLiveAnalysisSessions(input?.limit ?? 50)),
settings: adminProcedure.query(async () => db.listAppSettings()),
updateSetting: adminProcedure
.input(z.object({
settingKey: z.string().min(1),
value: z.any(),
}))
.mutation(async ({ ctx, input }) => {
await db.updateAppSetting(input.settingKey, input.value);
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "update_setting",
entityType: "app_setting",
entityId: input.settingKey,
payload: { value: input.value },
});
return { success: true };
}),
auditLogs: adminProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(100) }).optional())
.query(async ({ input }) => db.listAdminAuditLogs(input?.limit ?? 100)),
refreshUserNtrp: adminProcedure
.input(z.object({ userId: z.number() }))
.mutation(async ({ ctx, input }) => {
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "refresh_user_ntrp",
entityType: "user",
entityId: String(input.userId),
targetUserId: input.userId,
});
return enqueueTask({
userId: ctx.user.id,
type: "ntrp_refresh_user",
title: `用户 ${input.userId} NTRP 刷新`,
message: "用户 NTRP 刷新任务已加入后台队列",
payload: { targetUserId: input.userId },
});
}),
refreshAllNtrp: adminProcedure.mutation(async ({ ctx }) => {
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "refresh_all_ntrp",
entityType: "rating",
});
return enqueueTask({
userId: ctx.user.id,
type: "ntrp_refresh_all",
title: "全量 NTRP 刷新",
message: "全量 NTRP 刷新任务已加入后台队列",
payload: { source: "admin" },
});
}),
refreshUserNtrpNow: adminProcedure
.input(z.object({ userId: z.number() }))
.mutation(async ({ ctx, input }) => {
const snapshot = await refreshUserNtrp(input.userId, { triggerType: "manual" });
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "refresh_user_ntrp_now",
entityType: "user",
entityId: String(input.userId),
targetUserId: input.userId,
payload: snapshot,
});
return { snapshot };
}),
}),
// Leaderboard
leaderboard: router({
get: protectedProcedure

查看文件

@@ -17,6 +17,7 @@ import {
normalizeAdjustedPlanResponse,
normalizeTrainingPlanResponse,
} from "./trainingPlan";
import { refreshAllUsersNtrp, refreshUserNtrp, syncRecordingTrainingData } from "./trainingAutomation";
type TaskRow = Awaited<ReturnType<typeof db.getBackgroundTaskById>>;
@@ -419,6 +420,8 @@ async function runMediaFinalizeTask(task: NonNullable<TaskRow>) {
sessionId: string;
title: string;
exerciseType?: string;
sessionMode?: "practice" | "pk";
durationMinutes?: number;
};
const session = await getRemoteMediaSession(payload.sessionId);
@@ -489,6 +492,15 @@ async function runMediaFinalizeTask(task: NonNullable<TaskRow>) {
analysisStatus: "completed",
});
await syncRecordingTrainingData({
userId: task.userId,
videoId,
exerciseType: payload.exerciseType || "unknown",
title: payload.title || session.title,
sessionMode: payload.sessionMode || "practice",
durationMinutes: payload.durationMinutes ?? 5,
});
return {
kind: "media_finalize" as const,
sessionId: session.id,
@@ -499,6 +511,26 @@ async function runMediaFinalizeTask(task: NonNullable<TaskRow>) {
};
}
async function runNtrpRefreshUserTask(task: NonNullable<TaskRow>) {
const payload = task.payload as { targetUserId?: number };
const targetUserId = payload.targetUserId ?? task.userId;
const snapshot = await refreshUserNtrp(targetUserId, { triggerType: "manual", taskId: task.id });
return {
kind: "ntrp_refresh_user" as const,
targetUserId,
snapshot,
};
}
async function runNtrpRefreshAllTask(task: NonNullable<TaskRow>) {
const results = await refreshAllUsersNtrp({ triggerType: "daily", taskId: task.id });
return {
kind: "ntrp_refresh_all" as const,
refreshedUsers: results.length,
results,
};
}
export async function processBackgroundTask(task: NonNullable<TaskRow>) {
switch (task.type) {
case "training_plan_generate":
@@ -511,6 +543,10 @@ export async function processBackgroundTask(task: NonNullable<TaskRow>) {
return runMultimodalCorrectionTask(task);
case "media_finalize":
return runMediaFinalizeTask(task);
case "ntrp_refresh_user":
return runNtrpRefreshUserTask(task);
case "ntrp_refresh_all":
return runNtrpRefreshAllTask(task);
default:
throw new Error(`Unsupported task type: ${String(task.type)}`);
}

304
server/trainingAutomation.ts 普通文件
查看文件

@@ -0,0 +1,304 @@
import * as db from "./db";
export const ACTION_LABELS: Record<string, string> = {
forehand: "正手挥拍",
backhand: "反手挥拍",
serve: "发球",
volley: "截击",
overhead: "高压",
slice: "切削",
lob: "挑高球",
unknown: "未知动作",
};
function toMinutes(durationMs?: number | null) {
if (!durationMs || durationMs <= 0) return 1;
return Math.max(1, Math.round(durationMs / 60000));
}
function normalizeScore(value?: number | null) {
if (value == null || Number.isNaN(value)) return 0;
return Math.max(0, Math.min(100, value));
}
type NtrpTrigger = "analysis" | "daily" | "manual";
export async function refreshUserNtrp(userId: number, options: { triggerType: NtrpTrigger; taskId?: string | null }) {
const analyses = await db.getUserAnalyses(userId);
const aggregates = await db.listDailyTrainingAggregates(userId, 90);
const liveSessions = await db.listLiveAnalysisSessions(userId, 30);
const records = await db.getUserTrainingRecords(userId, 500);
const avgAnalysisScore = analyses.length > 0
? analyses.reduce((sum, item) => sum + (item.overallScore || 0), 0) / analyses.length
: 0;
const avgLiveScore = liveSessions.length > 0
? liveSessions.reduce((sum, item) => sum + (item.overallScore || 0), 0) / liveSessions.length
: 0;
const avgScore = avgAnalysisScore > 0 || avgLiveScore > 0
? ((avgAnalysisScore || 0) * 0.65 + (avgLiveScore || 0) * 0.35)
: 0;
const avgConsistency = analyses.length > 0
? analyses.reduce((sum, item) => sum + (item.strokeConsistency || 0), 0) / analyses.length
: liveSessions.length > 0
? liveSessions.reduce((sum, item) => sum + (item.consistencyScore || 0), 0) / liveSessions.length
: 0;
const avgFootwork = analyses.length > 0
? analyses.reduce((sum, item) => sum + (item.footworkScore || 0), 0) / analyses.length
: liveSessions.length > 0
? liveSessions.reduce((sum, item) => sum + (item.footworkScore || 0), 0) / liveSessions.length
: 0;
const avgFluidity = analyses.length > 0
? analyses.reduce((sum, item) => sum + (item.fluidityScore || 0), 0) / analyses.length
: liveSessions.length > 0
? liveSessions.reduce((sum, item) => sum + (item.techniqueScore || 0), 0) / liveSessions.length
: 0;
const totalMinutes = aggregates.reduce((sum, item) => sum + (item.totalMinutes || 0), 0);
const totalEffectiveActions = aggregates.reduce((sum, item) => sum + (item.effectiveActions || 0), 0);
const totalPk = aggregates.reduce((sum, item) => sum + (item.pkCount || 0), 0);
const activeDays = aggregates.filter(item => (item.sessionCount || 0) > 0).length;
const dimensions = {
poseAccuracy: normalizeScore(avgScore),
strokeConsistency: normalizeScore(avgConsistency),
footwork: normalizeScore(avgFootwork),
fluidity: normalizeScore(avgFluidity),
timing: normalizeScore(avgConsistency * 0.6 + avgScore * 0.4),
matchReadiness: normalizeScore(
Math.min(100, totalPk * 12) * 0.4 +
Math.min(100, activeDays * 3) * 0.3 +
Math.min(100, totalEffectiveActions / 5) * 0.3,
),
activityWeight: normalizeScore(Math.min(100, totalMinutes / 8 + activeDays * 2)),
};
const composite = (
dimensions.poseAccuracy * 0.22 +
dimensions.strokeConsistency * 0.18 +
dimensions.footwork * 0.16 +
dimensions.fluidity * 0.12 +
dimensions.timing * 0.12 +
dimensions.matchReadiness * 0.10 +
dimensions.activityWeight * 0.10
);
let ntrpRating: number;
if (composite <= 20) ntrpRating = 1.0 + (composite / 20) * 0.5;
else if (composite <= 40) ntrpRating = 1.5 + ((composite - 20) / 20) * 1.0;
else if (composite <= 60) ntrpRating = 2.5 + ((composite - 40) / 20) * 1.0;
else if (composite <= 80) ntrpRating = 3.5 + ((composite - 60) / 20) * 1.0;
else ntrpRating = 4.5 + ((composite - 80) / 20) * 0.5;
ntrpRating = Math.max(1.0, Math.min(5.0, Math.round(ntrpRating * 10) / 10));
const snapshotDate = db.getDateKey();
const snapshotKey = `${userId}:${snapshotDate}:${options.triggerType}`;
await db.createRatingEntry({
userId,
rating: ntrpRating,
reason: options.triggerType === "daily" ? "每日异步综合评分刷新" : "手动或分析触发综合评分刷新",
dimensionScores: dimensions,
analysisId: null,
});
await db.createNtrpSnapshot({
snapshotKey,
userId,
snapshotDate,
rating: ntrpRating,
triggerType: options.triggerType,
taskId: options.taskId ?? null,
dimensionScores: dimensions,
sourceSummary: {
analyses: analyses.length,
liveSessions: liveSessions.length,
records: records.length,
activeDays,
totalMinutes,
totalEffectiveActions,
totalPk,
},
});
await db.updateUserProfile(userId, { ntrpRating });
await db.refreshAchievementsForUser(userId);
return {
rating: ntrpRating,
dimensions,
snapshotDate,
};
}
export async function refreshAllUsersNtrp(options: { triggerType: NtrpTrigger; taskId?: string | null }) {
const userIds = await db.listUserIds();
const results = [];
for (const user of userIds) {
const snapshot = await refreshUserNtrp(user.id, options);
results.push({ userId: user.id, ...snapshot });
}
return results;
}
export async function syncAnalysisTrainingData(input: {
userId: number;
videoId: number;
exerciseType?: string | null;
overallScore?: number | null;
shotCount?: number | null;
framesAnalyzed?: number | null;
}) {
const trainingDate = db.getDateKey();
const planMatch = await db.matchActivePlanForExercise(input.userId, input.exerciseType);
const exerciseLabel = ACTION_LABELS[input.exerciseType || "unknown"] || input.exerciseType || "视频分析";
const recordResult = await db.upsertTrainingRecordBySource({
userId: input.userId,
planId: planMatch?.planId ?? null,
linkedPlanId: planMatch?.planId ?? null,
matchConfidence: planMatch?.confidence ?? null,
exerciseName: exerciseLabel,
exerciseType: input.exerciseType || "unknown",
sourceType: "analysis_upload",
sourceId: `analysis:${input.videoId}`,
videoId: input.videoId,
actionCount: input.shotCount ?? 0,
durationMinutes: Math.max(1, Math.round((input.framesAnalyzed || 0) / 60)),
completed: 1,
poseScore: input.overallScore ?? null,
trainingDate: new Date(),
metadata: {
source: "analysis_upload",
shotCount: input.shotCount ?? 0,
},
notes: "自动写入:视频分析",
});
if (recordResult.isNew) {
await db.upsertDailyTrainingAggregate({
userId: input.userId,
trainingDate,
deltaMinutes: Math.max(1, Math.round((input.framesAnalyzed || 0) / 60)),
deltaSessions: 1,
deltaAnalysisCount: 1,
deltaTotalActions: input.shotCount ?? 0,
deltaEffectiveActions: input.shotCount ?? 0,
score: input.overallScore ?? null,
metadata: { latestAnalysisExerciseType: input.exerciseType || "unknown" },
});
}
const unlocked = await db.refreshAchievementsForUser(input.userId);
await refreshUserNtrp(input.userId, { triggerType: "analysis" });
return { recordId: recordResult.recordId, unlocked };
}
export async function syncRecordingTrainingData(input: {
userId: number;
videoId: number;
exerciseType?: string | null;
title: string;
sessionMode?: "practice" | "pk";
durationMinutes?: number | null;
}) {
const trainingDate = db.getDateKey();
const planMatch = await db.matchActivePlanForExercise(input.userId, input.exerciseType);
const exerciseLabel = ACTION_LABELS[input.exerciseType || "unknown"] || input.exerciseType || input.title;
const recordResult = await db.upsertTrainingRecordBySource({
userId: input.userId,
planId: planMatch?.planId ?? null,
linkedPlanId: planMatch?.planId ?? null,
matchConfidence: planMatch?.confidence ?? null,
exerciseName: exerciseLabel,
exerciseType: input.exerciseType || "unknown",
sourceType: "recording",
sourceId: `recording:${input.videoId}`,
videoId: input.videoId,
actionCount: 0,
durationMinutes: Math.max(1, input.durationMinutes ?? 5),
completed: 1,
poseScore: null,
trainingDate: new Date(),
metadata: {
source: "recording",
sessionMode: input.sessionMode || "practice",
title: input.title,
},
notes: "自动写入:录制归档",
});
if (recordResult.isNew) {
await db.upsertDailyTrainingAggregate({
userId: input.userId,
trainingDate,
deltaMinutes: Math.max(1, input.durationMinutes ?? 5),
deltaSessions: 1,
deltaRecordingCount: 1,
deltaPkCount: input.sessionMode === "pk" ? 1 : 0,
metadata: { latestRecordingExerciseType: input.exerciseType || "unknown" },
});
}
const unlocked = await db.refreshAchievementsForUser(input.userId);
return { recordId: recordResult.recordId, unlocked };
}
export async function syncLiveTrainingData(input: {
userId: number;
sessionId: number;
title: string;
sessionMode: "practice" | "pk";
dominantAction?: string | null;
durationMs: number;
overallScore?: number | null;
effectiveSegments: number;
totalSegments: number;
unknownSegments: number;
videoId?: number | null;
}) {
const trainingDate = db.getDateKey();
const planMatch = await db.matchActivePlanForExercise(input.userId, input.dominantAction);
const exerciseLabel = ACTION_LABELS[input.dominantAction || "unknown"] || input.title;
const recordResult = await db.upsertTrainingRecordBySource({
userId: input.userId,
planId: planMatch?.planId ?? null,
linkedPlanId: planMatch?.planId ?? null,
matchConfidence: planMatch?.confidence ?? null,
exerciseName: exerciseLabel,
exerciseType: input.dominantAction || "unknown",
sourceType: "live_analysis",
sourceId: `live:${input.sessionId}`,
videoId: input.videoId ?? null,
actionCount: input.effectiveSegments,
durationMinutes: toMinutes(input.durationMs),
completed: 1,
poseScore: input.overallScore ?? null,
trainingDate: new Date(),
metadata: {
source: "live_analysis",
sessionMode: input.sessionMode,
totalSegments: input.totalSegments,
unknownSegments: input.unknownSegments,
},
notes: "自动写入:实时分析",
});
if (recordResult.isNew) {
await db.upsertDailyTrainingAggregate({
userId: input.userId,
trainingDate,
deltaMinutes: toMinutes(input.durationMs),
deltaSessions: 1,
deltaLiveAnalysisCount: 1,
deltaPkCount: input.sessionMode === "pk" ? 1 : 0,
deltaTotalActions: input.totalSegments,
deltaEffectiveActions: input.effectiveSegments,
deltaUnknownActions: input.unknownSegments,
score: input.overallScore ?? null,
metadata: { latestLiveDominantAction: input.dominantAction || "unknown" },
});
}
const unlocked = await db.refreshAchievementsForUser(input.userId);
await refreshUserNtrp(input.userId, { triggerType: "analysis" });
return { recordId: recordResult.recordId, unlocked };
}

查看文件

@@ -134,6 +134,58 @@ function buildStats(user: MockUser) {
shotCount: 18,
},
],
recentLiveSessions: [],
dailyTraining: [
{
trainingDate: "2026-03-13",
totalMinutes: 48,
sessionCount: 2,
effectiveActions: 36,
averageScore: 80,
},
{
trainingDate: "2026-03-14",
totalMinutes: 32,
sessionCount: 1,
effectiveActions: 18,
averageScore: 84,
},
],
achievements: [
{
key: "training_day_1",
name: "开练",
description: "完成首个训练日",
progressPct: 100,
unlocked: true,
},
{
key: "analyses_1",
name: "分析首秀",
description: "完成首个分析会话",
progressPct: 100,
unlocked: true,
},
{
key: "live_analysis_5",
name: "实时观察者",
description: "完成 5 次实时分析",
progressPct: 40,
unlocked: false,
},
],
latestNtrpSnapshot: {
rating: user.ntrpRating,
createdAt: nowIso(),
dimensionScores: {
poseAccuracy: 82,
strokeConsistency: 78,
footwork: 74,
fluidity: 79,
timing: 77,
matchReadiness: 70,
},
},
};
}
@@ -272,6 +324,10 @@ async function handleTrpc(route: Route, state: MockAppState) {
return trpcResult(state.videos);
case "analysis.list":
return trpcResult(state.analyses);
case "analysis.liveSessionList":
return trpcResult([]);
case "analysis.liveSessionSave":
return trpcResult({ sessionId: 1, trainingRecordId: 1 });
case "task.list":
return trpcResult(state.tasks);
case "task.get": {
@@ -369,6 +425,39 @@ async function handleTrpc(route: Route, state: MockAppState) {
];
}
return trpcResult({ videoId: state.nextVideoId, url: state.mediaSession?.playback.webmUrl });
case "achievement.list":
return trpcResult(buildStats(state.user).achievements);
case "rating.current":
return trpcResult({
rating: state.user.ntrpRating,
latestSnapshot: buildStats(state.user).latestNtrpSnapshot,
});
case "rating.history":
return trpcResult([
{
id: 1,
rating: 2.4,
triggerType: "daily",
createdAt: nowIso(),
dimensionScores: {
poseAccuracy: 72,
strokeConsistency: 70,
footwork: 66,
fluidity: 69,
timing: 68,
matchReadiness: 60,
},
sourceSummary: { analyses: 1, liveSessions: 0, totalEffectiveActions: 12, totalPk: 0, activeDays: 1 },
},
{
id: 2,
rating: state.user.ntrpRating,
triggerType: "daily",
createdAt: nowIso(),
dimensionScores: buildStats(state.user).latestNtrpSnapshot.dimensionScores,
sourceSummary: { analyses: 2, liveSessions: 1, totalEffectiveActions: 36, totalPk: 0, activeDays: 2 },
},
]);
default:
return trpcResult(null);
}