Implement live analysis achievements and admin console
这个提交包含在:
25
README.md
25
README.md
@@ -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 = [
|
||||
|
||||
316
client/src/pages/AdminConsole.tsx
普通文件
316
client/src/pages/AdminConsole.tsx
普通文件
@@ -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,238 +1,288 @@
|
||||
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">
|
||||
<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} 天`
|
||||
: "记录今天的训练,保持连续打卡!"
|
||||
}
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calendar heatmap */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
打卡日历(近60天)
|
||||
</CardTitle>
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">成就系统</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
每次训练、录制、实时分析和综合评分都会自动累计进度,持续生成新的阶段目标与解锁记录。
|
||||
</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 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="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="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>
|
||||
</section>
|
||||
|
||||
{Object.entries(groupedBadges).map(([category, items]) => {
|
||||
const catInfo = categoryLabels[category] || { label: category, color: "bg-gray-100 text-gray-700" };
|
||||
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 className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.9fr)]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
训练热力图
|
||||
</CardTitle>
|
||||
<CardDescription>最近 35 天内,只要有训练、录制或分析写回,就会点亮对应日期。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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={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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold 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>
|
||||
<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" data-testid="dashboard-title">
|
||||
当前用户:{user?.name || "未命名用户"}
|
||||
</h1>
|
||||
<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 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>
|
||||
<div className="flex 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("/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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent analyses */}
|
||||
<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>
|
||||
<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)}
|
||||
</div>
|
||||
<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>
|
||||
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="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>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
</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 className="space-y-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">最近实时分析</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
<Button variant="outline" className="w-full gap-2" onClick={() => setLocation("/checkin")}>
|
||||
查看成就系统
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</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 = 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 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 trendData = useMemo(
|
||||
() => history.map((item: any) => ({
|
||||
date: new Date(item.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
|
||||
rating: item.rating,
|
||||
})).reverse(),
|
||||
[history],
|
||||
);
|
||||
|
||||
const trendData = (history || []).map((h: any) => ({
|
||||
date: new Date(h.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
|
||||
rating: h.rating,
|
||||
}));
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">NTRP评分系统</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">基于所有历史训练记录自动计算的综合评分</p>
|
||||
</div>
|
||||
<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-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>
|
||||
{(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 gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(320px,360px)]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Radar chart */}
|
||||
<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>
|
||||
</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">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
评分变化趋势
|
||||
</CardTitle>
|
||||
<CardDescription>NTRP评分随时间的变化</CardDescription>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">评分维度</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendData.length > 0 ? (
|
||||
<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>
|
||||
</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(#ratingGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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 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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={trendData}>
|
||||
<defs>
|
||||
<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="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis domain={[1, 5]} tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<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>
|
||||
|
||||
{/* NTRP level reference */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">NTRP等级参考</CardTitle>
|
||||
<CardDescription>美国网球协会(USTA)标准评级体系</CardDescription>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">历史快照</CardTitle>
|
||||
<CardDescription>这里展示异步评分任务生成的最新记录。</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>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{l.label}</span>
|
||||
<p className="text-xs text-muted-foreground">{l.desc}</p>
|
||||
<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>
|
||||
) : (
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
{currentRating >= l.min && currentRating < l.max && (
|
||||
<Badge variant="default" className="ml-auto shrink-0">当前等级</Badge>
|
||||
)}
|
||||
</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 和自动化测试不受影响
|
||||
|
||||
## 当前未纳入自动验证的内容
|
||||
|
||||
|
||||
159
drizzle/0007_grounded_live_ops.sql
普通文件
159
drizzle/0007_grounded_live_ops.sql
普通文件
@@ -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);
|
||||
|
||||
653
server/db.ts
653
server/db.ts
@@ -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
普通文件
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);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户