diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..068ac5a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,27 @@
+# AGENTS
+
+## Update Discipline
+
+- Every shipped feature change must be recorded in the in-app update log page at `/changelog`.
+- Every shipped feature change must also be recorded in [docs/CHANGELOG.md](/root/auto/tennis/docs/CHANGELOG.md).
+- When online smoke tests are run, record whether the public site is already serving the new build or still on an older asset revision.
+- Each update log entry must include:
+ - release date
+ - feature summary
+ - tested modules or commands
+ - corresponding repository version identifier
+ - prefer the git short commit hash
+- After implementation, run the relevant tests before pushing.
+- Only record an entry as shipped after the related tests pass.
+- When a feature is deployed successfully, append the update entry before or together with the repository submission so the changelog stays in sync with the codebase.
+
+## Session Policy
+
+- Username login must support multiple active sessions across multiple devices.
+- New logins must not invalidate prior valid sessions for the same user.
+- Session validation should be tolerant of older token payloads where optional display fields are absent.
+
+## Timezone Policy
+
+- User-facing time displays should use `Asia/Shanghai`.
+- Daily aggregation keys and schedule-related server calculations should also use `Asia/Shanghai`.
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 2b7ba0a..497067b 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -22,6 +22,7 @@ import Reminders from "./pages/Reminders";
import VisionLab from "./pages/VisionLab";
import Logs from "./pages/Logs";
import AdminConsole from "./pages/AdminConsole";
+import ChangeLog from "./pages/ChangeLog";
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
return (
@@ -78,6 +79,9 @@ function Router() {
+
+
+
diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx
index 0c5b6c9..b5a3afb 100644
--- a/client/src/components/DashboardLayout.tsx
+++ b/client/src/components/DashboardLayout.tsx
@@ -51,6 +51,7 @@ const menuItems: MenuItem[] = [
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
+ { icon: ScrollText, label: "更新日志", path: "/changelog", 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 },
diff --git a/client/src/components/TaskCenter.tsx b/client/src/components/TaskCenter.tsx
index bce25c9..352c41e 100644
--- a/client/src/components/TaskCenter.tsx
+++ b/client/src/components/TaskCenter.tsx
@@ -6,6 +6,7 @@ import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
+import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { AlertTriangle, BellRing, CheckCircle2, Loader2, RefreshCcw } from "lucide-react";
@@ -147,7 +148,7 @@ export function TaskCenter({ compact = false }: { compact?: boolean }) {
- {new Date(task.createdAt).toLocaleString("zh-CN")} · 耗时 {formatTaskTiming(task)}
+ {formatDateTimeShanghai(task.createdAt)} · 耗时 {formatTaskTiming(task)}
{task.status === "failed" ? (
= {
+ forehand: "正手挥拍",
+ backhand: "反手挥拍",
+ serve: "发球",
+ volley: "截击",
+ overhead: "高压",
+ slice: "切削",
+ lob: "挑高球",
+ unknown: "未知动作",
+};
+
+function clamp(value: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, value));
+}
+
+function distance(a?: Point, b?: Point) {
+ if (!a || !b) return 0;
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return Math.sqrt(dx * dx + dy * dy);
+}
+
+function getAngle(a?: Point, b?: Point, c?: Point) {
+ if (!a || !b || !c) return 0;
+ const radians = Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(a.y - b.y, a.x - b.x);
+ let angle = Math.abs((radians * 180) / Math.PI);
+ if (angle > 180) angle = 360 - angle;
+ return angle;
+}
+
+export function recognizeActionFrame(landmarks: Point[], tracking: TrackingState, timestamp: number): ActionFrame {
+ const nose = landmarks[0];
+ const leftShoulder = landmarks[11];
+ const rightShoulder = landmarks[12];
+ const leftElbow = landmarks[13];
+ const rightElbow = landmarks[14];
+ const leftWrist = landmarks[15];
+ const rightWrist = landmarks[16];
+ const leftHip = landmarks[23];
+ const rightHip = landmarks[24];
+ const leftKnee = landmarks[25];
+ const rightKnee = landmarks[26];
+ const leftAnkle = landmarks[27];
+ const rightAnkle = landmarks[28];
+
+ const hipCenter = {
+ x: ((leftHip?.x ?? 0.5) + (rightHip?.x ?? 0.5)) / 2,
+ y: ((leftHip?.y ?? 0.7) + (rightHip?.y ?? 0.7)) / 2,
+ };
+
+ const dtMs = tracking.prevTimestamp ? Math.max(16, timestamp - tracking.prevTimestamp) : 33;
+ const rightSpeed = distance(rightWrist, tracking.prevRightWrist) * (1000 / dtMs);
+ const leftSpeed = distance(leftWrist, tracking.prevLeftWrist) * (1000 / dtMs);
+ const hipSpeed = distance(hipCenter, tracking.prevHipCenter) * (1000 / dtMs);
+ const rightVerticalMotion = tracking.prevRightWrist ? tracking.prevRightWrist.y - (rightWrist?.y ?? tracking.prevRightWrist.y) : 0;
+
+ const shoulderTilt = Math.abs((leftShoulder?.y ?? 0.3) - (rightShoulder?.y ?? 0.3));
+ const hipTilt = Math.abs((leftHip?.y ?? 0.55) - (rightHip?.y ?? 0.55));
+ const headOffset = Math.abs((nose?.x ?? 0.5) - (((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2));
+ const kneeBend = ((getAngle(leftHip, leftKnee, leftAnkle) || 165) + (getAngle(rightHip, rightKnee, rightAnkle) || 165)) / 2;
+ const rightElbowAngle = getAngle(rightShoulder, rightElbow, rightWrist) || 145;
+ const leftElbowAngle = getAngle(leftShoulder, leftElbow, leftWrist) || 145;
+ const footSpread = Math.abs((leftAnkle?.x ?? 0.42) - (rightAnkle?.x ?? 0.58));
+ const shoulderSpan = Math.abs((rightShoulder?.x ?? 0.56) - (leftShoulder?.x ?? 0.44));
+ const wristSpread = Math.abs((rightWrist?.x ?? 0.62) - (leftWrist?.x ?? 0.38));
+ const shoulderCenterX = ((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2;
+ const torsoOffset = Math.abs(shoulderCenterX - hipCenter.x);
+ const rightForward = (rightWrist?.x ?? shoulderCenterX) - hipCenter.x;
+ const leftForward = hipCenter.x - (leftWrist?.x ?? shoulderCenterX);
+ const contactHeight = hipCenter.y - (rightWrist?.y ?? hipCenter.y);
+ const landmarkVisibility = landmarks
+ .filter((item) => typeof item?.visibility === "number")
+ .map((item) => item.visibility as number);
+ const averageVisibility = landmarkVisibility.length > 0
+ ? landmarkVisibility.reduce((sum, item) => sum + item, 0) / landmarkVisibility.length
+ : 0.8;
+
+ tracking.prevTimestamp = timestamp;
+ tracking.prevRightWrist = rightWrist;
+ tracking.prevLeftWrist = leftWrist;
+ tracking.prevHipCenter = hipCenter;
+
+ if (averageVisibility < 0.58 || shoulderSpan < 0.08 || footSpread < 0.05 || headOffset > 0.26) {
+ return { action: "unknown", confidence: 0.28 };
+ }
+
+ const serveConfidence = clamp(
+ rightVerticalMotion * 2.2 +
+ Math.max(0, (hipCenter.y - (rightWrist?.y ?? hipCenter.y)) * 3.4) +
+ (rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.34 : 0 +
+ rightElbowAngle > 145 ? 0.12 : 0 -
+ shoulderTilt * 1.8,
+ 0,
+ 1,
+ );
+
+ const overheadConfidence = clamp(
+ serveConfidence * 0.62 +
+ ((rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.22 : 0) +
+ (rightSpeed > 0.34 ? 0.16 : 0) -
+ (kneeBend < 150 ? 0.08 : 0),
+ 0,
+ 1,
+ );
+
+ const forehandConfidence = clamp(
+ (rightSpeed * 1.5) +
+ Math.max(0, rightForward * 2.3) +
+ (rightElbowAngle > 120 ? 0.1 : 0) +
+ (hipSpeed > 0.07 ? 0.08 : 0) +
+ (footSpread > 0.12 ? 0.05 : 0) -
+ shoulderTilt * 1.1,
+ 0,
+ 1,
+ );
+
+ const backhandConfidence = clamp(
+ (leftSpeed * 1.45) +
+ Math.max(0, leftForward * 2.15) +
+ (leftElbowAngle > 118 ? 0.1 : 0) +
+ (wristSpread > shoulderSpan * 1.2 ? 0.08 : 0) +
+ (torsoOffset > 0.04 ? 0.06 : 0),
+ 0,
+ 1,
+ );
+
+ const volleyConfidence = clamp(
+ ((rightSpeed + leftSpeed) * 0.8) +
+ (footSpread < 0.12 ? 0.12 : 0) +
+ (kneeBend < 155 ? 0.12 : 0) +
+ (Math.abs(contactHeight) < 0.16 ? 0.1 : 0) +
+ (hipSpeed > 0.08 ? 0.08 : 0),
+ 0,
+ 1,
+ );
+
+ const sliceConfidence = clamp(
+ forehandConfidence * 0.68 +
+ ((rightWrist?.y ?? 0.5) > hipCenter.y ? 0.12 : 0) +
+ (contactHeight < 0.05 ? 0.1 : 0),
+ 0,
+ 1,
+ );
+
+ const lobConfidence = clamp(
+ overheadConfidence * 0.55 +
+ ((rightWrist?.y ?? 1) < (leftShoulder?.y ?? 0.3) ? 0.14 : 0) +
+ (hipSpeed < 0.08 ? 0.06 : 0),
+ 0,
+ 1,
+ );
+
+ const candidates = ([
+ ["serve", serveConfidence],
+ ["overhead", overheadConfidence],
+ ["forehand", forehandConfidence],
+ ["backhand", backhandConfidence],
+ ["volley", volleyConfidence],
+ ["slice", sliceConfidence],
+ ["lob", lobConfidence],
+ ] as Array<[ActionType, number]>).sort((left, right) => right[1] - left[1]);
+
+ const [action, confidence] = candidates[0] || ["unknown", 0];
+ if (confidence < 0.45) {
+ return { action: "unknown", confidence: clamp(confidence, 0.18, 0.42) };
+ }
+
+ return { action, confidence: clamp(confidence, 0, 1) };
+}
+
+export function stabilizeActionFrame(frame: ActionFrame, history: ActionObservation[]) {
+ const nextHistory = [...history, { action: frame.action, confidence: frame.confidence }].slice(-6);
+ history.splice(0, history.length, ...nextHistory);
+
+ const weights = nextHistory.map((_, index) => index + 1);
+ const scores = nextHistory.reduce>((acc, sample, index) => {
+ acc[sample.action] = (acc[sample.action] || 0) + sample.confidence * weights[index];
+ return acc;
+ }, {
+ forehand: 0,
+ backhand: 0,
+ serve: 0,
+ volley: 0,
+ overhead: 0,
+ slice: 0,
+ lob: 0,
+ unknown: 0,
+ });
+
+ const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]) as Array<[ActionType, number]>;
+ const [winner = "unknown", winnerScore = 0] = ranked[0] || [];
+ const [, runnerScore = 0] = ranked[1] || [];
+ const winnerSamples = nextHistory.filter((sample) => sample.action === winner);
+ const averageConfidence = winnerSamples.length > 0
+ ? winnerSamples.reduce((sum, sample) => sum + sample.confidence, 0) / winnerSamples.length
+ : frame.confidence;
+
+ const stableAction =
+ winner === "unknown" && frame.action !== "unknown" && frame.confidence >= 0.52
+ ? frame.action
+ : winnerScore - runnerScore < 0.2 && frame.confidence >= 0.65
+ ? frame.action
+ : winner;
+
+ return {
+ action: stableAction,
+ confidence: clamp(stableAction === frame.action ? Math.max(frame.confidence, averageConfidence) : averageConfidence, 0, 1),
+ };
+}
diff --git a/client/src/lib/changelog.ts b/client/src/lib/changelog.ts
new file mode 100644
index 0000000..a132544
--- /dev/null
+++ b/client/src/lib/changelog.ts
@@ -0,0 +1,80 @@
+export type ChangeLogEntry = {
+ version: string;
+ releaseDate: string;
+ repoVersion: string;
+ summary: string;
+ features: string[];
+ tests: string[];
+};
+
+export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
+ {
+ version: "2026.03.15-session-changelog",
+ releaseDate: "2026-03-15",
+ repoVersion: "pending-commit",
+ summary: "多端 session、更新日志页面、录制动作摘要与上海时区显示同步收口。",
+ features: [
+ "用户名登录生成独立 sid,同一账号多端登录保持并行有效",
+ "新增 /changelog 页面和侧边栏入口,展示版本、仓库版本和验证记录",
+ "训练进度页可展开查看最近训练记录的具体时间、动作统计和录制有效性",
+ "录制页增加动作抽样摘要、无效录制标记与 media 预归档状态",
+ "Dashboard、任务中心、管理台、评分、日志、视觉测试、视频库等页面统一使用 Asia/Shanghai 时间显示",
+ ],
+ tests: [
+ "pnpm check",
+ "pnpm test",
+ "pnpm test:go",
+ "pnpm build",
+ "Playwright smoke: https://te.hao.work/ 双上下文登录 H1 后 dashboard 均保持有效;线上 /changelog 仍显示旧构建,待部署后复测",
+ ],
+ },
+ {
+ version: "2026.03.15-recorder-zoom",
+ releaseDate: "2026-03-15",
+ repoVersion: "c4ec397",
+ summary: "补齐录制页与实时分析页的节省流量模式、镜头缩放和移动端控制。",
+ features: [
+ "在线录制默认切换为节省流量模式",
+ "在线录制支持镜头焦距放大缩小",
+ "实时分析支持镜头焦距放大缩小",
+ "页面内增加拍摄与流量设置说明",
+ ],
+ tests: [
+ "pnpm check",
+ "pnpm exec vitest run client/src/lib/media.test.ts client/src/lib/camera.test.ts",
+ "Playwright 真实站点检查 /live-camera 与 /recorder 新控件可见",
+ ],
+ },
+ {
+ version: "2026.03.15-videos-crud",
+ releaseDate: "2026-03-15",
+ repoVersion: "bd89981",
+ summary: "视频库支持新增、编辑、删除训练视频记录。",
+ features: [
+ "视频库新增外部视频登记",
+ "视频库支持编辑标题和动作类型",
+ "视频库支持删除视频及关联分析引用",
+ "视频详情读取按当前用户权限收敛",
+ ],
+ tests: [
+ "pnpm check",
+ "pnpm exec vitest run server/features.test.ts -t \"video\\\\.\"",
+ "Playwright 真实站点完成 /videos 新增-编辑-删除全链路",
+ ],
+ },
+ {
+ version: "v3.0.0",
+ releaseDate: "2026-03-14",
+ repoVersion: "历史版本",
+ summary: "教程库、提醒、通知等学习能力上线。",
+ features: [
+ "训练视频教程库",
+ "教程自评与学习进度",
+ "训练提醒通知",
+ "通知历史管理",
+ ],
+ tests: [
+ "教程库、提醒、通知相关测试通过",
+ ],
+ },
+];
diff --git a/client/src/lib/media.ts b/client/src/lib/media.ts
index 6e17747..e2edf16 100644
--- a/client/src/lib/media.ts
+++ b/client/src/lib/media.ts
@@ -14,6 +14,12 @@ export type ArchiveStatus =
| "completed"
| "failed";
+export type PreviewStatus =
+ | "idle"
+ | "processing"
+ | "ready"
+ | "failed";
+
export type MediaMarker = {
id: string;
type: string;
@@ -29,6 +35,7 @@ export type MediaSession = {
title: string;
status: MediaSessionStatus;
archiveStatus: ArchiveStatus;
+ previewStatus: PreviewStatus;
format: string;
mimeType: string;
qualityPreset: string;
@@ -37,8 +44,10 @@ export type MediaSession = {
reconnectCount: number;
uploadedSegments: number;
uploadedBytes: number;
+ previewSegments: number;
durationMs: number;
lastError?: string;
+ previewUpdatedAt?: string;
streamConnected: boolean;
lastStreamAt?: string;
playback: {
diff --git a/client/src/lib/time.ts b/client/src/lib/time.ts
new file mode 100644
index 0000000..7c95c11
--- /dev/null
+++ b/client/src/lib/time.ts
@@ -0,0 +1,57 @@
+const APP_TIME_ZONE = "Asia/Shanghai";
+
+type DateLike = string | number | Date | null | undefined;
+
+function toDate(value: DateLike) {
+ if (value == null) return null;
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return null;
+ return date;
+}
+
+export function formatDateTimeShanghai(
+ value: DateLike,
+ options?: Intl.DateTimeFormatOptions,
+) {
+ const date = toDate(value);
+ if (!date) return "";
+ return date.toLocaleString("zh-CN", {
+ timeZone: APP_TIME_ZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: options?.second,
+ ...options,
+ });
+}
+
+export function formatDateShanghai(
+ value: DateLike,
+ options?: Intl.DateTimeFormatOptions,
+) {
+ const date = toDate(value);
+ if (!date) return "";
+ return date.toLocaleDateString("zh-CN", {
+ timeZone: APP_TIME_ZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ ...options,
+ });
+}
+
+export function formatMonthDayShanghai(value: DateLike) {
+ const date = toDate(value);
+ if (!date) return "";
+ return date.toLocaleDateString("zh-CN", {
+ timeZone: APP_TIME_ZONE,
+ month: "short",
+ day: "numeric",
+ });
+}
+
+export function getAppTimeZoneLabel() {
+ return APP_TIME_ZONE;
+}
diff --git a/client/src/pages/AdminConsole.tsx b/client/src/pages/AdminConsole.tsx
index 94d78eb..8779559 100644
--- a/client/src/pages/AdminConsole.tsx
+++ b/client/src/pages/AdminConsole.tsx
@@ -7,6 +7,7 @@ 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 { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react";
@@ -193,7 +194,7 @@ export default function AdminConsole() {
- {task.userName || task.userId} · {new Date(task.createdAt).toLocaleString("zh-CN")}
+ {task.userName || task.userId} · {formatDateTimeShanghai(task.createdAt)}
@@ -300,7 +301,7 @@ export default function AdminConsole() {
{item.targetUserId ? 目标用户 {item.targetUserId} : null}
- 管理员 {item.adminName || item.adminUserId} · {new Date(item.createdAt).toLocaleString("zh-CN")}
+ 管理员 {item.adminName || item.adminUserId} · {formatDateTimeShanghai(item.createdAt)}
{item.entityId ? 实体 {item.entityId}
: null}
diff --git a/client/src/pages/ChangeLog.tsx b/client/src/pages/ChangeLog.tsx
new file mode 100644
index 0000000..15f68a8
--- /dev/null
+++ b/client/src/pages/ChangeLog.tsx
@@ -0,0 +1,66 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { CHANGE_LOG_ENTRIES } from "@/lib/changelog";
+import { formatDateShanghai } from "@/lib/time";
+import { GitBranch, ListChecks, ScrollText } from "lucide-react";
+
+export default function ChangeLog() {
+ return (
+
+
+
+
+
+
+
+
更新日志
+
+ 这里会按版本记录已上线的新功能、对应仓库版本和验证结果。后续每次改动测试通过并提交后,都会继续追加到这里。
+
+
+
+
+
+
+ {CHANGE_LOG_ENTRIES.map((entry) => (
+
+
+
+
+ {entry.version}
+ {entry.summary}
+
+
+ {formatDateShanghai(entry.releaseDate)}
+
+
+ {entry.repoVersion}
+
+
+
+
+
+
+
上线内容
+
+ {entry.features.map((feature) => (
+ {feature}
+ ))}
+
+
+
+
+
+ 验证记录
+
+
+ {entry.tests.map((item) => {item} )}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/pages/Checkin.tsx b/client/src/pages/Checkin.tsx
index 4aa14b2..f4f55a6 100644
--- a/client/src/pages/Checkin.tsx
+++ b/client/src/pages/Checkin.tsx
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
+import { formatDateShanghai } from "@/lib/time";
import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-react";
const CATEGORY_META: Record = {
@@ -17,11 +18,12 @@ const CATEGORY_META: Record = {
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" },
+ tutorial: { label: "教程路径", tone: "bg-violet-500/10 text-violet-700" },
};
function getProgressText(item: any) {
if (item.unlockedAt) {
- return `已于 ${new Date(item.unlockedAt).toLocaleDateString("zh-CN")} 解锁`;
+ return `已于 ${formatDateShanghai(item.unlockedAt)} 解锁`;
}
return `${Math.round(item.currentValue || 0)} / ${Math.round(item.targetValue || 0)}`;
}
diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx
index 4cd4fbc..5a7de01 100644
--- a/client/src/pages/Dashboard.tsx
+++ b/client/src/pages/Dashboard.tsx
@@ -6,6 +6,7 @@ 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 { formatDateTimeShanghai } from "@/lib/time";
import { Activity, Award, ChevronRight, Clock3, Sparkles, Swords, Target, Video } from "lucide-react";
import { useLocation } from "wouter";
@@ -199,7 +200,7 @@ export default function Dashboard() {
{session.title}
-
{new Date(session.createdAt).toLocaleString("zh-CN")}
+
{formatDateTimeShanghai(session.createdAt)}
{Math.round(session.overallScore || 0)} 分
diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx
index 1546a9c..2b51bb7 100644
--- a/client/src/pages/LiveCamera.tsx
+++ b/client/src/pages/LiveCamera.tsx
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Progress } from "@/components/ui/progress";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
+import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { applyTrackZoom, type CameraQualityPreset, getCameraVideoConstraints, getLiveAnalysisBitrate, readTrackZoomState } from "@/lib/camera";
import {
@@ -910,7 +911,10 @@ export default function LiveCamera() {
const format = recorderMimeTypeRef.current.includes("mp4") ? "mp4" : "webm";
const fileBase64 = await blobToBase64(recordedBlob);
uploadedVideo = await uploadMutation.mutateAsync({
- title: `实时分析 ${new Date().toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}`,
+ title: `实时分析 ${formatDateTimeShanghai(new Date(), {
+ year: undefined,
+ second: undefined,
+ })}`,
format,
fileSize: recordedBlob.size,
exerciseType: dominantAction,
@@ -1605,7 +1609,7 @@ export default function LiveCamera() {
{session.title}
- {new Date(session.createdAt).toLocaleString("zh-CN")}
+ {formatDateTimeShanghai(session.createdAt)}
diff --git a/client/src/pages/Logs.tsx b/client/src/pages/Logs.tsx
index b39e148..dbc5893 100644
--- a/client/src/pages/Logs.tsx
+++ b/client/src/pages/Logs.tsx
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
+import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { AlertTriangle, BellRing, CheckCircle2, ClipboardList, Loader2, RefreshCcw } from "lucide-react";
@@ -155,7 +156,7 @@ export default function Logs() {
{task.title}
- {new Date(task.createdAt).toLocaleString("zh-CN")} · {task.type}
+ {formatDateTimeShanghai(task.createdAt)} · {task.type}
@@ -233,7 +234,7 @@ export default function Logs() {
{item.title}
- {new Date(item.createdAt).toLocaleString("zh-CN")} · {item.notificationType}
+ {formatDateTimeShanghai(item.createdAt)} · {item.notificationType}
diff --git a/client/src/pages/Progress.tsx b/client/src/pages/Progress.tsx
index f07746e..8c109ac 100644
--- a/client/src/pages/Progress.tsx
+++ b/client/src/pages/Progress.tsx
@@ -4,7 +4,9 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
-import { Activity, Calendar, CheckCircle2, Clock, TrendingUp, Target, Sparkles } from "lucide-react";
+import { Activity, Calendar, CheckCircle2, ChevronDown, ChevronUp, Clock, TrendingUp, Target, Sparkles } from "lucide-react";
+import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time";
+import { useState } from "react";
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
LineChart, Line, Legend
@@ -17,6 +19,7 @@ export default function Progress() {
const { data: analyses } = trpc.analysis.list.useQuery();
const { data: stats } = trpc.profile.stats.useQuery();
const [, setLocation] = useLocation();
+ const [expandedRecordId, setExpandedRecordId] = useState(null);
if (isLoading) {
return (
@@ -29,7 +32,7 @@ export default function Progress() {
// Aggregate data by date for charts
const dateMap = new Map();
(records || []).forEach((r: any) => {
- const date = new Date(r.trainingDate || r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
+ const date = formatMonthDayShanghai(r.trainingDate || r.createdAt);
const existing = dateMap.get(date) || { date, sessions: 0, minutes: 0, avgScore: 0, scores: [] };
existing.sessions++;
existing.minutes += r.durationMinutes || 0;
@@ -44,7 +47,7 @@ export default function Progress() {
// Analysis score trend
const scoreTrend = (analyses || []).map((a: any) => ({
- date: new Date(a.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
+ date: formatMonthDayShanghai(a.createdAt),
overall: Math.round(a.overallScore || 0),
consistency: Math.round(a.strokeConsistency || 0),
footwork: Math.round(a.footworkScore || 0),
@@ -179,32 +182,104 @@ export default function Progress() {
{(records?.length || 0) > 0 ? (
{(records || []).slice(0, 20).map((record: any) => (
-
-
-
- {record.completed ?
:
}
+
+
+
+
+ {record.completed ?
:
}
+
+
+
{record.exerciseName}
+
+ {formatDateTimeShanghai(record.trainingDate || record.createdAt)}
+ {record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
+ {record.sourceType ? ` · ${record.sourceType}` : ""}
+
+ {record.actionCount ? (
+
+ 动作数 {record.actionCount}
+
+ ) : null}
+
-
-
{record.exerciseName}
-
- {new Date(record.trainingDate || record.createdAt).toLocaleDateString("zh-CN")}
- {record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
- {record.sourceType ? ` · ${record.sourceType}` : ""}
-
+
+ {record.poseScore && (
+ {Math.round(record.poseScore)}分
+ )}
+ {record.completed ? (
+ 已完成
+ ) : (
+ 进行中
+ )}
+ setExpandedRecordId((current) => current === record.id ? null : record.id)}
+ >
+ {expandedRecordId === record.id ? : }
+
-
- {record.poseScore && (
- {Math.round(record.poseScore)}分
- )}
- {record.completed ? (
- 已完成
- ) : (
- 进行中
- )}
-
+
+ {expandedRecordId === record.id ? (
+
+
+
+
记录时间
+
{formatDateTimeShanghai(record.trainingDate || record.createdAt, { second: "2-digit" })}
+
+
+
动作数据
+
动作数 {record.actionCount || 0}
+
+
+
+ {record.metadata ? (
+
+ {record.metadata.dominantAction ? (
+
+
主动作
+
{String(record.metadata.dominantAction)}
+
+ ) : null}
+
+ {record.metadata.actionSummary && Object.keys(record.metadata.actionSummary).length > 0 ? (
+
+
动作明细
+
+ {Object.entries(record.metadata.actionSummary as Record)
+ .filter(([, count]) => Number(count) > 0)
+ .map(([actionType, count]) => (
+
+ {actionType} {count} 次
+
+ ))}
+
+
+ ) : null}
+
+ {record.metadata.validityStatus ? (
+
+
录制有效性
+
{String(record.metadata.validityStatus)}
+ {record.metadata.invalidReason ? (
+
{String(record.metadata.invalidReason)}
+ ) : null}
+
+ ) : null}
+
+ {record.notes ? (
+
+ ) : null}
+
+ ) : null}
+
+ ) : null}
))}
diff --git a/client/src/pages/Rating.tsx b/client/src/pages/Rating.tsx
index 939dd3f..890209d 100644
--- a/client/src/pages/Rating.tsx
+++ b/client/src/pages/Rating.tsx
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
+import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time";
import { toast } from "sonner";
import { Activity, Award, Loader2, RefreshCw, Radar, TrendingUp } from "lucide-react";
import {
@@ -69,7 +70,7 @@ export default function Rating() {
const trendData = useMemo(
() => history.map((item: any) => ({
- date: new Date(item.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
+ date: formatMonthDayShanghai(item.createdAt),
rating: item.rating,
})).reverse(),
[history],
@@ -131,7 +132,7 @@ export default function Rating() {
{latestSnapshot?.triggerType ?
来源 {latestSnapshot.triggerType} : null}
{latestSnapshot?.createdAt ? (
- 刷新于 {new Date(latestSnapshot.createdAt).toLocaleString("zh-CN")}
+ 刷新于 {formatDateTimeShanghai(latestSnapshot.createdAt)}
) : null}
@@ -252,7 +253,7 @@ export default function Rating() {
NTRP {Number(item.rating || 0).toFixed(1)}
{item.triggerType}
-
{new Date(item.createdAt).toLocaleString("zh-CN")}
+
{formatDateTimeShanghai(item.createdAt)}
diff --git a/client/src/pages/Recorder.tsx b/client/src/pages/Recorder.tsx
index 370fbc2..b91a092 100644
--- a/client/src/pages/Recorder.tsx
+++ b/client/src/pages/Recorder.tsx
@@ -23,7 +23,16 @@ import { Slider } from "@/components/ui/slider";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { toast } from "sonner";
+import {
+ ACTION_LABELS as RECOGNIZED_ACTION_LABELS,
+ type ActionObservation,
+ type ActionType,
+ type TrackingState,
+ recognizeActionFrame,
+ stabilizeActionFrame,
+} from "@/lib/actionRecognition";
import { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera";
+import { formatDateTimeShanghai } from "@/lib/time";
import {
Activity,
Camera,
@@ -68,6 +77,8 @@ const SEGMENT_LENGTH_MS = 60_000;
const MOTION_SAMPLE_MS = 1_500;
const MOTION_THRESHOLD = 18;
const MOTION_COOLDOWN_MS = 8_000;
+const ACTION_SAMPLE_MS = 2_500;
+const INVALID_RECORDING_WINDOW_MS = 60_000;
const QUALITY_PRESETS = {
economy: {
@@ -151,6 +162,30 @@ function formatFileSize(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(bytes > 20 * 1024 * 1024 ? 1 : 2)} MB`;
}
+function createActionSummary(): Record {
+ return {
+ forehand: 0,
+ backhand: 0,
+ serve: 0,
+ volley: 0,
+ overhead: 0,
+ slice: 0,
+ lob: 0,
+ unknown: 0,
+ };
+}
+
+function summarizeActions(actionSummary: Record) {
+ return Object.entries(actionSummary)
+ .filter(([actionType, count]) => actionType !== "unknown" && count > 0)
+ .sort((left, right) => right[1] - left[1])
+ .map(([actionType, count]) => ({
+ actionType: actionType as ActionType,
+ label: RECOGNIZED_ACTION_LABELS[actionType as ActionType] || actionType,
+ count,
+ }));
+}
+
export default function Recorder() {
const { user } = useAuth();
const utils = trpc.useUtils();
@@ -170,11 +205,22 @@ export default function Recorder() {
const streamRef = useRef(null);
const recorderRef = useRef(null);
const peerRef = useRef(null);
+ const actionPoseRef = useRef(null);
const currentSegmentStartedAtRef = useRef(0);
const recordingStartedAtRef = useRef(0);
const segmentSequenceRef = useRef(0);
const motionFrameRef = useRef(null);
const lastMotionMarkerAtRef = useRef(0);
+ const actionTickerRef = useRef | null>(null);
+ const actionFrameInFlightRef = useRef(false);
+ const actionTrackingRef = useRef({});
+ const actionHistoryRef = useRef([]);
+ const actionSummaryRef = useRef>(createActionSummary());
+ const lastRecognizedActionAtRef = useRef(0);
+ const lastActionMarkerAtRef = useRef(0);
+ const latestRecognizedActionRef = useRef("unknown");
+ const validityOverrideRef = useRef<"valid" | "invalid" | null>(null);
+ const invalidAutoMarkedRef = useRef(false);
const pendingUploadsRef = useRef([]);
const uploadInFlightRef = useRef(false);
const currentSessionRef = useRef(null);
@@ -202,13 +248,17 @@ export default function Recorder() {
const [uploadedSegments, setUploadedSegments] = useState(0);
const [uploadBytes, setUploadBytes] = useState(0);
const [cameraError, setCameraError] = useState("");
- const [title, setTitle] = useState(() => `训练录制 ${new Date().toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}`);
+ const [title, setTitle] = useState(() => `训练录制 ${formatDateTimeShanghai(new Date(), { year: undefined, second: undefined })}`);
const [mediaSession, setMediaSession] = useState(null);
const [markers, setMarkers] = useState([]);
const [connectionState, setConnectionState] = useState("new");
const [immersivePreview, setImmersivePreview] = useState(false);
const [archiveTaskId, setArchiveTaskId] = useState(null);
const [zoomState, setZoomState] = useState(() => readTrackZoomState(null));
+ const [actionSummary, setActionSummary] = useState>(() => createActionSummary());
+ const [currentDetectedAction, setCurrentDetectedAction] = useState("unknown");
+ const [recordingValidity, setRecordingValidity] = useState<"pending" | "valid" | "invalid">("pending");
+ const [recordingValidityReason, setRecordingValidityReason] = useState("");
const mobile = useMemo(() => isMobileDevice(), []);
const mimeType = useMemo(() => pickRecorderMimeType(), []);
@@ -224,6 +274,7 @@ export default function Recorder() {
mediaSession?.archiveStatus === "queued" ||
mediaSession?.archiveStatus === "processing";
const canLeaveRecorderPage = !uploadStillDraining && (archiveRunning || mode === "archived");
+ const recognizedActionItems = useMemo(() => summarizeActions(actionSummary), [actionSummary]);
const syncSessionState = useCallback((session: MediaSession | null) => {
currentSessionRef.current = session;
@@ -273,9 +324,11 @@ export default function Recorder() {
if (segmentTickerRef.current) clearInterval(segmentTickerRef.current);
if (timerTickerRef.current) clearInterval(timerTickerRef.current);
if (motionTickerRef.current) clearInterval(motionTickerRef.current);
+ if (actionTickerRef.current) clearInterval(actionTickerRef.current);
segmentTickerRef.current = null;
timerTickerRef.current = null;
motionTickerRef.current = null;
+ actionTickerRef.current = null;
}, []);
const closePeer = useCallback(() => {
@@ -479,7 +532,11 @@ export default function Recorder() {
await stopped;
}, [stopTickers]);
- const createManualMarker = useCallback(async (type: "manual" | "motion", label: string, confidence?: number) => {
+ const createManualMarker = useCallback(async (
+ type: "manual" | "motion" | "action_detected" | "invalid_auto" | "invalid_manual" | "valid_manual",
+ label: string,
+ confidence?: number,
+ ) => {
const sessionId = currentSessionRef.current?.id;
if (!sessionId) return;
@@ -508,6 +565,132 @@ export default function Recorder() {
}
}, [syncSessionState]);
+ const setValidityState = useCallback((
+ nextStatus: "pending" | "valid" | "invalid",
+ reason: string,
+ override: "valid" | "invalid" | null = validityOverrideRef.current,
+ ) => {
+ validityOverrideRef.current = override;
+ setRecordingValidity(nextStatus);
+ setRecordingValidityReason(reason);
+ }, []);
+
+ const startActionSampling = useCallback(async () => {
+ if (typeof window === "undefined") return;
+ const liveVideo = liveVideoRef.current;
+ if (!liveVideo) return;
+
+ if (!actionPoseRef.current) {
+ const testFactory = (
+ window as typeof window & {
+ __TEST_MEDIAPIPE_FACTORY__?: () => Promise<{ Pose: any }>;
+ }
+ ).__TEST_MEDIAPIPE_FACTORY__;
+ const { Pose } = testFactory ? await testFactory() : await import("@mediapipe/pose");
+ const pose = new Pose({
+ locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`,
+ });
+ pose.setOptions({
+ modelComplexity: 0,
+ smoothLandmarks: true,
+ enableSegmentation: false,
+ minDetectionConfidence: 0.5,
+ minTrackingConfidence: 0.5,
+ });
+ pose.onResults((results: { poseLandmarks?: Array<{ x: number; y: number; visibility?: number }> }) => {
+ if (!results.poseLandmarks) {
+ return;
+ }
+
+ const analyzed = stabilizeActionFrame(
+ recognizeActionFrame(results.poseLandmarks, actionTrackingRef.current, performance.now()),
+ actionHistoryRef.current,
+ );
+ setCurrentDetectedAction(analyzed.action);
+
+ if (analyzed.action === "unknown" || analyzed.confidence < 0.55) {
+ return;
+ }
+
+ latestRecognizedActionRef.current = analyzed.action;
+ lastRecognizedActionAtRef.current = Date.now();
+ actionSummaryRef.current = {
+ ...actionSummaryRef.current,
+ [analyzed.action]: (actionSummaryRef.current[analyzed.action] || 0) + 1,
+ };
+ setActionSummary({ ...actionSummaryRef.current });
+
+ if (validityOverrideRef.current !== "invalid") {
+ setValidityState("valid", `已识别到 ${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, validityOverrideRef.current);
+ }
+
+ if (Date.now() - lastActionMarkerAtRef.current >= 15_000) {
+ lastActionMarkerAtRef.current = Date.now();
+ void createManualMarker("action_detected", `识别到${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, analyzed.confidence);
+ }
+ });
+ actionPoseRef.current = pose;
+ }
+
+ const checkInvalidWindow = () => {
+ const elapsedMs = Date.now() - recordingStartedAtRef.current;
+ if (elapsedMs < INVALID_RECORDING_WINDOW_MS || invalidAutoMarkedRef.current) {
+ return;
+ }
+ if (Date.now() - lastRecognizedActionAtRef.current < INVALID_RECORDING_WINDOW_MS) {
+ return;
+ }
+ invalidAutoMarkedRef.current = true;
+ setValidityState("invalid", "连续 60 秒未识别到有效动作,已自动标记为无效录制", validityOverrideRef.current);
+ void createManualMarker("invalid_auto", "连续60秒未识别到有效动作,自动标记为无效录制");
+ };
+
+ actionTickerRef.current = setInterval(() => {
+ const video = liveVideoRef.current;
+ if (!video || video.readyState < 2 || !actionPoseRef.current || actionFrameInFlightRef.current) {
+ checkInvalidWindow();
+ return;
+ }
+
+ actionFrameInFlightRef.current = true;
+ actionPoseRef.current.send({ image: video })
+ .catch(() => undefined)
+ .finally(() => {
+ actionFrameInFlightRef.current = false;
+ checkInvalidWindow();
+ });
+ }, ACTION_SAMPLE_MS);
+ }, [createManualMarker, setValidityState]);
+
+ const stopActionSampling = useCallback(async () => {
+ if (actionTickerRef.current) {
+ clearInterval(actionTickerRef.current);
+ actionTickerRef.current = null;
+ }
+ if (actionPoseRef.current?.close) {
+ try {
+ await actionPoseRef.current.close();
+ } catch {
+ // ignore pose teardown failures during recorder stop/reset
+ }
+ }
+ actionPoseRef.current = null;
+ actionFrameInFlightRef.current = false;
+ }, []);
+
+ const updateRecordingValidity = useCallback(async (next: "valid" | "invalid") => {
+ validityOverrideRef.current = next;
+ if (next === "valid") {
+ setValidityState("valid", "已手工恢复为有效录制", "valid");
+ invalidAutoMarkedRef.current = false;
+ await createManualMarker("valid_manual", "手工恢复为有效录制");
+ return;
+ }
+
+ setValidityState("invalid", "已手工标记为无效录制", "invalid");
+ await createManualMarker("invalid_manual", "手工标记为无效录制");
+ }, [createManualMarker, setValidityState]);
+
const sampleMotion = useCallback(() => {
const video = liveVideoRef.current;
const canvas = motionCanvasRef.current;
@@ -680,11 +863,22 @@ export default function Recorder() {
segmentSequenceRef.current = 0;
motionFrameRef.current = null;
pendingUploadsRef.current = [];
+ actionTrackingRef.current = {};
+ actionHistoryRef.current = [];
+ actionSummaryRef.current = createActionSummary();
+ setActionSummary(createActionSummary());
+ setCurrentDetectedAction("unknown");
+ setRecordingValidity("pending");
+ setRecordingValidityReason("正在抽样动作帧,持续 60 秒未识别到有效动作将自动标记无效。");
+ validityOverrideRef.current = null;
+ invalidAutoMarkedRef.current = false;
+ latestRecognizedActionRef.current = "unknown";
+ lastActionMarkerAtRef.current = 0;
const stream = await ensurePreviewStream();
const sessionResponse = await createMediaSession({
userId: String(user.id),
- title: title.trim() || `训练录制 ${new Date().toLocaleString("zh-CN")}`,
+ title: title.trim() || `训练录制 ${formatDateTimeShanghai(new Date())}`,
format: "webm",
mimeType,
qualityPreset,
@@ -695,14 +889,16 @@ export default function Recorder() {
await startRealtimePush(stream, sessionResponse.session.id);
recordingStartedAtRef.current = Date.now();
+ lastRecognizedActionAtRef.current = recordingStartedAtRef.current;
startRecorderLoop(stream);
+ await startActionSampling();
setMode("recording");
toast.success("录制已开始,已同步启动实时推流");
} catch (error: any) {
setMode("idle");
toast.error(`启动录制失败: ${error?.message || "未知错误"}`);
}
- }, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startRealtimePush, startRecorderLoop, syncSessionState, title, user]);
+ }, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startActionSampling, startRealtimePush, startRecorderLoop, syncSessionState, title, user]);
const finishRecording = useCallback(async () => {
const session = currentSessionRef.current;
@@ -712,6 +908,7 @@ export default function Recorder() {
try {
setMode("finalizing");
+ await stopActionSampling();
await stopRecorder();
await flushPendingSegments();
closePeer();
@@ -728,13 +925,25 @@ export default function Recorder() {
exerciseType: "recording",
sessionMode,
durationMinutes: Math.max(1, Math.round((Date.now() - recordingStartedAtRef.current) / 60000)),
+ actionCount: Object.entries(actionSummaryRef.current)
+ .filter(([actionType]) => actionType !== "unknown")
+ .reduce((sum, [, count]) => sum + count, 0),
+ actionSummary: actionSummaryRef.current,
+ dominantAction: latestRecognizedActionRef.current !== "unknown" ? latestRecognizedActionRef.current : undefined,
+ validityStatus:
+ recordingValidity === "invalid"
+ ? validityOverrideRef.current === "invalid" ? "invalid_manual" : "invalid_auto"
+ : recordingValidity === "valid"
+ ? validityOverrideRef.current === "valid" ? "valid_manual" : "valid"
+ : "pending",
+ invalidReason: recordingValidity === "invalid" ? recordingValidityReason : undefined,
});
toast.success("录制已提交,后台正在整理回放文件");
} catch (error: any) {
toast.error(`结束录制失败: ${error?.message || "未知错误"}`);
setMode("recording");
}
- }, [closePeer, finalizeTaskMutation, flushPendingSegments, sessionMode, stopCamera, stopRecorder, syncSessionState, title]);
+ }, [closePeer, finalizeTaskMutation, flushPendingSegments, recordingValidity, recordingValidityReason, sessionMode, stopActionSampling, stopCamera, stopRecorder, syncSessionState, title]);
const resetRecorder = useCallback(async () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
@@ -745,10 +954,18 @@ export default function Recorder() {
pendingUploadsRef.current = [];
uploadInFlightRef.current = false;
motionFrameRef.current = null;
+ await stopActionSampling().catch(() => {});
+ actionTrackingRef.current = {};
+ actionHistoryRef.current = [];
+ actionSummaryRef.current = createActionSummary();
currentSessionRef.current = null;
setArchiveTaskId(null);
setMediaSession(null);
setMarkers([]);
+ setActionSummary(createActionSummary());
+ setCurrentDetectedAction("unknown");
+ setRecordingValidity("pending");
+ setRecordingValidityReason("");
setDurationMs(0);
setQueuedSegments(0);
setQueuedBytes(0);
@@ -758,7 +975,7 @@ export default function Recorder() {
setConnectionState("new");
setCameraError("");
setMode("idle");
- }, [closePeer, stopCamera, stopRecorder, stopTickers]);
+ }, [closePeer, stopActionSampling, stopCamera, stopRecorder, stopTickers]);
const flipCamera = useCallback(async () => {
const nextFacingMode = facingMode === "user" ? "environment" : "user";
@@ -844,6 +1061,7 @@ export default function Recorder() {
return () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
stopTickers();
+ void stopActionSampling();
if (recorderRef.current && recorderRef.current.state !== "inactive") {
try {
recorderRef.current.stop();
@@ -856,7 +1074,7 @@ export default function Recorder() {
streamRef.current.getTracks().forEach((track) => track.stop());
}
};
- }, [closePeer, stopTickers]);
+ }, [closePeer, stopActionSampling, stopTickers]);
const statusBadge = useMemo(() => {
if (mode === "finalizing") {
@@ -1333,6 +1551,53 @@ export default function Recorder() {
+
+
+
动作有效性
+
+ {recordingValidity === "valid" ? "有效录制" : recordingValidity === "invalid" ? "无效录制" : "待判定"}
+
+
+ {recordingValidityReason || "录制中会自动抽样动作帧并进行判定。"}
+
+
+
+
当前识别动作
+
+ {RECOGNIZED_ACTION_LABELS[currentDetectedAction] || "未知动作"}
+
+
+ 每 {Math.round(ACTION_SAMPLE_MS / 1000)} 秒抽样动作帧;连续 {Math.round(INVALID_RECORDING_WINDOW_MS / 1000)} 秒无有效动作会自动标记无效。
+
+
+
+
+
+
void updateRecordingValidity("valid")}
+ disabled={!currentSessionRef.current}
+ >
+ 手工恢复有效
+
+
void updateRecordingValidity("invalid")}
+ disabled={!currentSessionRef.current}
+ >
+ 手工标记无效
+
+ {mediaSession?.playback.previewUrl ? (
+
+
+ 查看滚动预归档
+
+
+ ) : null}
+
+
已上传文件
@@ -1354,6 +1619,13 @@ export default function Recorder() {
服务端状态
{mediaSession?.status || "idle"}
+
+ 滚动预归档
+
+ {mediaSession?.previewStatus || "idle"}
+ {typeof mediaSession?.previewSegments === "number" ? ` · ${mediaSession.previewSegments} 段` : ""}
+
+
{(mode === "finalizing" || mode === "archived" || mediaSession?.archiveStatus === "failed") && (
@@ -1404,6 +1676,19 @@ export default function Recorder() {
)}
+ {recognizedActionItems.length > 0 ? (
+
+
识别到的动作数据
+
+ {recognizedActionItems.map((item) => (
+
+ {item.label} {item.count} 次
+
+ ))}
+
+
+ ) : null}
+
{cameraError && (
{cameraError}
diff --git a/client/src/pages/Videos.tsx b/client/src/pages/Videos.tsx
index ebb4735..7953278 100644
--- a/client/src/pages/Videos.tsx
+++ b/client/src/pages/Videos.tsx
@@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
+import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import {
BarChart3,
@@ -145,7 +146,7 @@ function buildClipCueSheet(title: string, clips: ClipDraft[]) {
` 时长: ${formatSeconds(Math.max(0, clip.endSec - clip.startSec))}\n` +
` 来源: ${clip.source === "manual" ? "手动" : "分析建议"}\n` +
` 备注: ${clip.notes || "无"}`
- )).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${new Date().toLocaleString("zh-CN")}\n`;
+ )).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${formatDateTimeShanghai(new Date())}\n`;
}
function createEmptyVideoDraft(): VideoCreateDraft {
@@ -440,7 +441,7 @@ export default function Videos() {
) : null}
- {new Date(video.createdAt).toLocaleDateString("zh-CN")}
+ {formatDateShanghai(video.createdAt)}
{((video.fileSize || 0) / 1024 / 1024).toFixed(1)}MB
diff --git a/client/src/pages/VisionLab.tsx b/client/src/pages/VisionLab.tsx
index dc18360..cda7af9 100644
--- a/client/src/pages/VisionLab.tsx
+++ b/client/src/pages/VisionLab.tsx
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
+import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { Database, Image as ImageIcon, Loader2, Microscope, ShieldCheck, Sparkles } from "lucide-react";
@@ -358,7 +359,7 @@ export default function VisionLab() {
{run.exerciseType}
- {new Date(run.createdAt).toLocaleString("zh-CN")}
+ {formatDateTimeShanghai(run.createdAt)}
{user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 77a4adb..9806375 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,29 @@
# Tennis Training Hub - 变更日志
+## 2026.03.15-session-changelog (2026-03-15)
+
+### 功能更新
+
+- 用户名登录生成独立 `sid`,同一账号在多个设备或浏览器上下文中登录时不再互相顶掉 session
+- 新增应用内更新日志页面 `/changelog`,展示版本号、发布日期、仓库版本和测试记录
+- 训练进度页最近训练记录支持展开,展示具体上海时间、动作数、主动作、动作明细、录制有效性和备注
+- 录制页补齐动作抽样摘要、无效录制标记与 media 预归档状态的前端展示
+- Dashboard、任务中心、管理台、训练页、评分页、日志页、视觉测试页、视频库等高频页面统一使用 `Asia/Shanghai` 时间显示
+
+### 测试
+
+- `pnpm check`
+- `pnpm test`
+- `pnpm test:go`
+- `pnpm build`
+- Playwright 线上 smoke:
+ - `https://te.hao.work/` 使用两个浏览器上下文分别登录 `H1`,两端 dashboard 均保持有效
+ - 当前线上 `/changelog` 仍返回旧前端构建,待部署最新版本后需要复测该页面
+
+### 仓库版本
+
+- `pending-commit`
+
## v3.0.0 (2026-03-14)
### 新增功能
diff --git a/media/main.go b/media/main.go
index 1c12478..b54a5ea 100644
--- a/media/main.go
+++ b/media/main.go
@@ -44,6 +44,15 @@ const (
ArchiveFailed ArchiveStatus = "failed"
)
+type PreviewStatus string
+
+const (
+ PreviewIdle PreviewStatus = "idle"
+ PreviewProcessing PreviewStatus = "processing"
+ PreviewReady PreviewStatus = "ready"
+ PreviewFailed PreviewStatus = "failed"
+)
+
type PlaybackInfo struct {
WebMURL string `json:"webmUrl,omitempty"`
MP4URL string `json:"mp4Url,omitempty"`
@@ -77,6 +86,7 @@ type Session struct {
Title string `json:"title"`
Status SessionStatus `json:"status"`
ArchiveStatus ArchiveStatus `json:"archiveStatus"`
+ PreviewStatus PreviewStatus `json:"previewStatus"`
Format string `json:"format"`
MimeType string `json:"mimeType"`
QualityPreset string `json:"qualityPreset"`
@@ -85,11 +95,13 @@ type Session struct {
ReconnectCount int `json:"reconnectCount"`
UploadedSegments int `json:"uploadedSegments"`
UploadedBytes int64 `json:"uploadedBytes"`
+ PreviewSegments int `json:"previewSegments"`
DurationMS int64 `json:"durationMs"`
LastError string `json:"lastError,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
FinalizedAt string `json:"finalizedAt,omitempty"`
+ PreviewUpdatedAt string `json:"previewUpdatedAt,omitempty"`
StreamConnected bool `json:"streamConnected"`
LastStreamAt string `json:"lastStreamAt,omitempty"`
Playback PlaybackInfo `json:"playback"`
@@ -159,7 +171,7 @@ func newSessionStore(rootDir string) (*sessionStore, error) {
if err := os.MkdirAll(store.public, 0o755); err != nil {
return nil, err
}
- if err := store.load(); err != nil {
+ if err := store.refreshFromDisk(); err != nil {
return nil, err
}
for _, session := range store.sessions {
@@ -168,12 +180,13 @@ func newSessionStore(rootDir string) (*sessionStore, error) {
return store, nil
}
-func (s *sessionStore) load() error {
+func (s *sessionStore) loadSessionsFromDisk() (map[string]*Session, error) {
pattern := filepath.Join(s.rootDir, "sessions", "*", "session.json")
files, err := filepath.Glob(pattern)
if err != nil {
- return err
+ return nil, err
}
+ sessions := make(map[string]*Session, len(files))
for _, file := range files {
body, readErr := os.ReadFile(file)
if readErr != nil {
@@ -183,8 +196,19 @@ func (s *sessionStore) load() error {
if unmarshalErr := json.Unmarshal(body, &session); unmarshalErr != nil {
continue
}
- s.sessions[session.ID] = &session
+ sessions[session.ID] = &session
}
+ return sessions, nil
+}
+
+func (s *sessionStore) refreshFromDisk() error {
+ sessions, err := s.loadSessionsFromDisk()
+ if err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.sessions = sessions
return nil
}
@@ -228,6 +252,7 @@ func (s *sessionStore) createSession(input CreateSessionRequest) (*Session, erro
Title: strings.TrimSpace(input.Title),
Status: StatusCreated,
ArchiveStatus: ArchiveIdle,
+ PreviewStatus: PreviewIdle,
Format: defaultString(input.Format, "webm"),
MimeType: defaultString(input.MimeType, "video/webm"),
QualityPreset: defaultString(input.QualityPreset, "balanced"),
@@ -295,13 +320,20 @@ func (s *sessionStore) updateSession(id string, update func(*Session) error) (*S
return cloneSession(session), nil
}
-func (s *sessionStore) listFinalizingSessions() []*Session {
+func (s *sessionStore) listProcessableSessions() []*Session {
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]*Session, 0, len(s.sessions))
for _, session := range s.sessions {
+ if len(session.Segments) == 0 {
+ continue
+ }
if session.ArchiveStatus == ArchiveQueued || session.ArchiveStatus == ArchiveProcessing {
items = append(items, cloneSession(session))
+ continue
+ }
+ if session.PreviewSegments < len(session.Segments) && session.PreviewStatus != PreviewProcessing {
+ items = append(items, cloneSession(session))
}
}
return items
@@ -315,6 +347,10 @@ func newMediaServer(store *sessionStore) *mediaServer {
return &mediaServer{store: store}
}
+func (m *mediaServer) refreshSessionsForRead() error {
+ return m.store.refreshFromDisk()
+}
+
func (m *mediaServer) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/media/health", m.handleHealth)
@@ -359,6 +395,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
}
sessionID := parts[0]
if len(parts) == 1 && r.Method == http.MethodGet {
+ if err := m.refreshSessionsForRead(); err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
session, err := m.store.getSession(sessionID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
@@ -402,6 +442,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
+ if err := m.refreshSessionsForRead(); err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
session, err := m.store.getSession(sessionID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
@@ -632,7 +676,11 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat
case <-ctx.Done():
return
case <-ticker.C:
- sessions := store.listFinalizingSessions()
+ if err := store.refreshFromDisk(); err != nil {
+ log.Printf("[worker] failed to refresh session store: %v", err)
+ continue
+ }
+ sessions := store.listProcessableSessions()
for _, session := range sessions {
if err := processSession(store, session.ID); err != nil {
log.Printf("[worker] failed to process session %s: %v", session.ID, err)
@@ -643,6 +691,42 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat
}
func processSession(store *sessionStore, sessionID string) error {
+ current, err := store.getSession(sessionID)
+ if err != nil {
+ return err
+ }
+
+ if current.ArchiveStatus == ArchiveQueued || current.ArchiveStatus == ArchiveProcessing {
+ return processFinalArchive(store, sessionID)
+ }
+
+ if current.PreviewSegments < len(current.Segments) {
+ return processRollingPreview(store, sessionID)
+ }
+
+ return nil
+}
+
+func processRollingPreview(store *sessionStore, sessionID string) error {
+ session, err := store.updateSession(sessionID, func(session *Session) error {
+ if session.PreviewStatus == PreviewProcessing {
+ return errors.New("preview already processing")
+ }
+ session.PreviewStatus = PreviewProcessing
+ session.LastError = ""
+ return nil
+ })
+ if err != nil {
+ if strings.Contains(err.Error(), "preview already processing") {
+ return nil
+ }
+ return err
+ }
+
+ return buildPlaybackArtifacts(store, session, false)
+}
+
+func processFinalArchive(store *sessionStore, sessionID string) error {
session, err := store.updateSession(sessionID, func(session *Session) error {
if session.ArchiveStatus == ArchiveProcessing {
return errors.New("already processing")
@@ -668,12 +752,22 @@ func processSession(store *sessionStore, sessionID string) error {
return errors.New("no uploaded segments found")
}
+ return buildPlaybackArtifacts(store, session, true)
+}
+
+func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool) error {
+ sessionID := session.ID
+
publicDir := store.publicDir(sessionID)
if err := os.MkdirAll(publicDir, 0o755); err != nil {
return err
}
- outputWebM := filepath.Join(publicDir, "recording.webm")
- outputMP4 := filepath.Join(publicDir, "recording.mp4")
+ baseName := "preview"
+ if finalize {
+ baseName = "recording"
+ }
+ outputWebM := filepath.Join(publicDir, baseName+".webm")
+ outputMP4 := filepath.Join(publicDir, baseName+".mp4")
listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt")
inputs := make([]string, 0, len(session.Segments))
@@ -684,23 +778,23 @@ func processSession(store *sessionStore, sessionID string) error {
inputs = append(inputs, filepath.Join(store.segmentsDir(sessionID), segment.Filename))
}
if err := writeConcatList(listFile, inputs); err != nil {
- return markArchiveError(store, sessionID, err)
+ return markProcessingError(store, sessionID, err, finalize)
}
if len(inputs) == 1 {
body, copyErr := os.ReadFile(inputs[0])
if copyErr != nil {
- return markArchiveError(store, sessionID, copyErr)
+ return markProcessingError(store, sessionID, copyErr, finalize)
}
if writeErr := os.WriteFile(outputWebM, body, 0o644); writeErr != nil {
- return markArchiveError(store, sessionID, writeErr)
+ return markProcessingError(store, sessionID, writeErr, finalize)
}
} else {
copyErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputWebM)
if copyErr != nil {
reencodeErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c:v", "libvpx-vp9", "-b:v", "1800k", "-c:a", "libopus", outputWebM)
if reencodeErr != nil {
- return markArchiveError(store, sessionID, fmt.Errorf("concat failed: %w / %v", copyErr, reencodeErr))
+ return markProcessingError(store, sessionID, fmt.Errorf("concat failed: %w / %v", copyErr, reencodeErr), finalize)
}
}
}
@@ -712,7 +806,7 @@ func processSession(store *sessionStore, sessionID string) error {
webmInfo, webmStatErr := os.Stat(outputWebM)
if webmStatErr != nil {
- return markArchiveError(store, sessionID, webmStatErr)
+ return markProcessingError(store, sessionID, webmStatErr, finalize)
}
var mp4Size int64
var mp4URL string
@@ -720,27 +814,41 @@ func processSession(store *sessionStore, sessionID string) error {
mp4Size = info.Size()
mp4URL = fmt.Sprintf("/media/assets/sessions/%s/recording.mp4", sessionID)
}
- _, err = store.updateSession(sessionID, func(session *Session) error {
- session.ArchiveStatus = ArchiveCompleted
- session.Status = StatusArchived
- session.Playback = PlaybackInfo{
- WebMURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID),
- MP4URL: mp4URL,
- WebMSize: webmInfo.Size(),
- MP4Size: mp4Size,
- Ready: true,
- PreviewURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID),
- }
+ previewURL := fmt.Sprintf("/media/assets/sessions/%s/%s.webm", sessionID, baseName)
+ if mp4URL != "" {
+ previewURL = mp4URL
+ }
+
+ _, updateErr := store.updateSession(sessionID, func(session *Session) error {
+ session.Playback.PreviewURL = previewURL
+ session.PreviewSegments = len(inputs)
+ session.PreviewUpdatedAt = time.Now().UTC().Format(time.RFC3339)
+ session.PreviewStatus = PreviewReady
session.LastError = ""
+ if finalize {
+ session.ArchiveStatus = ArchiveCompleted
+ session.Status = StatusArchived
+ session.Playback = PlaybackInfo{
+ WebMURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID),
+ MP4URL: mp4URL,
+ WebMSize: webmInfo.Size(),
+ MP4Size: mp4Size,
+ Ready: true,
+ PreviewURL: previewURL,
+ }
+ }
return nil
})
- return err
+ return updateErr
}
-func markArchiveError(store *sessionStore, sessionID string, err error) error {
+func markProcessingError(store *sessionStore, sessionID string, err error, finalize bool) error {
_, _ = store.updateSession(sessionID, func(session *Session) error {
- session.ArchiveStatus = ArchiveFailed
- session.Status = StatusFailed
+ session.PreviewStatus = PreviewFailed
+ if finalize {
+ session.ArchiveStatus = ArchiveFailed
+ session.Status = StatusFailed
+ }
session.LastError = err.Error()
return nil
})
diff --git a/media/main_test.go b/media/main_test.go
index edba0a6..d1af01e 100644
--- a/media/main_test.go
+++ b/media/main_test.go
@@ -1,6 +1,7 @@
package main
import (
+ "encoding/json"
"net/http"
"net/http/httptest"
"os"
@@ -128,3 +129,130 @@ func TestProcessSessionArchivesPlayback(t *testing.T) {
t.Fatalf("expected webm playback url, got %#v", archived.Playback)
}
}
+
+func TestRefreshFromDiskPicksUpSessionsCreatedAfterWorkerStartup(t *testing.T) {
+ tempDir := t.TempDir()
+
+ workerStore, err := newSessionStore(tempDir)
+ if err != nil {
+ t.Fatalf("newSessionStore(worker): %v", err)
+ }
+ if got := len(workerStore.listProcessableSessions()); got != 0 {
+ t.Fatalf("expected no processable sessions at startup, got %d", got)
+ }
+
+ appStore, err := newSessionStore(tempDir)
+ if err != nil {
+ t.Fatalf("newSessionStore(app): %v", err)
+ }
+
+ session, err := appStore.createSession(CreateSessionRequest{UserID: "1", Title: "Queued Session"})
+ if err != nil {
+ t.Fatalf("createSession: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(appStore.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil {
+ t.Fatalf("write segment: %v", err)
+ }
+ if _, err := appStore.updateSession(session.ID, func(current *Session) error {
+ current.Segments = append(current.Segments, SegmentMeta{
+ Sequence: 0,
+ Filename: "000000.webm",
+ DurationMS: 60000,
+ SizeBytes: 7,
+ ContentType: "video/webm",
+ })
+ current.ArchiveStatus = ArchiveQueued
+ current.Status = StatusFinalizing
+ return nil
+ }); err != nil {
+ t.Fatalf("updateSession: %v", err)
+ }
+
+ if err := workerStore.refreshFromDisk(); err != nil {
+ t.Fatalf("refreshFromDisk: %v", err)
+ }
+
+ processable := workerStore.listProcessableSessions()
+ if len(processable) != 1 {
+ t.Fatalf("expected worker to pick up queued session after refresh, got %d", len(processable))
+ }
+ if processable[0].ID != session.ID {
+ t.Fatalf("expected session %s, got %s", session.ID, processable[0].ID)
+ }
+}
+
+func TestHandleSessionGetRefreshesSessionStateFromDisk(t *testing.T) {
+ tempDir := t.TempDir()
+
+ serverStore, err := newSessionStore(tempDir)
+ if err != nil {
+ t.Fatalf("newSessionStore(server): %v", err)
+ }
+ server := newMediaServer(serverStore)
+
+ writerStore, err := newSessionStore(tempDir)
+ if err != nil {
+ t.Fatalf("newSessionStore(writer): %v", err)
+ }
+
+ session, err := writerStore.createSession(CreateSessionRequest{UserID: "1", Title: "Fresh Session"})
+ if err != nil {
+ t.Fatalf("createSession: %v", err)
+ }
+ if _, err := writerStore.updateSession(session.ID, func(current *Session) error {
+ current.Status = StatusFinalizing
+ current.ArchiveStatus = ArchiveQueued
+ return nil
+ }); err != nil {
+ t.Fatalf("queue session: %v", err)
+ }
+
+ getReq := httptest.NewRequest(http.MethodGet, "/media/sessions/"+session.ID, nil)
+ getRes := httptest.NewRecorder()
+ server.routes().ServeHTTP(getRes, getReq)
+ if getRes.Code != http.StatusOK {
+ t.Fatalf("expected get session 200, got %d", getRes.Code)
+ }
+
+ var queuedResponse struct {
+ Session Session `json:"session"`
+ }
+ if err := json.NewDecoder(getRes.Body).Decode(&queuedResponse); err != nil {
+ t.Fatalf("decode queued response: %v", err)
+ }
+ if queuedResponse.Session.ArchiveStatus != ArchiveQueued {
+ t.Fatalf("expected queued archive status, got %s", queuedResponse.Session.ArchiveStatus)
+ }
+
+ if _, err := writerStore.updateSession(session.ID, func(current *Session) error {
+ current.Status = StatusArchived
+ current.ArchiveStatus = ArchiveCompleted
+ current.Playback = PlaybackInfo{
+ WebMURL: "/media/assets/sessions/" + session.ID + "/recording.webm",
+ Ready: true,
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("complete session: %v", err)
+ }
+
+ refreshReq := httptest.NewRequest(http.MethodGet, "/media/sessions/"+session.ID, nil)
+ refreshRes := httptest.NewRecorder()
+ server.routes().ServeHTTP(refreshRes, refreshReq)
+ if refreshRes.Code != http.StatusOK {
+ t.Fatalf("expected refreshed get session 200, got %d", refreshRes.Code)
+ }
+
+ var completedResponse struct {
+ Session Session `json:"session"`
+ }
+ if err := json.NewDecoder(refreshRes.Body).Decode(&completedResponse); err != nil {
+ t.Fatalf("decode completed response: %v", err)
+ }
+ if completedResponse.Session.ArchiveStatus != ArchiveCompleted {
+ t.Fatalf("expected completed archive status, got %s", completedResponse.Session.ArchiveStatus)
+ }
+ if !completedResponse.Session.Playback.Ready {
+ t.Fatalf("expected playback ready after refresh")
+ }
+}
diff --git a/server/_core/sdk.ts b/server/_core/sdk.ts
index 230e762..f1033d7 100644
--- a/server/_core/sdk.ts
+++ b/server/_core/sdk.ts
@@ -21,7 +21,8 @@ const isNonEmptyString = (value: unknown): value is string =>
export type SessionPayload = {
openId: string;
appId: string;
- name: string;
+ name?: string;
+ sid?: string;
};
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
@@ -173,6 +174,7 @@ class SDKServer {
openId,
appId: ENV.appId,
name: options.name || "",
+ sid: crypto.randomUUID(),
},
options
);
@@ -190,7 +192,8 @@ class SDKServer {
return new SignJWT({
openId: payload.openId,
appId: payload.appId,
- name: payload.name,
+ name: payload.name || "",
+ sid: payload.sid || crypto.randomUUID(),
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setExpirationTime(expirationSeconds)
@@ -199,7 +202,7 @@ class SDKServer {
async verifySession(
cookieValue: string | undefined | null
- ): Promise<{ openId: string; appId: string; name: string } | null> {
+ ): Promise<{ openId: string; appId: string; name?: string; sid?: string } | null> {
if (!cookieValue) {
console.warn("[Auth] Missing session cookie");
return null;
@@ -210,12 +213,11 @@ class SDKServer {
const { payload } = await jwtVerify(cookieValue, secretKey, {
algorithms: ["HS256"],
});
- const { openId, appId, name } = payload as Record;
+ const { openId, appId, name, sid } = payload as Record;
if (
!isNonEmptyString(openId) ||
- !isNonEmptyString(appId) ||
- !isNonEmptyString(name)
+ !isNonEmptyString(appId)
) {
console.warn("[Auth] Session payload missing required fields");
return null;
@@ -224,7 +226,8 @@ class SDKServer {
return {
openId,
appId,
- name,
+ name: typeof name === "string" ? name : undefined,
+ sid: typeof sid === "string" ? sid : undefined,
};
} catch (error) {
console.warn("[Auth] Session verification failed", String(error));
diff --git a/server/mediaService.ts b/server/mediaService.ts
index 706a9c8..430c7ed 100644
--- a/server/mediaService.ts
+++ b/server/mediaService.ts
@@ -6,6 +6,16 @@ export type RemoteMediaSession = {
userId: string;
title: string;
archiveStatus: "idle" | "queued" | "processing" | "completed" | "failed";
+ previewStatus?: "idle" | "processing" | "ready" | "failed";
+ previewSegments?: number;
+ markers?: Array<{
+ id: string;
+ type: string;
+ label: string;
+ timestampMs: number;
+ confidence?: number;
+ createdAt: string;
+ }>;
playback: {
webmUrl?: string;
mp4Url?: string;
diff --git a/server/routers.ts b/server/routers.ts
index ba28eda..49333d7 100644
--- a/server/routers.ts
+++ b/server/routers.ts
@@ -664,6 +664,11 @@ export const appRouter = router({
exerciseType: z.string().optional(),
sessionMode: z.enum(["practice", "pk"]).default("practice"),
durationMinutes: z.number().min(1).max(720).optional(),
+ actionCount: z.number().min(0).max(100000).optional(),
+ actionSummary: z.record(z.string(), z.number()).optional(),
+ dominantAction: z.string().optional(),
+ validityStatus: z.enum(["pending", "valid", "valid_manual", "invalid_auto", "invalid_manual"]).optional(),
+ invalidReason: z.string().max(512).optional(),
}))
.mutation(async ({ ctx, input }) => {
return enqueueTask({
diff --git a/server/taskWorker.ts b/server/taskWorker.ts
index 98a0caa..2810494 100644
--- a/server/taskWorker.ts
+++ b/server/taskWorker.ts
@@ -34,8 +34,13 @@ type StructuredParams = {
};
};
parse: (content: unknown) => T;
+ timeoutMs?: number;
+ retryCount?: number;
};
+const TRAINING_PLAN_LLM_TIMEOUT_MS = Math.max(ENV.llmTimeoutMs, 120_000);
+const TRAINING_PLAN_LLM_RETRY_COUNT = Math.max(ENV.llmRetryCount, 2);
+
async function invokeStructured(params: StructuredParams) {
let lastError: unknown;
@@ -56,6 +61,8 @@ async function invokeStructured(params: StructuredParams) {
model: params.model,
messages: [...params.baseMessages, ...retryHint],
response_format: params.responseFormat,
+ timeoutMs: params.timeoutMs,
+ retryCount: params.retryCount,
});
try {
@@ -136,6 +143,17 @@ async function runTrainingPlanGenerateTask(task: NonNullable) {
durationDays: number;
focusAreas?: string[];
};
+ const user = await db.getUserById(task.userId);
+ if (!user) {
+ throw new Error("User not found");
+ }
+ const latestSnapshot = await db.getLatestNtrpSnapshot(task.userId);
+ const trainingProfileStatus = db.getTrainingProfileStatus(user, latestSnapshot);
+ if (!trainingProfileStatus.isComplete) {
+ const missingLabels = trainingProfileStatus.missingFields.map((field) => db.TRAINING_PROFILE_FIELD_LABELS[field]).join("、");
+ throw new Error(`训练计划生成前请先完善训练档案:${missingLabels}`);
+ }
+
const analyses = await db.getUserAnalyses(task.userId);
const recentScores = analyses.slice(0, 5).map((analysis) => ({
score: analysis.overallScore ?? null,
@@ -154,6 +172,9 @@ async function runTrainingPlanGenerateTask(task: NonNullable) {
content: buildTrainingPlanPrompt({
...payload,
recentScores,
+ effectiveNtrpRating: trainingProfileStatus.effectiveNtrp,
+ ntrpSource: trainingProfileStatus.ntrpSource,
+ assessmentSnapshot: trainingProfileStatus.assessmentSnapshot,
}),
},
],
@@ -194,6 +215,8 @@ async function runTrainingPlanGenerateTask(task: NonNullable) {
content,
fallbackTitle: `${payload.durationDays}天训练计划`,
}),
+ timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
+ retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
});
const planId = await db.createTrainingPlan({
@@ -280,6 +303,8 @@ async function runTrainingPlanAdjustTask(task: NonNullable) {
content,
fallbackTitle: currentPlan.title,
}),
+ timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
+ retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
});
await db.updateTrainingPlan(payload.planId, {
@@ -418,6 +443,11 @@ async function runMediaFinalizeTask(task: NonNullable) {
exerciseType?: string;
sessionMode?: "practice" | "pk";
durationMinutes?: number;
+ actionCount?: number;
+ actionSummary?: Record;
+ dominantAction?: string;
+ validityStatus?: string;
+ invalidReason?: string;
};
const session = await getRemoteMediaSession(payload.sessionId);
@@ -495,6 +525,11 @@ async function runMediaFinalizeTask(task: NonNullable) {
title: payload.title || session.title,
sessionMode: payload.sessionMode || "practice",
durationMinutes: payload.durationMinutes ?? 5,
+ actionCount: payload.actionCount ?? 0,
+ actionSummary: payload.actionSummary ?? {},
+ dominantAction: payload.dominantAction ?? null,
+ validityStatus: payload.validityStatus ?? "pending",
+ invalidReason: payload.invalidReason ?? null,
});
return {
diff --git a/server/trainingAutomation.ts b/server/trainingAutomation.ts
index 5551bd7..6adad2f 100644
--- a/server/trainingAutomation.ts
+++ b/server/trainingAutomation.ts
@@ -199,21 +199,28 @@ export async function syncRecordingTrainingData(input: {
title: string;
sessionMode?: "practice" | "pk";
durationMinutes?: number | null;
+ actionCount?: number | null;
+ actionSummary?: Record | null;
+ dominantAction?: string | null;
+ validityStatus?: string | null;
+ invalidReason?: string | 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 resolvedExerciseType = input.exerciseType || input.dominantAction || "recording";
+ const planMatch = await db.matchActivePlanForExercise(input.userId, resolvedExerciseType);
+ const exerciseLabel = ACTION_LABELS[resolvedExerciseType || "unknown"] || resolvedExerciseType || input.title;
+ const totalActions = Math.max(0, input.actionCount ?? 0);
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",
+ exerciseType: resolvedExerciseType,
sourceType: "recording",
sourceId: `recording:${input.videoId}`,
videoId: input.videoId,
- actionCount: 0,
+ actionCount: totalActions,
durationMinutes: Math.max(1, input.durationMinutes ?? 5),
completed: 1,
poseScore: null,
@@ -222,8 +229,15 @@ export async function syncRecordingTrainingData(input: {
source: "recording",
sessionMode: input.sessionMode || "practice",
title: input.title,
+ actionCount: totalActions,
+ actionSummary: input.actionSummary ?? {},
+ dominantAction: input.dominantAction ?? null,
+ validityStatus: input.validityStatus ?? "pending",
+ invalidReason: input.invalidReason ?? null,
},
- notes: "自动写入:录制归档",
+ notes: input.validityStatus?.startsWith("invalid")
+ ? `自动写入:录制归档(无效录制)${input.invalidReason ? ` · ${input.invalidReason}` : ""}`
+ : "自动写入:录制归档",
});
if (recordResult.isNew) {
@@ -234,7 +248,12 @@ export async function syncRecordingTrainingData(input: {
deltaSessions: 1,
deltaRecordingCount: 1,
deltaPkCount: input.sessionMode === "pk" ? 1 : 0,
- metadata: { latestRecordingExerciseType: input.exerciseType || "unknown" },
+ deltaTotalActions: totalActions,
+ deltaEffectiveActions: input.validityStatus?.startsWith("invalid") ? 0 : totalActions,
+ metadata: {
+ latestRecordingExerciseType: resolvedExerciseType,
+ latestRecordingValidity: input.validityStatus ?? "pending",
+ },
});
}