From edc66ea5bc9490dfd6d3f573479a0651d23d99b1 Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Sun, 15 Mar 2026 01:39:34 +0800 Subject: [PATCH] Implement live analysis achievements and admin console --- README.md | 25 +- client/src/App.tsx | 7 + client/src/components/DashboardLayout.tsx | 5 +- client/src/pages/AdminConsole.tsx | 316 +++++ client/src/pages/Checkin.tsx | 458 ++++--- client/src/pages/Dashboard.tsx | 357 +++-- client/src/pages/LiveCamera.tsx | 1486 +++++++++++++++------ client/src/pages/Rating.tsx | 399 +++--- client/src/pages/Recorder.tsx | 21 +- docs/FEATURES.md | 19 +- docs/developer-workflow.md | 14 +- docs/testing.md | 16 +- docs/verified-features.md | 13 +- drizzle/0007_grounded_live_ops.sql | 159 +++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 199 ++- server/_core/index.ts | 42 +- server/db.ts | 653 ++++++++- server/features.test.ts | 171 +++ server/routers.ts | 259 +++- server/taskWorker.ts | 36 + server/trainingAutomation.ts | 304 +++++ tests/e2e/helpers/mockApp.ts | 89 ++ 23 files changed, 4033 insertions(+), 1022 deletions(-) create mode 100644 client/src/pages/AdminConsole.tsx create mode 100644 drizzle/0007_grounded_live_ops.sql create mode 100644 server/trainingAutomation.ts diff --git a/README.md b/README.md index 3c6dd2f..35ea05d 100644 --- a/README.md +++ b/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 迁移 diff --git a/client/src/App.tsx b/client/src/App.tsx index 2852451..2b7ba0a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { + + + @@ -77,6 +81,9 @@ function Router() { + + + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index b7d1510..0c5b6c9 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -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 = [ diff --git a/client/src/pages/AdminConsole.tsx b/client/src/pages/AdminConsole.tsx new file mode 100644 index 0000000..94d78eb --- /dev/null +++ b/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>({}); + + 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 = {}; + (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 ( + + + 需要管理员权限 + 当前账号没有管理系统访问权限。 + + ); + } + + return ( +
+
+
+
+

管理系统

+

+ 这里集中查看用户、后台任务、实时分析记录、全局设置和审计日志。H1 管理员可以提交和执行用户级评分刷新。 +

+
+
+ +
+
+
+ +
+ + +
+ +
+
用户数
+
{totals.users}
+
+
+
+
+ + +
+ +
+
后台任务
+
{totals.tasks}
+
+
+
+
+ + +
+ +
+
实时分析会话
+
{totals.sessions}
+
+
+
+
+
+ + + + 用户 + 任务 + 会话 + 设置 + 审计 + + + + + + 用户列表 + 支持排队刷新和即时刷新单个用户的 NTRP。 + + + {(usersQuery.data || []).map((item: any) => ( +
+
+
+
+ {item.name} + {item.role} + NTRP {Number(item.ntrpRating || 1.5).toFixed(1)} +
+
+ 训练 {item.totalSessions || 0} 次 · {item.totalMinutes || 0} 分钟 · 连练 {item.currentStreak || 0} 天 +
+
+
+ + +
+
+
+ ))} +
+
+
+ + + + + 后台任务 + + + {(tasksQuery.data || []).map((task: any) => ( +
+
+
+
+ {task.title} + {task.type} + + {task.status} + +
+
+ {task.userName || task.userId} · {new Date(task.createdAt).toLocaleString("zh-CN")} +
+
+
+
+ {task.message || "无描述"} + {task.progress || 0}% +
+
+
+
+
+
+
+ ))} + + + + + + + + 实时分析会话 + + + {(liveSessionsQuery.data || []).map((session: any) => ( +
+
+
+
+ {session.title} + {session.userName || session.userId} + {session.sessionMode} +
+
+ 主动作 {session.dominantAction || "unknown"} · 有效片段 {session.effectiveSegments || 0}/{session.totalSegments || 0} +
+
+
+ {Math.round(session.overallScore || 0)} 分 · {Math.round((session.durationMs || 0) / 1000)} 秒 +
+
+
+ ))} +
+
+
+ + + + + + + 全局设置 + + 设置值以 JSON 形式保存,适合阈值、开关和结构化配置。 + + + {(settingsQuery.data || []).map((setting: any) => ( +
+
+
+
{setting.label}
+
{setting.description}
+ setSettingsDrafts((current) => ({ ...current, [setting.settingKey]: event.target.value }))} + className="mt-3 h-11 rounded-2xl" + /> +
+ +
+
+ ))} +
+
+
+ + + + + 审计日志 + + + {(auditQuery.data || []).map((item: any) => ( +
+
+
+
+ {item.actionType} + {item.entityType} + {item.targetUserId ? 目标用户 {item.targetUserId} : null} +
+
+ 管理员 {item.adminName || item.adminUserId} · {new Date(item.createdAt).toLocaleString("zh-CN")} +
+
+ {item.entityId ?
实体 {item.entityId}
: null} +
+
+ ))} +
+
+
+ +
+ ); +} diff --git a/client/src/pages/Checkin.tsx b/client/src/pages/Checkin.tsx index b929d2e..4aa14b2 100644 --- a/client/src/pages/Checkin.tsx +++ b/client/src/pages/Checkin.tsx @@ -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 = { - 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 = { + 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(); - (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 = {}; - (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(); + (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 (
- {[1, 2, 3].map(i => )} + + +
); } return (
-
-

训练打卡

-

坚持每日打卡,解锁成就徽章

-
- - {/* Check-in card */} - - -
-
- {alreadyCheckedIn ? ( - - ) : ( - - )} -
-
-

- {alreadyCheckedIn ? "今日已打卡 ✅" : "今日尚未打卡"} -

-

- {alreadyCheckedIn - ? `当前连续打卡 ${todayCheckin?.streakCount || (checkinHistory?.[0] as any)?.streakCount || 1} 天` - : "记录今天的训练,保持连续打卡!" - } -

- {!alreadyCheckedIn && ( -
-