Add multi-session auth and changelog tracking
这个提交包含在:
27
AGENTS.md
普通文件
27
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`.
|
||||||
@@ -22,6 +22,7 @@ import Reminders from "./pages/Reminders";
|
|||||||
import VisionLab from "./pages/VisionLab";
|
import VisionLab from "./pages/VisionLab";
|
||||||
import Logs from "./pages/Logs";
|
import Logs from "./pages/Logs";
|
||||||
import AdminConsole from "./pages/AdminConsole";
|
import AdminConsole from "./pages/AdminConsole";
|
||||||
|
import ChangeLog from "./pages/ChangeLog";
|
||||||
|
|
||||||
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
|
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
|
||||||
return (
|
return (
|
||||||
@@ -78,6 +79,9 @@ function Router() {
|
|||||||
<Route path="/logs">
|
<Route path="/logs">
|
||||||
<DashboardRoute component={Logs} />
|
<DashboardRoute component={Logs} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/changelog">
|
||||||
|
<DashboardRoute component={ChangeLog} />
|
||||||
|
</Route>
|
||||||
<Route path="/vision-lab">
|
<Route path="/vision-lab">
|
||||||
<DashboardRoute component={VisionLab} />
|
<DashboardRoute component={VisionLab} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const menuItems: MenuItem[] = [
|
|||||||
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
|
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
|
||||||
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },
|
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },
|
||||||
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
|
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
|
||||||
|
{ icon: ScrollText, label: "更新日志", path: "/changelog", group: "learn" },
|
||||||
{ icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" },
|
{ icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" },
|
||||||
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
|
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
|
||||||
{ icon: Shield, label: "管理系统", path: "/admin", group: "learn", adminOnly: true },
|
{ icon: Shield, label: "管理系统", path: "/admin", group: "learn", adminOnly: true },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Progress } from "@/components/ui/progress";
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { formatDateTimeShanghai } from "@/lib/time";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertTriangle, BellRing, CheckCircle2, Loader2, RefreshCcw } from "lucide-react";
|
import { AlertTriangle, BellRing, CheckCircle2, Loader2, RefreshCcw } from "lucide-react";
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@ export function TaskCenter({ compact = false }: { compact?: boolean }) {
|
|||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{new Date(task.createdAt).toLocaleString("zh-CN")} · 耗时 {formatTaskTiming(task)}
|
{formatDateTimeShanghai(task.createdAt)} · 耗时 {formatTaskTiming(task)}
|
||||||
</span>
|
</span>
|
||||||
{task.status === "failed" ? (
|
{task.status === "failed" ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
242
client/src/lib/actionRecognition.ts
普通文件
242
client/src/lib/actionRecognition.ts
普通文件
@@ -0,0 +1,242 @@
|
|||||||
|
export type ActionType =
|
||||||
|
| "forehand"
|
||||||
|
| "backhand"
|
||||||
|
| "serve"
|
||||||
|
| "volley"
|
||||||
|
| "overhead"
|
||||||
|
| "slice"
|
||||||
|
| "lob"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type Point = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
visibility?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackingState = {
|
||||||
|
prevTimestamp?: number;
|
||||||
|
prevRightWrist?: Point;
|
||||||
|
prevLeftWrist?: Point;
|
||||||
|
prevHipCenter?: Point;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionObservation = {
|
||||||
|
action: ActionType;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionFrame = {
|
||||||
|
action: ActionType;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ACTION_LABELS: Record<ActionType, string> = {
|
||||||
|
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<Record<ActionType, number>>((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),
|
||||||
|
};
|
||||||
|
}
|
||||||
80
client/src/lib/changelog.ts
普通文件
80
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: [
|
||||||
|
"教程库、提醒、通知相关测试通过",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -14,6 +14,12 @@ export type ArchiveStatus =
|
|||||||
| "completed"
|
| "completed"
|
||||||
| "failed";
|
| "failed";
|
||||||
|
|
||||||
|
export type PreviewStatus =
|
||||||
|
| "idle"
|
||||||
|
| "processing"
|
||||||
|
| "ready"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
export type MediaMarker = {
|
export type MediaMarker = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -29,6 +35,7 @@ export type MediaSession = {
|
|||||||
title: string;
|
title: string;
|
||||||
status: MediaSessionStatus;
|
status: MediaSessionStatus;
|
||||||
archiveStatus: ArchiveStatus;
|
archiveStatus: ArchiveStatus;
|
||||||
|
previewStatus: PreviewStatus;
|
||||||
format: string;
|
format: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
qualityPreset: string;
|
qualityPreset: string;
|
||||||
@@ -37,8 +44,10 @@ export type MediaSession = {
|
|||||||
reconnectCount: number;
|
reconnectCount: number;
|
||||||
uploadedSegments: number;
|
uploadedSegments: number;
|
||||||
uploadedBytes: number;
|
uploadedBytes: number;
|
||||||
|
previewSegments: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
|
previewUpdatedAt?: string;
|
||||||
streamConnected: boolean;
|
streamConnected: boolean;
|
||||||
lastStreamAt?: string;
|
lastStreamAt?: string;
|
||||||
playback: {
|
playback: {
|
||||||
|
|||||||
57
client/src/lib/time.ts
普通文件
57
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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { formatDateTimeShanghai } from "@/lib/time";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react";
|
import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react";
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ export default function AdminConsole() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
{task.userName || task.userId} · {new Date(task.createdAt).toLocaleString("zh-CN")}
|
{task.userName || task.userId} · {formatDateTimeShanghai(task.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-[180px]">
|
<div className="min-w-[180px]">
|
||||||
@@ -300,7 +301,7 @@ export default function AdminConsole() {
|
|||||||
{item.targetUserId ? <Badge variant="outline">目标用户 {item.targetUserId}</Badge> : null}
|
{item.targetUserId ? <Badge variant="outline">目标用户 {item.targetUserId}</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
管理员 {item.adminName || item.adminUserId} · {new Date(item.createdAt).toLocaleString("zh-CN")}
|
管理员 {item.adminName || item.adminUserId} · {formatDateTimeShanghai(item.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{item.entityId ? <div className="text-sm text-muted-foreground">实体 {item.entityId}</div> : null}
|
{item.entityId ? <div className="text-sm text-muted-foreground">实体 {item.entityId}</div> : null}
|
||||||
|
|||||||
66
client/src/pages/ChangeLog.tsx
普通文件
66
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.1),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-6 shadow-sm">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<ScrollText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">更新日志</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
这里会按版本记录已上线的新功能、对应仓库版本和验证结果。后续每次改动测试通过并提交后,都会继续追加到这里。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{CHANGE_LOG_ENTRIES.map((entry) => (
|
||||||
|
<Card key={`${entry.version}-${entry.repoVersion}`} className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{entry.version}</CardTitle>
|
||||||
|
<CardDescription className="mt-2">{entry.summary}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="secondary">{formatDateShanghai(entry.releaseDate)}</Badge>
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
{entry.repoVersion}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">上线内容</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{entry.features.map((feature) => (
|
||||||
|
<Badge key={feature} variant="secondary">{feature}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<ListChecks className="h-4 w-4 text-primary" />
|
||||||
|
验证记录
|
||||||
|
</div>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||||
|
{entry.tests.map((item) => <li key={item}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { formatDateShanghai } from "@/lib/time";
|
||||||
import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-react";
|
import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-react";
|
||||||
|
|
||||||
const CATEGORY_META: Record<string, { label: string; tone: string }> = {
|
const CATEGORY_META: Record<string, { label: string; tone: string }> = {
|
||||||
@@ -17,11 +18,12 @@ const CATEGORY_META: Record<string, { label: string; tone: string }> = {
|
|||||||
rating: { label: "评分", tone: "bg-violet-500/10 text-violet-700" },
|
rating: { label: "评分", tone: "bg-violet-500/10 text-violet-700" },
|
||||||
pk: { label: "训练 PK", tone: "bg-orange-500/10 text-orange-700" },
|
pk: { label: "训练 PK", tone: "bg-orange-500/10 text-orange-700" },
|
||||||
plan: { label: "计划匹配", tone: "bg-cyan-500/10 text-cyan-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) {
|
function getProgressText(item: any) {
|
||||||
if (item.unlockedAt) {
|
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)}`;
|
return `${Math.round(item.currentValue || 0)} / ${Math.round(item.targetValue || 0)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { formatDateTimeShanghai } from "@/lib/time";
|
||||||
import { Activity, Award, ChevronRight, Clock3, Sparkles, Swords, Target, Video } from "lucide-react";
|
import { Activity, Award, ChevronRight, Clock3, Sparkles, Swords, Target, Video } from "lucide-react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
@@ -199,7 +200,7 @@ export default function Dashboard() {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{session.title}</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 className="mt-1 text-xs text-muted-foreground">{formatDateTimeShanghai(session.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{Math.round(session.overallScore || 0)} 分</Badge>
|
<Badge variant="outline">{Math.round(session.overallScore || 0)} 分</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { formatDateTimeShanghai } from "@/lib/time";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { applyTrackZoom, type CameraQualityPreset, getCameraVideoConstraints, getLiveAnalysisBitrate, readTrackZoomState } from "@/lib/camera";
|
import { applyTrackZoom, type CameraQualityPreset, getCameraVideoConstraints, getLiveAnalysisBitrate, readTrackZoomState } from "@/lib/camera";
|
||||||
import {
|
import {
|
||||||
@@ -910,7 +911,10 @@ export default function LiveCamera() {
|
|||||||
const format = recorderMimeTypeRef.current.includes("mp4") ? "mp4" : "webm";
|
const format = recorderMimeTypeRef.current.includes("mp4") ? "mp4" : "webm";
|
||||||
const fileBase64 = await blobToBase64(recordedBlob);
|
const fileBase64 = await blobToBase64(recordedBlob);
|
||||||
uploadedVideo = await uploadMutation.mutateAsync({
|
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,
|
format,
|
||||||
fileSize: recordedBlob.size,
|
fileSize: recordedBlob.size,
|
||||||
exerciseType: dominantAction,
|
exerciseType: dominantAction,
|
||||||
@@ -1605,7 +1609,7 @@ export default function LiveCamera() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{session.title}</div>
|
<div className="font-medium">{session.title}</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
{new Date(session.createdAt).toLocaleString("zh-CN")}
|
{formatDateTimeShanghai(session.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={ACTION_META[(session.dominantAction as ActionType) || "unknown"].tone}>
|
<Badge className={ACTION_META[(session.dominantAction as ActionType) || "unknown"].tone}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { formatDateTimeShanghai } from "@/lib/time";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertTriangle, BellRing, CheckCircle2, ClipboardList, Loader2, RefreshCcw } from "lucide-react";
|
import { AlertTriangle, BellRing, CheckCircle2, ClipboardList, Loader2, RefreshCcw } from "lucide-react";
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ export default function Logs() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-base">{task.title}</CardTitle>
|
<CardTitle className="text-base">{task.title}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{new Date(task.createdAt).toLocaleString("zh-CN")} · {task.type}
|
{formatDateTimeShanghai(task.createdAt)} · {task.type}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={task.status === "failed" ? "destructive" : "secondary"}>
|
<Badge variant={task.status === "failed" ? "destructive" : "secondary"}>
|
||||||
@@ -233,7 +234,7 @@ export default function Logs() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-base">{item.title}</CardTitle>
|
<CardTitle className="text-base">{item.title}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{new Date(item.createdAt).toLocaleString("zh-CN")} · {item.notificationType}
|
{formatDateTimeShanghai(item.createdAt)} · {item.notificationType}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={item.isRead ? "secondary" : "outline"}>
|
<Badge variant={item.isRead ? "secondary" : "outline"}>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
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 {
|
import {
|
||||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
LineChart, Line, Legend
|
LineChart, Line, Legend
|
||||||
@@ -17,6 +19,7 @@ export default function Progress() {
|
|||||||
const { data: analyses } = trpc.analysis.list.useQuery();
|
const { data: analyses } = trpc.analysis.list.useQuery();
|
||||||
const { data: stats } = trpc.profile.stats.useQuery();
|
const { data: stats } = trpc.profile.stats.useQuery();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
const [expandedRecordId, setExpandedRecordId] = useState<number | null>(null);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +32,7 @@ export default function Progress() {
|
|||||||
// Aggregate data by date for charts
|
// Aggregate data by date for charts
|
||||||
const dateMap = new Map<string, { date: string; sessions: number; minutes: number; avgScore: number; scores: number[] }>();
|
const dateMap = new Map<string, { date: string; sessions: number; minutes: number; avgScore: number; scores: number[] }>();
|
||||||
(records || []).forEach((r: any) => {
|
(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: [] };
|
const existing = dateMap.get(date) || { date, sessions: 0, minutes: 0, avgScore: 0, scores: [] };
|
||||||
existing.sessions++;
|
existing.sessions++;
|
||||||
existing.minutes += r.durationMinutes || 0;
|
existing.minutes += r.durationMinutes || 0;
|
||||||
@@ -44,7 +47,7 @@ export default function Progress() {
|
|||||||
|
|
||||||
// Analysis score trend
|
// Analysis score trend
|
||||||
const scoreTrend = (analyses || []).map((a: any) => ({
|
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),
|
overall: Math.round(a.overallScore || 0),
|
||||||
consistency: Math.round(a.strokeConsistency || 0),
|
consistency: Math.round(a.strokeConsistency || 0),
|
||||||
footwork: Math.round(a.footworkScore || 0),
|
footwork: Math.round(a.footworkScore || 0),
|
||||||
@@ -179,9 +182,10 @@ export default function Progress() {
|
|||||||
{(records?.length || 0) > 0 ? (
|
{(records?.length || 0) > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(records || []).slice(0, 20).map((record: any) => (
|
{(records || []).slice(0, 20).map((record: any) => (
|
||||||
<div key={record.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
<div key={record.id} className="border-b py-2 last:border-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className={`h-8 w-8 rounded-lg flex items-center justify-center ${
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`mt-0.5 h-8 w-8 rounded-lg flex items-center justify-center ${
|
||||||
record.completed ? "bg-green-50 text-green-600" : "bg-muted text-muted-foreground"
|
record.completed ? "bg-green-50 text-green-600" : "bg-muted text-muted-foreground"
|
||||||
}`}>
|
}`}>
|
||||||
{record.completed ? <CheckCircle2 className="h-4 w-4" /> : <Activity className="h-4 w-4" />}
|
{record.completed ? <CheckCircle2 className="h-4 w-4" /> : <Activity className="h-4 w-4" />}
|
||||||
@@ -189,10 +193,15 @@ export default function Progress() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{record.exerciseName}</p>
|
<p className="text-sm font-medium">{record.exerciseName}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{new Date(record.trainingDate || record.createdAt).toLocaleDateString("zh-CN")}
|
{formatDateTimeShanghai(record.trainingDate || record.createdAt)}
|
||||||
{record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
|
{record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
|
||||||
{record.sourceType ? ` · ${record.sourceType}` : ""}
|
{record.sourceType ? ` · ${record.sourceType}` : ""}
|
||||||
</p>
|
</p>
|
||||||
|
{record.actionCount ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
动作数 {record.actionCount}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -204,8 +213,74 @@ export default function Progress() {
|
|||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-xs">进行中</Badge>
|
<Badge variant="outline" className="text-xs">进行中</Badge>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExpandedRecordId((current) => current === record.id ? null : record.id)}
|
||||||
|
>
|
||||||
|
{expandedRecordId === record.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{expandedRecordId === record.id ? (
|
||||||
|
<div className="mt-3 rounded-2xl border border-border/60 bg-muted/20 p-4 text-sm">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">记录时间</div>
|
||||||
|
<div className="mt-1 font-medium">{formatDateTimeShanghai(record.trainingDate || record.createdAt, { second: "2-digit" })}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">动作数据</div>
|
||||||
|
<div className="mt-1 font-medium">动作数 {record.actionCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{record.metadata ? (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{record.metadata.dominantAction ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">主动作</div>
|
||||||
|
<div className="mt-1 font-medium">{String(record.metadata.dominantAction)}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{record.metadata.actionSummary && Object.keys(record.metadata.actionSummary).length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">动作明细</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{Object.entries(record.metadata.actionSummary as Record<string, number>)
|
||||||
|
.filter(([, count]) => Number(count) > 0)
|
||||||
|
.map(([actionType, count]) => (
|
||||||
|
<Badge key={actionType} variant="secondary">
|
||||||
|
{actionType} {count} 次
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{record.metadata.validityStatus ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">录制有效性</div>
|
||||||
|
<div className="mt-1 font-medium">{String(record.metadata.validityStatus)}</div>
|
||||||
|
{record.metadata.invalidReason ? (
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{String(record.metadata.invalidReason)}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{record.notes ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">备注</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">{record.notes}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||||
|
import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Activity, Award, Loader2, RefreshCw, Radar, TrendingUp } from "lucide-react";
|
import { Activity, Award, Loader2, RefreshCw, Radar, TrendingUp } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -69,7 +70,7 @@ export default function Rating() {
|
|||||||
|
|
||||||
const trendData = useMemo(
|
const trendData = useMemo(
|
||||||
() => history.map((item: any) => ({
|
() => history.map((item: any) => ({
|
||||||
date: new Date(item.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
|
date: formatMonthDayShanghai(item.createdAt),
|
||||||
rating: item.rating,
|
rating: item.rating,
|
||||||
})).reverse(),
|
})).reverse(),
|
||||||
[history],
|
[history],
|
||||||
@@ -131,7 +132,7 @@ export default function Rating() {
|
|||||||
{latestSnapshot?.triggerType ? <Badge variant="outline">来源 {latestSnapshot.triggerType}</Badge> : null}
|
{latestSnapshot?.triggerType ? <Badge variant="outline">来源 {latestSnapshot.triggerType}</Badge> : null}
|
||||||
{latestSnapshot?.createdAt ? (
|
{latestSnapshot?.createdAt ? (
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
刷新于 {new Date(latestSnapshot.createdAt).toLocaleString("zh-CN")}
|
刷新于 {formatDateTimeShanghai(latestSnapshot.createdAt)}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +253,7 @@ export default function Rating() {
|
|||||||
<span className="font-medium">NTRP {Number(item.rating || 0).toFixed(1)}</span>
|
<span className="font-medium">NTRP {Number(item.rating || 0).toFixed(1)}</span>
|
||||||
<Badge variant="outline">{item.triggerType}</Badge>
|
<Badge variant="outline">{item.triggerType}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{new Date(item.createdAt).toLocaleString("zh-CN")}</div>
|
<div className="mt-1 text-xs text-muted-foreground">{formatDateTimeShanghai(item.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
|
|||||||
@@ -23,7 +23,16 @@ import { Slider } from "@/components/ui/slider";
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||||
import { toast } from "sonner";
|
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 { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera";
|
||||||
|
import { formatDateTimeShanghai } from "@/lib/time";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Camera,
|
Camera,
|
||||||
@@ -68,6 +77,8 @@ const SEGMENT_LENGTH_MS = 60_000;
|
|||||||
const MOTION_SAMPLE_MS = 1_500;
|
const MOTION_SAMPLE_MS = 1_500;
|
||||||
const MOTION_THRESHOLD = 18;
|
const MOTION_THRESHOLD = 18;
|
||||||
const MOTION_COOLDOWN_MS = 8_000;
|
const MOTION_COOLDOWN_MS = 8_000;
|
||||||
|
const ACTION_SAMPLE_MS = 2_500;
|
||||||
|
const INVALID_RECORDING_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
const QUALITY_PRESETS = {
|
const QUALITY_PRESETS = {
|
||||||
economy: {
|
economy: {
|
||||||
@@ -151,6 +162,30 @@ function formatFileSize(bytes: number) {
|
|||||||
return `${(bytes / 1024 / 1024).toFixed(bytes > 20 * 1024 * 1024 ? 1 : 2)} MB`;
|
return `${(bytes / 1024 / 1024).toFixed(bytes > 20 * 1024 * 1024 ? 1 : 2)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createActionSummary(): Record<ActionType, number> {
|
||||||
|
return {
|
||||||
|
forehand: 0,
|
||||||
|
backhand: 0,
|
||||||
|
serve: 0,
|
||||||
|
volley: 0,
|
||||||
|
overhead: 0,
|
||||||
|
slice: 0,
|
||||||
|
lob: 0,
|
||||||
|
unknown: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeActions(actionSummary: Record<ActionType, number>) {
|
||||||
|
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() {
|
export default function Recorder() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -170,11 +205,22 @@ export default function Recorder() {
|
|||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const peerRef = useRef<RTCPeerConnection | null>(null);
|
const peerRef = useRef<RTCPeerConnection | null>(null);
|
||||||
|
const actionPoseRef = useRef<any>(null);
|
||||||
const currentSegmentStartedAtRef = useRef<number>(0);
|
const currentSegmentStartedAtRef = useRef<number>(0);
|
||||||
const recordingStartedAtRef = useRef<number>(0);
|
const recordingStartedAtRef = useRef<number>(0);
|
||||||
const segmentSequenceRef = useRef(0);
|
const segmentSequenceRef = useRef(0);
|
||||||
const motionFrameRef = useRef<Uint8ClampedArray | null>(null);
|
const motionFrameRef = useRef<Uint8ClampedArray | null>(null);
|
||||||
const lastMotionMarkerAtRef = useRef(0);
|
const lastMotionMarkerAtRef = useRef(0);
|
||||||
|
const actionTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const actionFrameInFlightRef = useRef(false);
|
||||||
|
const actionTrackingRef = useRef<TrackingState>({});
|
||||||
|
const actionHistoryRef = useRef<ActionObservation[]>([]);
|
||||||
|
const actionSummaryRef = useRef<Record<ActionType, number>>(createActionSummary());
|
||||||
|
const lastRecognizedActionAtRef = useRef<number>(0);
|
||||||
|
const lastActionMarkerAtRef = useRef<number>(0);
|
||||||
|
const latestRecognizedActionRef = useRef<ActionType>("unknown");
|
||||||
|
const validityOverrideRef = useRef<"valid" | "invalid" | null>(null);
|
||||||
|
const invalidAutoMarkedRef = useRef(false);
|
||||||
const pendingUploadsRef = useRef<PendingSegment[]>([]);
|
const pendingUploadsRef = useRef<PendingSegment[]>([]);
|
||||||
const uploadInFlightRef = useRef(false);
|
const uploadInFlightRef = useRef(false);
|
||||||
const currentSessionRef = useRef<MediaSession | null>(null);
|
const currentSessionRef = useRef<MediaSession | null>(null);
|
||||||
@@ -202,13 +248,17 @@ export default function Recorder() {
|
|||||||
const [uploadedSegments, setUploadedSegments] = useState(0);
|
const [uploadedSegments, setUploadedSegments] = useState(0);
|
||||||
const [uploadBytes, setUploadBytes] = useState(0);
|
const [uploadBytes, setUploadBytes] = useState(0);
|
||||||
const [cameraError, setCameraError] = useState("");
|
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<MediaSession | null>(null);
|
const [mediaSession, setMediaSession] = useState<MediaSession | null>(null);
|
||||||
const [markers, setMarkers] = useState<MediaMarker[]>([]);
|
const [markers, setMarkers] = useState<MediaMarker[]>([]);
|
||||||
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
|
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
|
||||||
const [immersivePreview, setImmersivePreview] = useState(false);
|
const [immersivePreview, setImmersivePreview] = useState(false);
|
||||||
const [archiveTaskId, setArchiveTaskId] = useState<string | null>(null);
|
const [archiveTaskId, setArchiveTaskId] = useState<string | null>(null);
|
||||||
const [zoomState, setZoomState] = useState(() => readTrackZoomState(null));
|
const [zoomState, setZoomState] = useState(() => readTrackZoomState(null));
|
||||||
|
const [actionSummary, setActionSummary] = useState<Record<ActionType, number>>(() => createActionSummary());
|
||||||
|
const [currentDetectedAction, setCurrentDetectedAction] = useState<ActionType>("unknown");
|
||||||
|
const [recordingValidity, setRecordingValidity] = useState<"pending" | "valid" | "invalid">("pending");
|
||||||
|
const [recordingValidityReason, setRecordingValidityReason] = useState("");
|
||||||
|
|
||||||
const mobile = useMemo(() => isMobileDevice(), []);
|
const mobile = useMemo(() => isMobileDevice(), []);
|
||||||
const mimeType = useMemo(() => pickRecorderMimeType(), []);
|
const mimeType = useMemo(() => pickRecorderMimeType(), []);
|
||||||
@@ -224,6 +274,7 @@ export default function Recorder() {
|
|||||||
mediaSession?.archiveStatus === "queued" ||
|
mediaSession?.archiveStatus === "queued" ||
|
||||||
mediaSession?.archiveStatus === "processing";
|
mediaSession?.archiveStatus === "processing";
|
||||||
const canLeaveRecorderPage = !uploadStillDraining && (archiveRunning || mode === "archived");
|
const canLeaveRecorderPage = !uploadStillDraining && (archiveRunning || mode === "archived");
|
||||||
|
const recognizedActionItems = useMemo(() => summarizeActions(actionSummary), [actionSummary]);
|
||||||
|
|
||||||
const syncSessionState = useCallback((session: MediaSession | null) => {
|
const syncSessionState = useCallback((session: MediaSession | null) => {
|
||||||
currentSessionRef.current = session;
|
currentSessionRef.current = session;
|
||||||
@@ -273,9 +324,11 @@ export default function Recorder() {
|
|||||||
if (segmentTickerRef.current) clearInterval(segmentTickerRef.current);
|
if (segmentTickerRef.current) clearInterval(segmentTickerRef.current);
|
||||||
if (timerTickerRef.current) clearInterval(timerTickerRef.current);
|
if (timerTickerRef.current) clearInterval(timerTickerRef.current);
|
||||||
if (motionTickerRef.current) clearInterval(motionTickerRef.current);
|
if (motionTickerRef.current) clearInterval(motionTickerRef.current);
|
||||||
|
if (actionTickerRef.current) clearInterval(actionTickerRef.current);
|
||||||
segmentTickerRef.current = null;
|
segmentTickerRef.current = null;
|
||||||
timerTickerRef.current = null;
|
timerTickerRef.current = null;
|
||||||
motionTickerRef.current = null;
|
motionTickerRef.current = null;
|
||||||
|
actionTickerRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closePeer = useCallback(() => {
|
const closePeer = useCallback(() => {
|
||||||
@@ -479,7 +532,11 @@ export default function Recorder() {
|
|||||||
await stopped;
|
await stopped;
|
||||||
}, [stopTickers]);
|
}, [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;
|
const sessionId = currentSessionRef.current?.id;
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
@@ -508,6 +565,132 @@ export default function Recorder() {
|
|||||||
}
|
}
|
||||||
}, [syncSessionState]);
|
}, [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 sampleMotion = useCallback(() => {
|
||||||
const video = liveVideoRef.current;
|
const video = liveVideoRef.current;
|
||||||
const canvas = motionCanvasRef.current;
|
const canvas = motionCanvasRef.current;
|
||||||
@@ -680,11 +863,22 @@ export default function Recorder() {
|
|||||||
segmentSequenceRef.current = 0;
|
segmentSequenceRef.current = 0;
|
||||||
motionFrameRef.current = null;
|
motionFrameRef.current = null;
|
||||||
pendingUploadsRef.current = [];
|
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 stream = await ensurePreviewStream();
|
||||||
const sessionResponse = await createMediaSession({
|
const sessionResponse = await createMediaSession({
|
||||||
userId: String(user.id),
|
userId: String(user.id),
|
||||||
title: title.trim() || `训练录制 ${new Date().toLocaleString("zh-CN")}`,
|
title: title.trim() || `训练录制 ${formatDateTimeShanghai(new Date())}`,
|
||||||
format: "webm",
|
format: "webm",
|
||||||
mimeType,
|
mimeType,
|
||||||
qualityPreset,
|
qualityPreset,
|
||||||
@@ -695,14 +889,16 @@ export default function Recorder() {
|
|||||||
await startRealtimePush(stream, sessionResponse.session.id);
|
await startRealtimePush(stream, sessionResponse.session.id);
|
||||||
|
|
||||||
recordingStartedAtRef.current = Date.now();
|
recordingStartedAtRef.current = Date.now();
|
||||||
|
lastRecognizedActionAtRef.current = recordingStartedAtRef.current;
|
||||||
startRecorderLoop(stream);
|
startRecorderLoop(stream);
|
||||||
|
await startActionSampling();
|
||||||
setMode("recording");
|
setMode("recording");
|
||||||
toast.success("录制已开始,已同步启动实时推流");
|
toast.success("录制已开始,已同步启动实时推流");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setMode("idle");
|
setMode("idle");
|
||||||
toast.error(`启动录制失败: ${error?.message || "未知错误"}`);
|
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 finishRecording = useCallback(async () => {
|
||||||
const session = currentSessionRef.current;
|
const session = currentSessionRef.current;
|
||||||
@@ -712,6 +908,7 @@ export default function Recorder() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setMode("finalizing");
|
setMode("finalizing");
|
||||||
|
await stopActionSampling();
|
||||||
await stopRecorder();
|
await stopRecorder();
|
||||||
await flushPendingSegments();
|
await flushPendingSegments();
|
||||||
closePeer();
|
closePeer();
|
||||||
@@ -728,13 +925,25 @@ export default function Recorder() {
|
|||||||
exerciseType: "recording",
|
exerciseType: "recording",
|
||||||
sessionMode,
|
sessionMode,
|
||||||
durationMinutes: Math.max(1, Math.round((Date.now() - recordingStartedAtRef.current) / 60000)),
|
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("录制已提交,后台正在整理回放文件");
|
toast.success("录制已提交,后台正在整理回放文件");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`结束录制失败: ${error?.message || "未知错误"}`);
|
toast.error(`结束录制失败: ${error?.message || "未知错误"}`);
|
||||||
setMode("recording");
|
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 () => {
|
const resetRecorder = useCallback(async () => {
|
||||||
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
||||||
@@ -745,10 +954,18 @@ export default function Recorder() {
|
|||||||
pendingUploadsRef.current = [];
|
pendingUploadsRef.current = [];
|
||||||
uploadInFlightRef.current = false;
|
uploadInFlightRef.current = false;
|
||||||
motionFrameRef.current = null;
|
motionFrameRef.current = null;
|
||||||
|
await stopActionSampling().catch(() => {});
|
||||||
|
actionTrackingRef.current = {};
|
||||||
|
actionHistoryRef.current = [];
|
||||||
|
actionSummaryRef.current = createActionSummary();
|
||||||
currentSessionRef.current = null;
|
currentSessionRef.current = null;
|
||||||
setArchiveTaskId(null);
|
setArchiveTaskId(null);
|
||||||
setMediaSession(null);
|
setMediaSession(null);
|
||||||
setMarkers([]);
|
setMarkers([]);
|
||||||
|
setActionSummary(createActionSummary());
|
||||||
|
setCurrentDetectedAction("unknown");
|
||||||
|
setRecordingValidity("pending");
|
||||||
|
setRecordingValidityReason("");
|
||||||
setDurationMs(0);
|
setDurationMs(0);
|
||||||
setQueuedSegments(0);
|
setQueuedSegments(0);
|
||||||
setQueuedBytes(0);
|
setQueuedBytes(0);
|
||||||
@@ -758,7 +975,7 @@ export default function Recorder() {
|
|||||||
setConnectionState("new");
|
setConnectionState("new");
|
||||||
setCameraError("");
|
setCameraError("");
|
||||||
setMode("idle");
|
setMode("idle");
|
||||||
}, [closePeer, stopCamera, stopRecorder, stopTickers]);
|
}, [closePeer, stopActionSampling, stopCamera, stopRecorder, stopTickers]);
|
||||||
|
|
||||||
const flipCamera = useCallback(async () => {
|
const flipCamera = useCallback(async () => {
|
||||||
const nextFacingMode = facingMode === "user" ? "environment" : "user";
|
const nextFacingMode = facingMode === "user" ? "environment" : "user";
|
||||||
@@ -844,6 +1061,7 @@ export default function Recorder() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
||||||
stopTickers();
|
stopTickers();
|
||||||
|
void stopActionSampling();
|
||||||
if (recorderRef.current && recorderRef.current.state !== "inactive") {
|
if (recorderRef.current && recorderRef.current.state !== "inactive") {
|
||||||
try {
|
try {
|
||||||
recorderRef.current.stop();
|
recorderRef.current.stop();
|
||||||
@@ -856,7 +1074,7 @@ export default function Recorder() {
|
|||||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [closePeer, stopTickers]);
|
}, [closePeer, stopActionSampling, stopTickers]);
|
||||||
|
|
||||||
const statusBadge = useMemo(() => {
|
const statusBadge = useMemo(() => {
|
||||||
if (mode === "finalizing") {
|
if (mode === "finalizing") {
|
||||||
@@ -1333,6 +1551,53 @@ export default function Recorder() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-2xl bg-muted/35 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">动作有效性</div>
|
||||||
|
<div className="mt-2 text-sm font-medium">
|
||||||
|
{recordingValidity === "valid" ? "有效录制" : recordingValidity === "invalid" ? "无效录制" : "待判定"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||||
|
{recordingValidityReason || "录制中会自动抽样动作帧并进行判定。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-muted/35 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前识别动作</div>
|
||||||
|
<div className="mt-2 text-sm font-medium">
|
||||||
|
{RECOGNIZED_ACTION_LABELS[currentDetectedAction] || "未知动作"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||||
|
每 {Math.round(ACTION_SAMPLE_MS / 1000)} 秒抽样动作帧;连续 {Math.round(INVALID_RECORDING_WINDOW_MS / 1000)} 秒无有效动作会自动标记无效。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void updateRecordingValidity("valid")}
|
||||||
|
disabled={!currentSessionRef.current}
|
||||||
|
>
|
||||||
|
手工恢复有效
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void updateRecordingValidity("invalid")}
|
||||||
|
disabled={!currentSessionRef.current}
|
||||||
|
>
|
||||||
|
手工标记无效
|
||||||
|
</Button>
|
||||||
|
{mediaSession?.playback.previewUrl ? (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<a href={mediaSession.playback.previewUrl} target="_blank" rel="noreferrer">
|
||||||
|
查看滚动预归档
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span>已上传文件</span>
|
<span>已上传文件</span>
|
||||||
@@ -1354,6 +1619,13 @@ export default function Recorder() {
|
|||||||
<span>服务端状态</span>
|
<span>服务端状态</span>
|
||||||
<span className="font-medium">{mediaSession?.status || "idle"}</span>
|
<span className="font-medium">{mediaSession?.status || "idle"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>滚动预归档</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{mediaSession?.previewStatus || "idle"}
|
||||||
|
{typeof mediaSession?.previewSegments === "number" ? ` · ${mediaSession.previewSegments} 段` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(mode === "finalizing" || mode === "archived" || mediaSession?.archiveStatus === "failed") && (
|
{(mode === "finalizing" || mode === "archived" || mediaSession?.archiveStatus === "failed") && (
|
||||||
@@ -1404,6 +1676,19 @@ export default function Recorder() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{recognizedActionItems.length > 0 ? (
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-muted/25 p-4">
|
||||||
|
<div className="text-sm font-medium">识别到的动作数据</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{recognizedActionItems.map((item) => (
|
||||||
|
<Badge key={item.actionType} variant="secondary">
|
||||||
|
{item.label} {item.count} 次
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{cameraError && (
|
{cameraError && (
|
||||||
<div className="rounded-2xl border border-destructive/20 bg-destructive/5 p-4 text-sm text-destructive">
|
<div className="rounded-2xl border border-destructive/20 bg-destructive/5 p-4 text-sm text-destructive">
|
||||||
{cameraError}
|
{cameraError}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -145,7 +146,7 @@ function buildClipCueSheet(title: string, clips: ClipDraft[]) {
|
|||||||
` 时长: ${formatSeconds(Math.max(0, clip.endSec - clip.startSec))}\n` +
|
` 时长: ${formatSeconds(Math.max(0, clip.endSec - clip.startSec))}\n` +
|
||||||
` 来源: ${clip.source === "manual" ? "手动" : "分析建议"}\n` +
|
` 来源: ${clip.source === "manual" ? "手动" : "分析建议"}\n` +
|
||||||
` 备注: ${clip.notes || "无"}`
|
` 备注: ${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 {
|
function createEmptyVideoDraft(): VideoCreateDraft {
|
||||||
@@ -440,7 +441,7 @@ export default function Videos() {
|
|||||||
) : null}
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{new Date(video.createdAt).toLocaleDateString("zh-CN")}
|
{formatDateShanghai(video.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{((video.fileSize || 0) / 1024 / 1024).toFixed(1)}MB
|
{((video.fileSize || 0) / 1024 / 1024).toFixed(1)}MB
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { formatDateTimeShanghai } from "@/lib/time";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||||
import { Database, Image as ImageIcon, Loader2, Microscope, ShieldCheck, Sparkles } from "lucide-react";
|
import { Database, Image as ImageIcon, Loader2, Microscope, ShieldCheck, Sparkles } from "lucide-react";
|
||||||
@@ -358,7 +359,7 @@ export default function VisionLab() {
|
|||||||
<Badge variant="outline">{run.exerciseType}</Badge>
|
<Badge variant="outline">{run.exerciseType}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{new Date(run.createdAt).toLocaleString("zh-CN")}
|
{formatDateTimeShanghai(run.createdAt)}
|
||||||
{user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}
|
{user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
# Tennis Training Hub - 变更日志
|
# 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)
|
## v3.0.0 (2026-03-14)
|
||||||
|
|
||||||
### 新增功能
|
### 新增功能
|
||||||
|
|||||||
144
media/main.go
144
media/main.go
@@ -44,6 +44,15 @@ const (
|
|||||||
ArchiveFailed ArchiveStatus = "failed"
|
ArchiveFailed ArchiveStatus = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type PreviewStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PreviewIdle PreviewStatus = "idle"
|
||||||
|
PreviewProcessing PreviewStatus = "processing"
|
||||||
|
PreviewReady PreviewStatus = "ready"
|
||||||
|
PreviewFailed PreviewStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
type PlaybackInfo struct {
|
type PlaybackInfo struct {
|
||||||
WebMURL string `json:"webmUrl,omitempty"`
|
WebMURL string `json:"webmUrl,omitempty"`
|
||||||
MP4URL string `json:"mp4Url,omitempty"`
|
MP4URL string `json:"mp4Url,omitempty"`
|
||||||
@@ -77,6 +86,7 @@ type Session struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Status SessionStatus `json:"status"`
|
Status SessionStatus `json:"status"`
|
||||||
ArchiveStatus ArchiveStatus `json:"archiveStatus"`
|
ArchiveStatus ArchiveStatus `json:"archiveStatus"`
|
||||||
|
PreviewStatus PreviewStatus `json:"previewStatus"`
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
MimeType string `json:"mimeType"`
|
MimeType string `json:"mimeType"`
|
||||||
QualityPreset string `json:"qualityPreset"`
|
QualityPreset string `json:"qualityPreset"`
|
||||||
@@ -85,11 +95,13 @@ type Session struct {
|
|||||||
ReconnectCount int `json:"reconnectCount"`
|
ReconnectCount int `json:"reconnectCount"`
|
||||||
UploadedSegments int `json:"uploadedSegments"`
|
UploadedSegments int `json:"uploadedSegments"`
|
||||||
UploadedBytes int64 `json:"uploadedBytes"`
|
UploadedBytes int64 `json:"uploadedBytes"`
|
||||||
|
PreviewSegments int `json:"previewSegments"`
|
||||||
DurationMS int64 `json:"durationMs"`
|
DurationMS int64 `json:"durationMs"`
|
||||||
LastError string `json:"lastError,omitempty"`
|
LastError string `json:"lastError,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
FinalizedAt string `json:"finalizedAt,omitempty"`
|
FinalizedAt string `json:"finalizedAt,omitempty"`
|
||||||
|
PreviewUpdatedAt string `json:"previewUpdatedAt,omitempty"`
|
||||||
StreamConnected bool `json:"streamConnected"`
|
StreamConnected bool `json:"streamConnected"`
|
||||||
LastStreamAt string `json:"lastStreamAt,omitempty"`
|
LastStreamAt string `json:"lastStreamAt,omitempty"`
|
||||||
Playback PlaybackInfo `json:"playback"`
|
Playback PlaybackInfo `json:"playback"`
|
||||||
@@ -159,7 +171,7 @@ func newSessionStore(rootDir string) (*sessionStore, error) {
|
|||||||
if err := os.MkdirAll(store.public, 0o755); err != nil {
|
if err := os.MkdirAll(store.public, 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := store.load(); err != nil {
|
if err := store.refreshFromDisk(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, session := range store.sessions {
|
for _, session := range store.sessions {
|
||||||
@@ -168,12 +180,13 @@ func newSessionStore(rootDir string) (*sessionStore, error) {
|
|||||||
return store, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sessionStore) load() error {
|
func (s *sessionStore) loadSessionsFromDisk() (map[string]*Session, error) {
|
||||||
pattern := filepath.Join(s.rootDir, "sessions", "*", "session.json")
|
pattern := filepath.Join(s.rootDir, "sessions", "*", "session.json")
|
||||||
files, err := filepath.Glob(pattern)
|
files, err := filepath.Glob(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
sessions := make(map[string]*Session, len(files))
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
body, readErr := os.ReadFile(file)
|
body, readErr := os.ReadFile(file)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
@@ -183,8 +196,19 @@ func (s *sessionStore) load() error {
|
|||||||
if unmarshalErr := json.Unmarshal(body, &session); unmarshalErr != nil {
|
if unmarshalErr := json.Unmarshal(body, &session); unmarshalErr != nil {
|
||||||
continue
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +252,7 @@ func (s *sessionStore) createSession(input CreateSessionRequest) (*Session, erro
|
|||||||
Title: strings.TrimSpace(input.Title),
|
Title: strings.TrimSpace(input.Title),
|
||||||
Status: StatusCreated,
|
Status: StatusCreated,
|
||||||
ArchiveStatus: ArchiveIdle,
|
ArchiveStatus: ArchiveIdle,
|
||||||
|
PreviewStatus: PreviewIdle,
|
||||||
Format: defaultString(input.Format, "webm"),
|
Format: defaultString(input.Format, "webm"),
|
||||||
MimeType: defaultString(input.MimeType, "video/webm"),
|
MimeType: defaultString(input.MimeType, "video/webm"),
|
||||||
QualityPreset: defaultString(input.QualityPreset, "balanced"),
|
QualityPreset: defaultString(input.QualityPreset, "balanced"),
|
||||||
@@ -295,13 +320,20 @@ func (s *sessionStore) updateSession(id string, update func(*Session) error) (*S
|
|||||||
return cloneSession(session), nil
|
return cloneSession(session), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sessionStore) listFinalizingSessions() []*Session {
|
func (s *sessionStore) listProcessableSessions() []*Session {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
items := make([]*Session, 0, len(s.sessions))
|
items := make([]*Session, 0, len(s.sessions))
|
||||||
for _, session := range s.sessions {
|
for _, session := range s.sessions {
|
||||||
|
if len(session.Segments) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if session.ArchiveStatus == ArchiveQueued || session.ArchiveStatus == ArchiveProcessing {
|
if session.ArchiveStatus == ArchiveQueued || session.ArchiveStatus == ArchiveProcessing {
|
||||||
items = append(items, cloneSession(session))
|
items = append(items, cloneSession(session))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if session.PreviewSegments < len(session.Segments) && session.PreviewStatus != PreviewProcessing {
|
||||||
|
items = append(items, cloneSession(session))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
@@ -315,6 +347,10 @@ func newMediaServer(store *sessionStore) *mediaServer {
|
|||||||
return &mediaServer{store: store}
|
return &mediaServer{store: store}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mediaServer) refreshSessionsForRead() error {
|
||||||
|
return m.store.refreshFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mediaServer) routes() http.Handler {
|
func (m *mediaServer) routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/media/health", m.handleHealth)
|
mux.HandleFunc("/media/health", m.handleHealth)
|
||||||
@@ -359,6 +395,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
sessionID := parts[0]
|
sessionID := parts[0]
|
||||||
if len(parts) == 1 && r.Method == http.MethodGet {
|
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)
|
session, err := m.store.getSession(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
@@ -402,6 +442,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := m.refreshSessionsForRead(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
session, err := m.store.getSession(sessionID)
|
session, err := m.store.getSession(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
@@ -632,7 +676,11 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
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 {
|
for _, session := range sessions {
|
||||||
if err := processSession(store, session.ID); err != nil {
|
if err := processSession(store, session.ID); err != nil {
|
||||||
log.Printf("[worker] failed to process session %s: %v", session.ID, err)
|
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 {
|
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 {
|
session, err := store.updateSession(sessionID, func(session *Session) error {
|
||||||
if session.ArchiveStatus == ArchiveProcessing {
|
if session.ArchiveStatus == ArchiveProcessing {
|
||||||
return errors.New("already processing")
|
return errors.New("already processing")
|
||||||
@@ -668,12 +752,22 @@ func processSession(store *sessionStore, sessionID string) error {
|
|||||||
return errors.New("no uploaded segments found")
|
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)
|
publicDir := store.publicDir(sessionID)
|
||||||
if err := os.MkdirAll(publicDir, 0o755); err != nil {
|
if err := os.MkdirAll(publicDir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
outputWebM := filepath.Join(publicDir, "recording.webm")
|
baseName := "preview"
|
||||||
outputMP4 := filepath.Join(publicDir, "recording.mp4")
|
if finalize {
|
||||||
|
baseName = "recording"
|
||||||
|
}
|
||||||
|
outputWebM := filepath.Join(publicDir, baseName+".webm")
|
||||||
|
outputMP4 := filepath.Join(publicDir, baseName+".mp4")
|
||||||
listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt")
|
listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt")
|
||||||
|
|
||||||
inputs := make([]string, 0, len(session.Segments))
|
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))
|
inputs = append(inputs, filepath.Join(store.segmentsDir(sessionID), segment.Filename))
|
||||||
}
|
}
|
||||||
if err := writeConcatList(listFile, inputs); err != nil {
|
if err := writeConcatList(listFile, inputs); err != nil {
|
||||||
return markArchiveError(store, sessionID, err)
|
return markProcessingError(store, sessionID, err, finalize)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(inputs) == 1 {
|
if len(inputs) == 1 {
|
||||||
body, copyErr := os.ReadFile(inputs[0])
|
body, copyErr := os.ReadFile(inputs[0])
|
||||||
if copyErr != nil {
|
if copyErr != nil {
|
||||||
return markArchiveError(store, sessionID, copyErr)
|
return markProcessingError(store, sessionID, copyErr, finalize)
|
||||||
}
|
}
|
||||||
if writeErr := os.WriteFile(outputWebM, body, 0o644); writeErr != nil {
|
if writeErr := os.WriteFile(outputWebM, body, 0o644); writeErr != nil {
|
||||||
return markArchiveError(store, sessionID, writeErr)
|
return markProcessingError(store, sessionID, writeErr, finalize)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
copyErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputWebM)
|
copyErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputWebM)
|
||||||
if copyErr != nil {
|
if copyErr != nil {
|
||||||
reencodeErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c:v", "libvpx-vp9", "-b:v", "1800k", "-c:a", "libopus", outputWebM)
|
reencodeErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c:v", "libvpx-vp9", "-b:v", "1800k", "-c:a", "libopus", outputWebM)
|
||||||
if reencodeErr != nil {
|
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)
|
webmInfo, webmStatErr := os.Stat(outputWebM)
|
||||||
if webmStatErr != nil {
|
if webmStatErr != nil {
|
||||||
return markArchiveError(store, sessionID, webmStatErr)
|
return markProcessingError(store, sessionID, webmStatErr, finalize)
|
||||||
}
|
}
|
||||||
var mp4Size int64
|
var mp4Size int64
|
||||||
var mp4URL string
|
var mp4URL string
|
||||||
@@ -720,7 +814,18 @@ func processSession(store *sessionStore, sessionID string) error {
|
|||||||
mp4Size = info.Size()
|
mp4Size = info.Size()
|
||||||
mp4URL = fmt.Sprintf("/media/assets/sessions/%s/recording.mp4", sessionID)
|
mp4URL = fmt.Sprintf("/media/assets/sessions/%s/recording.mp4", sessionID)
|
||||||
}
|
}
|
||||||
_, err = store.updateSession(sessionID, func(session *Session) error {
|
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.ArchiveStatus = ArchiveCompleted
|
||||||
session.Status = StatusArchived
|
session.Status = StatusArchived
|
||||||
session.Playback = PlaybackInfo{
|
session.Playback = PlaybackInfo{
|
||||||
@@ -729,18 +834,21 @@ func processSession(store *sessionStore, sessionID string) error {
|
|||||||
WebMSize: webmInfo.Size(),
|
WebMSize: webmInfo.Size(),
|
||||||
MP4Size: mp4Size,
|
MP4Size: mp4Size,
|
||||||
Ready: true,
|
Ready: true,
|
||||||
PreviewURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID),
|
PreviewURL: previewURL,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
session.LastError = ""
|
|
||||||
return nil
|
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 {
|
_, _ = store.updateSession(sessionID, func(session *Session) error {
|
||||||
|
session.PreviewStatus = PreviewFailed
|
||||||
|
if finalize {
|
||||||
session.ArchiveStatus = ArchiveFailed
|
session.ArchiveStatus = ArchiveFailed
|
||||||
session.Status = StatusFailed
|
session.Status = StatusFailed
|
||||||
|
}
|
||||||
session.LastError = err.Error()
|
session.LastError = err.Error()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -128,3 +129,130 @@ func TestProcessSessionArchivesPlayback(t *testing.T) {
|
|||||||
t.Fatalf("expected webm playback url, got %#v", archived.Playback)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const isNonEmptyString = (value: unknown): value is string =>
|
|||||||
export type SessionPayload = {
|
export type SessionPayload = {
|
||||||
openId: string;
|
openId: string;
|
||||||
appId: string;
|
appId: string;
|
||||||
name: string;
|
name?: string;
|
||||||
|
sid?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
|
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
|
||||||
@@ -173,6 +174,7 @@ class SDKServer {
|
|||||||
openId,
|
openId,
|
||||||
appId: ENV.appId,
|
appId: ENV.appId,
|
||||||
name: options.name || "",
|
name: options.name || "",
|
||||||
|
sid: crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
@@ -190,7 +192,8 @@ class SDKServer {
|
|||||||
return new SignJWT({
|
return new SignJWT({
|
||||||
openId: payload.openId,
|
openId: payload.openId,
|
||||||
appId: payload.appId,
|
appId: payload.appId,
|
||||||
name: payload.name,
|
name: payload.name || "",
|
||||||
|
sid: payload.sid || crypto.randomUUID(),
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||||
.setExpirationTime(expirationSeconds)
|
.setExpirationTime(expirationSeconds)
|
||||||
@@ -199,7 +202,7 @@ class SDKServer {
|
|||||||
|
|
||||||
async verifySession(
|
async verifySession(
|
||||||
cookieValue: string | undefined | null
|
cookieValue: string | undefined | null
|
||||||
): Promise<{ openId: string; appId: string; name: string } | null> {
|
): Promise<{ openId: string; appId: string; name?: string; sid?: string } | null> {
|
||||||
if (!cookieValue) {
|
if (!cookieValue) {
|
||||||
console.warn("[Auth] Missing session cookie");
|
console.warn("[Auth] Missing session cookie");
|
||||||
return null;
|
return null;
|
||||||
@@ -210,12 +213,11 @@ class SDKServer {
|
|||||||
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
||||||
algorithms: ["HS256"],
|
algorithms: ["HS256"],
|
||||||
});
|
});
|
||||||
const { openId, appId, name } = payload as Record<string, unknown>;
|
const { openId, appId, name, sid } = payload as Record<string, unknown>;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isNonEmptyString(openId) ||
|
!isNonEmptyString(openId) ||
|
||||||
!isNonEmptyString(appId) ||
|
!isNonEmptyString(appId)
|
||||||
!isNonEmptyString(name)
|
|
||||||
) {
|
) {
|
||||||
console.warn("[Auth] Session payload missing required fields");
|
console.warn("[Auth] Session payload missing required fields");
|
||||||
return null;
|
return null;
|
||||||
@@ -224,7 +226,8 @@ class SDKServer {
|
|||||||
return {
|
return {
|
||||||
openId,
|
openId,
|
||||||
appId,
|
appId,
|
||||||
name,
|
name: typeof name === "string" ? name : undefined,
|
||||||
|
sid: typeof sid === "string" ? sid : undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Auth] Session verification failed", String(error));
|
console.warn("[Auth] Session verification failed", String(error));
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ export type RemoteMediaSession = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
title: string;
|
title: string;
|
||||||
archiveStatus: "idle" | "queued" | "processing" | "completed" | "failed";
|
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: {
|
playback: {
|
||||||
webmUrl?: string;
|
webmUrl?: string;
|
||||||
mp4Url?: string;
|
mp4Url?: string;
|
||||||
|
|||||||
@@ -664,6 +664,11 @@ export const appRouter = router({
|
|||||||
exerciseType: z.string().optional(),
|
exerciseType: z.string().optional(),
|
||||||
sessionMode: z.enum(["practice", "pk"]).default("practice"),
|
sessionMode: z.enum(["practice", "pk"]).default("practice"),
|
||||||
durationMinutes: z.number().min(1).max(720).optional(),
|
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 }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return enqueueTask({
|
return enqueueTask({
|
||||||
|
|||||||
@@ -34,8 +34,13 @@ type StructuredParams<T> = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
parse: (content: unknown) => T;
|
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<T>(params: StructuredParams<T>) {
|
async function invokeStructured<T>(params: StructuredParams<T>) {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
@@ -56,6 +61,8 @@ async function invokeStructured<T>(params: StructuredParams<T>) {
|
|||||||
model: params.model,
|
model: params.model,
|
||||||
messages: [...params.baseMessages, ...retryHint],
|
messages: [...params.baseMessages, ...retryHint],
|
||||||
response_format: params.responseFormat,
|
response_format: params.responseFormat,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
retryCount: params.retryCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -136,6 +143,17 @@ async function runTrainingPlanGenerateTask(task: NonNullable<TaskRow>) {
|
|||||||
durationDays: number;
|
durationDays: number;
|
||||||
focusAreas?: string[];
|
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 analyses = await db.getUserAnalyses(task.userId);
|
||||||
const recentScores = analyses.slice(0, 5).map((analysis) => ({
|
const recentScores = analyses.slice(0, 5).map((analysis) => ({
|
||||||
score: analysis.overallScore ?? null,
|
score: analysis.overallScore ?? null,
|
||||||
@@ -154,6 +172,9 @@ async function runTrainingPlanGenerateTask(task: NonNullable<TaskRow>) {
|
|||||||
content: buildTrainingPlanPrompt({
|
content: buildTrainingPlanPrompt({
|
||||||
...payload,
|
...payload,
|
||||||
recentScores,
|
recentScores,
|
||||||
|
effectiveNtrpRating: trainingProfileStatus.effectiveNtrp,
|
||||||
|
ntrpSource: trainingProfileStatus.ntrpSource,
|
||||||
|
assessmentSnapshot: trainingProfileStatus.assessmentSnapshot,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -194,6 +215,8 @@ async function runTrainingPlanGenerateTask(task: NonNullable<TaskRow>) {
|
|||||||
content,
|
content,
|
||||||
fallbackTitle: `${payload.durationDays}天训练计划`,
|
fallbackTitle: `${payload.durationDays}天训练计划`,
|
||||||
}),
|
}),
|
||||||
|
timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
|
||||||
|
retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
|
||||||
});
|
});
|
||||||
|
|
||||||
const planId = await db.createTrainingPlan({
|
const planId = await db.createTrainingPlan({
|
||||||
@@ -280,6 +303,8 @@ async function runTrainingPlanAdjustTask(task: NonNullable<TaskRow>) {
|
|||||||
content,
|
content,
|
||||||
fallbackTitle: currentPlan.title,
|
fallbackTitle: currentPlan.title,
|
||||||
}),
|
}),
|
||||||
|
timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
|
||||||
|
retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.updateTrainingPlan(payload.planId, {
|
await db.updateTrainingPlan(payload.planId, {
|
||||||
@@ -418,6 +443,11 @@ async function runMediaFinalizeTask(task: NonNullable<TaskRow>) {
|
|||||||
exerciseType?: string;
|
exerciseType?: string;
|
||||||
sessionMode?: "practice" | "pk";
|
sessionMode?: "practice" | "pk";
|
||||||
durationMinutes?: number;
|
durationMinutes?: number;
|
||||||
|
actionCount?: number;
|
||||||
|
actionSummary?: Record<string, number>;
|
||||||
|
dominantAction?: string;
|
||||||
|
validityStatus?: string;
|
||||||
|
invalidReason?: string;
|
||||||
};
|
};
|
||||||
const session = await getRemoteMediaSession(payload.sessionId);
|
const session = await getRemoteMediaSession(payload.sessionId);
|
||||||
|
|
||||||
@@ -495,6 +525,11 @@ async function runMediaFinalizeTask(task: NonNullable<TaskRow>) {
|
|||||||
title: payload.title || session.title,
|
title: payload.title || session.title,
|
||||||
sessionMode: payload.sessionMode || "practice",
|
sessionMode: payload.sessionMode || "practice",
|
||||||
durationMinutes: payload.durationMinutes ?? 5,
|
durationMinutes: payload.durationMinutes ?? 5,
|
||||||
|
actionCount: payload.actionCount ?? 0,
|
||||||
|
actionSummary: payload.actionSummary ?? {},
|
||||||
|
dominantAction: payload.dominantAction ?? null,
|
||||||
|
validityStatus: payload.validityStatus ?? "pending",
|
||||||
|
invalidReason: payload.invalidReason ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -199,21 +199,28 @@ export async function syncRecordingTrainingData(input: {
|
|||||||
title: string;
|
title: string;
|
||||||
sessionMode?: "practice" | "pk";
|
sessionMode?: "practice" | "pk";
|
||||||
durationMinutes?: number | null;
|
durationMinutes?: number | null;
|
||||||
|
actionCount?: number | null;
|
||||||
|
actionSummary?: Record<string, number> | null;
|
||||||
|
dominantAction?: string | null;
|
||||||
|
validityStatus?: string | null;
|
||||||
|
invalidReason?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const trainingDate = db.getDateKey();
|
const trainingDate = db.getDateKey();
|
||||||
const planMatch = await db.matchActivePlanForExercise(input.userId, input.exerciseType);
|
const resolvedExerciseType = input.exerciseType || input.dominantAction || "recording";
|
||||||
const exerciseLabel = ACTION_LABELS[input.exerciseType || "unknown"] || input.exerciseType || input.title;
|
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({
|
const recordResult = await db.upsertTrainingRecordBySource({
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
planId: planMatch?.planId ?? null,
|
planId: planMatch?.planId ?? null,
|
||||||
linkedPlanId: planMatch?.planId ?? null,
|
linkedPlanId: planMatch?.planId ?? null,
|
||||||
matchConfidence: planMatch?.confidence ?? null,
|
matchConfidence: planMatch?.confidence ?? null,
|
||||||
exerciseName: exerciseLabel,
|
exerciseName: exerciseLabel,
|
||||||
exerciseType: input.exerciseType || "unknown",
|
exerciseType: resolvedExerciseType,
|
||||||
sourceType: "recording",
|
sourceType: "recording",
|
||||||
sourceId: `recording:${input.videoId}`,
|
sourceId: `recording:${input.videoId}`,
|
||||||
videoId: input.videoId,
|
videoId: input.videoId,
|
||||||
actionCount: 0,
|
actionCount: totalActions,
|
||||||
durationMinutes: Math.max(1, input.durationMinutes ?? 5),
|
durationMinutes: Math.max(1, input.durationMinutes ?? 5),
|
||||||
completed: 1,
|
completed: 1,
|
||||||
poseScore: null,
|
poseScore: null,
|
||||||
@@ -222,8 +229,15 @@ export async function syncRecordingTrainingData(input: {
|
|||||||
source: "recording",
|
source: "recording",
|
||||||
sessionMode: input.sessionMode || "practice",
|
sessionMode: input.sessionMode || "practice",
|
||||||
title: input.title,
|
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) {
|
if (recordResult.isNew) {
|
||||||
@@ -234,7 +248,12 @@ export async function syncRecordingTrainingData(input: {
|
|||||||
deltaSessions: 1,
|
deltaSessions: 1,
|
||||||
deltaRecordingCount: 1,
|
deltaRecordingCount: 1,
|
||||||
deltaPkCount: input.sessionMode === "pk" ? 1 : 0,
|
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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户