Add multi-session auth and changelog tracking

这个提交包含在:
cryptocommuniums-afk
2026-03-15 17:30:19 +08:00
父节点 c4ec397ed3
当前提交 a9ea94fb78
修改 27 个文件,包含 1280 行新增89 行删除

查看文件

@@ -22,6 +22,7 @@ import Reminders from "./pages/Reminders";
import VisionLab from "./pages/VisionLab";
import Logs from "./pages/Logs";
import AdminConsole from "./pages/AdminConsole";
import ChangeLog from "./pages/ChangeLog";
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
return (
@@ -78,6 +79,9 @@ function Router() {
<Route path="/logs">
<DashboardRoute component={Logs} />
</Route>
<Route path="/changelog">
<DashboardRoute component={ChangeLog} />
</Route>
<Route path="/vision-lab">
<DashboardRoute component={VisionLab} />
</Route>

查看文件

@@ -51,6 +51,7 @@ const menuItems: MenuItem[] = [
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
{ icon: ScrollText, label: "更新日志", path: "/changelog", group: "learn" },
{ icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" },
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
{ icon: Shield, label: "管理系统", path: "/admin", group: "learn", adminOnly: true },

查看文件

@@ -6,6 +6,7 @@ import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { AlertTriangle, BellRing, CheckCircle2, Loader2, RefreshCcw } from "lucide-react";
@@ -147,7 +148,7 @@ export function TaskCenter({ compact = false }: { compact?: boolean }) {
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>
{new Date(task.createdAt).toLocaleString("zh-CN")} · {formatTaskTiming(task)}
{formatDateTimeShanghai(task.createdAt)} · {formatTaskTiming(task)}
</span>
{task.status === "failed" ? (
<Button

查看文件

@@ -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 普通文件
查看文件

@@ -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"
| "failed";
export type PreviewStatus =
| "idle"
| "processing"
| "ready"
| "failed";
export type MediaMarker = {
id: string;
type: string;
@@ -29,6 +35,7 @@ export type MediaSession = {
title: string;
status: MediaSessionStatus;
archiveStatus: ArchiveStatus;
previewStatus: PreviewStatus;
format: string;
mimeType: string;
qualityPreset: string;
@@ -37,8 +44,10 @@ export type MediaSession = {
reconnectCount: number;
uploadedSegments: number;
uploadedBytes: number;
previewSegments: number;
durationMs: number;
lastError?: string;
previewUpdatedAt?: string;
streamConnected: boolean;
lastStreamAt?: string;
playback: {

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 { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react";
@@ -193,7 +194,7 @@ export default function AdminConsole() {
</Badge>
</div>
<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 className="min-w-[180px]">
@@ -300,7 +301,7 @@ export default function AdminConsole() {
{item.targetUserId ? <Badge variant="outline"> {item.targetUserId}</Badge> : null}
</div>
<div className="mt-2 text-sm text-muted-foreground">
{item.adminName || item.adminUserId} · {new Date(item.createdAt).toLocaleString("zh-CN")}
{item.adminName || item.adminUserId} · {formatDateTimeShanghai(item.createdAt)}
</div>
</div>
{item.entityId ? <div className="text-sm text-muted-foreground"> {item.entityId}</div> : null}

查看文件

@@ -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 { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateShanghai } from "@/lib/time";
import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-react";
const CATEGORY_META: Record<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" },
pk: { label: "训练 PK", tone: "bg-orange-500/10 text-orange-700" },
plan: { label: "计划匹配", tone: "bg-cyan-500/10 text-cyan-700" },
tutorial: { label: "教程路径", tone: "bg-violet-500/10 text-violet-700" },
};
function getProgressText(item: any) {
if (item.unlockedAt) {
return `已于 ${new Date(item.unlockedAt).toLocaleDateString("zh-CN")} 解锁`;
return `已于 ${formatDateShanghai(item.unlockedAt)} 解锁`;
}
return `${Math.round(item.currentValue || 0)} / ${Math.round(item.targetValue || 0)}`;
}

查看文件

@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTimeShanghai } from "@/lib/time";
import { Activity, Award, ChevronRight, Clock3, Sparkles, Swords, Target, Video } from "lucide-react";
import { useLocation } from "wouter";
@@ -199,7 +200,7 @@ export default function Dashboard() {
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium">{session.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{new Date(session.createdAt).toLocaleString("zh-CN")}</div>
<div className="mt-1 text-xs text-muted-foreground">{formatDateTimeShanghai(session.createdAt)}</div>
</div>
<Badge variant="outline">{Math.round(session.overallScore || 0)} </Badge>
</div>

查看文件

@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Progress } from "@/components/ui/progress";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { applyTrackZoom, type CameraQualityPreset, getCameraVideoConstraints, getLiveAnalysisBitrate, readTrackZoomState } from "@/lib/camera";
import {
@@ -910,7 +911,10 @@ export default function LiveCamera() {
const format = recorderMimeTypeRef.current.includes("mp4") ? "mp4" : "webm";
const fileBase64 = await blobToBase64(recordedBlob);
uploadedVideo = await uploadMutation.mutateAsync({
title: `实时分析 ${new Date().toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}`,
title: `实时分析 ${formatDateTimeShanghai(new Date(), {
year: undefined,
second: undefined,
})}`,
format,
fileSize: recordedBlob.size,
exerciseType: dominantAction,
@@ -1605,7 +1609,7 @@ export default function LiveCamera() {
<div>
<div className="font-medium">{session.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{new Date(session.createdAt).toLocaleString("zh-CN")}
{formatDateTimeShanghai(session.createdAt)}
</div>
</div>
<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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { AlertTriangle, BellRing, CheckCircle2, ClipboardList, Loader2, RefreshCcw } from "lucide-react";
@@ -155,7 +156,7 @@ export default function Logs() {
<div className="space-y-1">
<CardTitle className="text-base">{task.title}</CardTitle>
<CardDescription>
{new Date(task.createdAt).toLocaleString("zh-CN")} · {task.type}
{formatDateTimeShanghai(task.createdAt)} · {task.type}
</CardDescription>
</div>
<Badge variant={task.status === "failed" ? "destructive" : "secondary"}>
@@ -233,7 +234,7 @@ export default function Logs() {
<div className="space-y-1">
<CardTitle className="text-base">{item.title}</CardTitle>
<CardDescription>
{new Date(item.createdAt).toLocaleString("zh-CN")} · {item.notificationType}
{formatDateTimeShanghai(item.createdAt)} · {item.notificationType}
</CardDescription>
</div>
<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 { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Activity, Calendar, CheckCircle2, Clock, TrendingUp, Target, Sparkles } from "lucide-react";
import { Activity, Calendar, CheckCircle2, ChevronDown, ChevronUp, Clock, TrendingUp, Target, Sparkles } from "lucide-react";
import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time";
import { useState } from "react";
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
LineChart, Line, Legend
@@ -17,6 +19,7 @@ export default function Progress() {
const { data: analyses } = trpc.analysis.list.useQuery();
const { data: stats } = trpc.profile.stats.useQuery();
const [, setLocation] = useLocation();
const [expandedRecordId, setExpandedRecordId] = useState<number | null>(null);
if (isLoading) {
return (
@@ -29,7 +32,7 @@ export default function Progress() {
// Aggregate data by date for charts
const dateMap = new Map<string, { date: string; sessions: number; minutes: number; avgScore: number; scores: number[] }>();
(records || []).forEach((r: any) => {
const date = new Date(r.trainingDate || r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
const date = formatMonthDayShanghai(r.trainingDate || r.createdAt);
const existing = dateMap.get(date) || { date, sessions: 0, minutes: 0, avgScore: 0, scores: [] };
existing.sessions++;
existing.minutes += r.durationMinutes || 0;
@@ -44,7 +47,7 @@ export default function Progress() {
// Analysis score trend
const scoreTrend = (analyses || []).map((a: any) => ({
date: new Date(a.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
date: formatMonthDayShanghai(a.createdAt),
overall: Math.round(a.overallScore || 0),
consistency: Math.round(a.strokeConsistency || 0),
footwork: Math.round(a.footworkScore || 0),
@@ -179,32 +182,104 @@ export default function Progress() {
{(records?.length || 0) > 0 ? (
<div className="space-y-2">
{(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 className="flex items-center gap-3">
<div className={`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 ? <CheckCircle2 className="h-4 w-4" /> : <Activity className="h-4 w-4" />}
<div key={record.id} className="border-b py-2 last:border-0">
<div className="flex items-start justify-between gap-3">
<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 ? <CheckCircle2 className="h-4 w-4" /> : <Activity className="h-4 w-4" />}
</div>
<div>
<p className="text-sm font-medium">{record.exerciseName}</p>
<p className="text-xs text-muted-foreground">
{formatDateTimeShanghai(record.trainingDate || record.createdAt)}
{record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
{record.sourceType ? ` · ${record.sourceType}` : ""}
</p>
{record.actionCount ? (
<p className="mt-1 text-xs text-muted-foreground">
{record.actionCount}
</p>
) : null}
</div>
</div>
<div>
<p className="text-sm font-medium">{record.exerciseName}</p>
<p className="text-xs text-muted-foreground">
{new Date(record.trainingDate || record.createdAt).toLocaleDateString("zh-CN")}
{record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
{record.sourceType ? ` · ${record.sourceType}` : ""}
</p>
<div className="flex items-center gap-2">
{record.poseScore && (
<Badge variant="secondary" className="text-xs">{Math.round(record.poseScore)}</Badge>
)}
{record.completed ? (
<Badge className="bg-green-100 text-green-700 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 className="flex items-center gap-2">
{record.poseScore && (
<Badge variant="secondary" className="text-xs">{Math.round(record.poseScore)}</Badge>
)}
{record.completed ? (
<Badge className="bg-green-100 text-green-700 text-xs"></Badge>
) : (
<Badge variant="outline" className="text-xs"></Badge>
)}
</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>

查看文件

@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time";
import { toast } from "sonner";
import { Activity, Award, Loader2, RefreshCw, Radar, TrendingUp } from "lucide-react";
import {
@@ -69,7 +70,7 @@ export default function Rating() {
const trendData = useMemo(
() => history.map((item: any) => ({
date: new Date(item.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
date: formatMonthDayShanghai(item.createdAt),
rating: item.rating,
})).reverse(),
[history],
@@ -131,7 +132,7 @@ export default function Rating() {
{latestSnapshot?.triggerType ? <Badge variant="outline"> {latestSnapshot.triggerType}</Badge> : null}
{latestSnapshot?.createdAt ? (
<Badge variant="outline">
{new Date(latestSnapshot.createdAt).toLocaleString("zh-CN")}
{formatDateTimeShanghai(latestSnapshot.createdAt)}
</Badge>
) : null}
</div>
@@ -252,7 +253,7 @@ export default function Rating() {
<span className="font-medium">NTRP {Number(item.rating || 0).toFixed(1)}</span>
<Badge variant="outline">{item.triggerType}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">{new Date(item.createdAt).toLocaleString("zh-CN")}</div>
<div className="mt-1 text-xs text-muted-foreground">{formatDateTimeShanghai(item.createdAt)}</div>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<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 { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { toast } from "sonner";
import {
ACTION_LABELS as RECOGNIZED_ACTION_LABELS,
type ActionObservation,
type ActionType,
type TrackingState,
recognizeActionFrame,
stabilizeActionFrame,
} from "@/lib/actionRecognition";
import { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera";
import { formatDateTimeShanghai } from "@/lib/time";
import {
Activity,
Camera,
@@ -68,6 +77,8 @@ const SEGMENT_LENGTH_MS = 60_000;
const MOTION_SAMPLE_MS = 1_500;
const MOTION_THRESHOLD = 18;
const MOTION_COOLDOWN_MS = 8_000;
const ACTION_SAMPLE_MS = 2_500;
const INVALID_RECORDING_WINDOW_MS = 60_000;
const QUALITY_PRESETS = {
economy: {
@@ -151,6 +162,30 @@ function formatFileSize(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(bytes > 20 * 1024 * 1024 ? 1 : 2)} MB`;
}
function createActionSummary(): Record<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() {
const { user } = useAuth();
const utils = trpc.useUtils();
@@ -170,11 +205,22 @@ export default function Recorder() {
const streamRef = useRef<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const peerRef = useRef<RTCPeerConnection | null>(null);
const actionPoseRef = useRef<any>(null);
const currentSegmentStartedAtRef = useRef<number>(0);
const recordingStartedAtRef = useRef<number>(0);
const segmentSequenceRef = useRef(0);
const motionFrameRef = useRef<Uint8ClampedArray | null>(null);
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 uploadInFlightRef = useRef(false);
const currentSessionRef = useRef<MediaSession | null>(null);
@@ -202,13 +248,17 @@ export default function Recorder() {
const [uploadedSegments, setUploadedSegments] = useState(0);
const [uploadBytes, setUploadBytes] = useState(0);
const [cameraError, setCameraError] = useState("");
const [title, setTitle] = useState(() => `训练录制 ${new Date().toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}`);
const [title, setTitle] = useState(() => `训练录制 ${formatDateTimeShanghai(new Date(), { year: undefined, second: undefined })}`);
const [mediaSession, setMediaSession] = useState<MediaSession | null>(null);
const [markers, setMarkers] = useState<MediaMarker[]>([]);
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
const [immersivePreview, setImmersivePreview] = useState(false);
const [archiveTaskId, setArchiveTaskId] = useState<string | null>(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 mimeType = useMemo(() => pickRecorderMimeType(), []);
@@ -224,6 +274,7 @@ export default function Recorder() {
mediaSession?.archiveStatus === "queued" ||
mediaSession?.archiveStatus === "processing";
const canLeaveRecorderPage = !uploadStillDraining && (archiveRunning || mode === "archived");
const recognizedActionItems = useMemo(() => summarizeActions(actionSummary), [actionSummary]);
const syncSessionState = useCallback((session: MediaSession | null) => {
currentSessionRef.current = session;
@@ -273,9 +324,11 @@ export default function Recorder() {
if (segmentTickerRef.current) clearInterval(segmentTickerRef.current);
if (timerTickerRef.current) clearInterval(timerTickerRef.current);
if (motionTickerRef.current) clearInterval(motionTickerRef.current);
if (actionTickerRef.current) clearInterval(actionTickerRef.current);
segmentTickerRef.current = null;
timerTickerRef.current = null;
motionTickerRef.current = null;
actionTickerRef.current = null;
}, []);
const closePeer = useCallback(() => {
@@ -479,7 +532,11 @@ export default function Recorder() {
await stopped;
}, [stopTickers]);
const createManualMarker = useCallback(async (type: "manual" | "motion", label: string, confidence?: number) => {
const createManualMarker = useCallback(async (
type: "manual" | "motion" | "action_detected" | "invalid_auto" | "invalid_manual" | "valid_manual",
label: string,
confidence?: number,
) => {
const sessionId = currentSessionRef.current?.id;
if (!sessionId) return;
@@ -508,6 +565,132 @@ export default function Recorder() {
}
}, [syncSessionState]);
const setValidityState = useCallback((
nextStatus: "pending" | "valid" | "invalid",
reason: string,
override: "valid" | "invalid" | null = validityOverrideRef.current,
) => {
validityOverrideRef.current = override;
setRecordingValidity(nextStatus);
setRecordingValidityReason(reason);
}, []);
const startActionSampling = useCallback(async () => {
if (typeof window === "undefined") return;
const liveVideo = liveVideoRef.current;
if (!liveVideo) return;
if (!actionPoseRef.current) {
const testFactory = (
window as typeof window & {
__TEST_MEDIAPIPE_FACTORY__?: () => Promise<{ Pose: any }>;
}
).__TEST_MEDIAPIPE_FACTORY__;
const { Pose } = testFactory ? await testFactory() : await import("@mediapipe/pose");
const pose = new Pose({
locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`,
});
pose.setOptions({
modelComplexity: 0,
smoothLandmarks: true,
enableSegmentation: false,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5,
});
pose.onResults((results: { poseLandmarks?: Array<{ x: number; y: number; visibility?: number }> }) => {
if (!results.poseLandmarks) {
return;
}
const analyzed = stabilizeActionFrame(
recognizeActionFrame(results.poseLandmarks, actionTrackingRef.current, performance.now()),
actionHistoryRef.current,
);
setCurrentDetectedAction(analyzed.action);
if (analyzed.action === "unknown" || analyzed.confidence < 0.55) {
return;
}
latestRecognizedActionRef.current = analyzed.action;
lastRecognizedActionAtRef.current = Date.now();
actionSummaryRef.current = {
...actionSummaryRef.current,
[analyzed.action]: (actionSummaryRef.current[analyzed.action] || 0) + 1,
};
setActionSummary({ ...actionSummaryRef.current });
if (validityOverrideRef.current !== "invalid") {
setValidityState("valid", `已识别到 ${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, validityOverrideRef.current);
}
if (Date.now() - lastActionMarkerAtRef.current >= 15_000) {
lastActionMarkerAtRef.current = Date.now();
void createManualMarker("action_detected", `识别到${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, analyzed.confidence);
}
});
actionPoseRef.current = pose;
}
const checkInvalidWindow = () => {
const elapsedMs = Date.now() - recordingStartedAtRef.current;
if (elapsedMs < INVALID_RECORDING_WINDOW_MS || invalidAutoMarkedRef.current) {
return;
}
if (Date.now() - lastRecognizedActionAtRef.current < INVALID_RECORDING_WINDOW_MS) {
return;
}
invalidAutoMarkedRef.current = true;
setValidityState("invalid", "连续 60 秒未识别到有效动作,已自动标记为无效录制", validityOverrideRef.current);
void createManualMarker("invalid_auto", "连续60秒未识别到有效动作,自动标记为无效录制");
};
actionTickerRef.current = setInterval(() => {
const video = liveVideoRef.current;
if (!video || video.readyState < 2 || !actionPoseRef.current || actionFrameInFlightRef.current) {
checkInvalidWindow();
return;
}
actionFrameInFlightRef.current = true;
actionPoseRef.current.send({ image: video })
.catch(() => undefined)
.finally(() => {
actionFrameInFlightRef.current = false;
checkInvalidWindow();
});
}, ACTION_SAMPLE_MS);
}, [createManualMarker, setValidityState]);
const stopActionSampling = useCallback(async () => {
if (actionTickerRef.current) {
clearInterval(actionTickerRef.current);
actionTickerRef.current = null;
}
if (actionPoseRef.current?.close) {
try {
await actionPoseRef.current.close();
} catch {
// ignore pose teardown failures during recorder stop/reset
}
}
actionPoseRef.current = null;
actionFrameInFlightRef.current = false;
}, []);
const updateRecordingValidity = useCallback(async (next: "valid" | "invalid") => {
validityOverrideRef.current = next;
if (next === "valid") {
setValidityState("valid", "已手工恢复为有效录制", "valid");
invalidAutoMarkedRef.current = false;
await createManualMarker("valid_manual", "手工恢复为有效录制");
return;
}
setValidityState("invalid", "已手工标记为无效录制", "invalid");
await createManualMarker("invalid_manual", "手工标记为无效录制");
}, [createManualMarker, setValidityState]);
const sampleMotion = useCallback(() => {
const video = liveVideoRef.current;
const canvas = motionCanvasRef.current;
@@ -680,11 +863,22 @@ export default function Recorder() {
segmentSequenceRef.current = 0;
motionFrameRef.current = null;
pendingUploadsRef.current = [];
actionTrackingRef.current = {};
actionHistoryRef.current = [];
actionSummaryRef.current = createActionSummary();
setActionSummary(createActionSummary());
setCurrentDetectedAction("unknown");
setRecordingValidity("pending");
setRecordingValidityReason("正在抽样动作帧,持续 60 秒未识别到有效动作将自动标记无效。");
validityOverrideRef.current = null;
invalidAutoMarkedRef.current = false;
latestRecognizedActionRef.current = "unknown";
lastActionMarkerAtRef.current = 0;
const stream = await ensurePreviewStream();
const sessionResponse = await createMediaSession({
userId: String(user.id),
title: title.trim() || `训练录制 ${new Date().toLocaleString("zh-CN")}`,
title: title.trim() || `训练录制 ${formatDateTimeShanghai(new Date())}`,
format: "webm",
mimeType,
qualityPreset,
@@ -695,14 +889,16 @@ export default function Recorder() {
await startRealtimePush(stream, sessionResponse.session.id);
recordingStartedAtRef.current = Date.now();
lastRecognizedActionAtRef.current = recordingStartedAtRef.current;
startRecorderLoop(stream);
await startActionSampling();
setMode("recording");
toast.success("录制已开始,已同步启动实时推流");
} catch (error: any) {
setMode("idle");
toast.error(`启动录制失败: ${error?.message || "未知错误"}`);
}
}, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startRealtimePush, startRecorderLoop, syncSessionState, title, user]);
}, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startActionSampling, startRealtimePush, startRecorderLoop, syncSessionState, title, user]);
const finishRecording = useCallback(async () => {
const session = currentSessionRef.current;
@@ -712,6 +908,7 @@ export default function Recorder() {
try {
setMode("finalizing");
await stopActionSampling();
await stopRecorder();
await flushPendingSegments();
closePeer();
@@ -728,13 +925,25 @@ export default function Recorder() {
exerciseType: "recording",
sessionMode,
durationMinutes: Math.max(1, Math.round((Date.now() - recordingStartedAtRef.current) / 60000)),
actionCount: Object.entries(actionSummaryRef.current)
.filter(([actionType]) => actionType !== "unknown")
.reduce((sum, [, count]) => sum + count, 0),
actionSummary: actionSummaryRef.current,
dominantAction: latestRecognizedActionRef.current !== "unknown" ? latestRecognizedActionRef.current : undefined,
validityStatus:
recordingValidity === "invalid"
? validityOverrideRef.current === "invalid" ? "invalid_manual" : "invalid_auto"
: recordingValidity === "valid"
? validityOverrideRef.current === "valid" ? "valid_manual" : "valid"
: "pending",
invalidReason: recordingValidity === "invalid" ? recordingValidityReason : undefined,
});
toast.success("录制已提交,后台正在整理回放文件");
} catch (error: any) {
toast.error(`结束录制失败: ${error?.message || "未知错误"}`);
setMode("recording");
}
}, [closePeer, finalizeTaskMutation, flushPendingSegments, sessionMode, stopCamera, stopRecorder, syncSessionState, title]);
}, [closePeer, finalizeTaskMutation, flushPendingSegments, recordingValidity, recordingValidityReason, sessionMode, stopActionSampling, stopCamera, stopRecorder, syncSessionState, title]);
const resetRecorder = useCallback(async () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
@@ -745,10 +954,18 @@ export default function Recorder() {
pendingUploadsRef.current = [];
uploadInFlightRef.current = false;
motionFrameRef.current = null;
await stopActionSampling().catch(() => {});
actionTrackingRef.current = {};
actionHistoryRef.current = [];
actionSummaryRef.current = createActionSummary();
currentSessionRef.current = null;
setArchiveTaskId(null);
setMediaSession(null);
setMarkers([]);
setActionSummary(createActionSummary());
setCurrentDetectedAction("unknown");
setRecordingValidity("pending");
setRecordingValidityReason("");
setDurationMs(0);
setQueuedSegments(0);
setQueuedBytes(0);
@@ -758,7 +975,7 @@ export default function Recorder() {
setConnectionState("new");
setCameraError("");
setMode("idle");
}, [closePeer, stopCamera, stopRecorder, stopTickers]);
}, [closePeer, stopActionSampling, stopCamera, stopRecorder, stopTickers]);
const flipCamera = useCallback(async () => {
const nextFacingMode = facingMode === "user" ? "environment" : "user";
@@ -844,6 +1061,7 @@ export default function Recorder() {
return () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
stopTickers();
void stopActionSampling();
if (recorderRef.current && recorderRef.current.state !== "inactive") {
try {
recorderRef.current.stop();
@@ -856,7 +1074,7 @@ export default function Recorder() {
streamRef.current.getTracks().forEach((track) => track.stop());
}
};
}, [closePeer, stopTickers]);
}, [closePeer, stopActionSampling, stopTickers]);
const statusBadge = useMemo(() => {
if (mode === "finalizing") {
@@ -1333,6 +1551,53 @@ export default function Recorder() {
</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="flex items-center justify-between text-sm">
<span></span>
@@ -1354,6 +1619,13 @@ export default function Recorder() {
<span></span>
<span className="font-medium">{mediaSession?.status || "idle"}</span>
</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>
{(mode === "finalizing" || mode === "archived" || mediaSession?.archiveStatus === "failed") && (
@@ -1404,6 +1676,19 @@ export default function Recorder() {
</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 && (
<div className="rounded-2xl border border-destructive/20 bg-destructive/5 p-4 text-sm text-destructive">
{cameraError}

查看文件

@@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import {
BarChart3,
@@ -145,7 +146,7 @@ function buildClipCueSheet(title: string, clips: ClipDraft[]) {
` 时长: ${formatSeconds(Math.max(0, clip.endSec - clip.startSec))}\n` +
` 来源: ${clip.source === "manual" ? "手动" : "分析建议"}\n` +
` 备注: ${clip.notes || "无"}`
)).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${new Date().toLocaleString("zh-CN")}\n`;
)).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${formatDateTimeShanghai(new Date())}\n`;
}
function createEmptyVideoDraft(): VideoCreateDraft {
@@ -440,7 +441,7 @@ export default function Videos() {
) : null}
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{new Date(video.createdAt).toLocaleDateString("zh-CN")}
{formatDateShanghai(video.createdAt)}
</span>
<span className="text-xs text-muted-foreground">
{((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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { Database, Image as ImageIcon, Loader2, Microscope, ShieldCheck, Sparkles } from "lucide-react";
@@ -358,7 +359,7 @@ export default function VisionLab() {
<Badge variant="outline">{run.exerciseType}</Badge>
</div>
<p className="text-xs text-muted-foreground">
{new Date(run.createdAt).toLocaleString("zh-CN")}
{formatDateTimeShanghai(run.createdAt)}
{user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}
</p>
</div>