diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..068ac5a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# AGENTS + +## Update Discipline + +- Every shipped feature change must be recorded in the in-app update log page at `/changelog`. +- Every shipped feature change must also be recorded in [docs/CHANGELOG.md](/root/auto/tennis/docs/CHANGELOG.md). +- When online smoke tests are run, record whether the public site is already serving the new build or still on an older asset revision. +- Each update log entry must include: + - release date + - feature summary + - tested modules or commands + - corresponding repository version identifier + - prefer the git short commit hash +- After implementation, run the relevant tests before pushing. +- Only record an entry as shipped after the related tests pass. +- When a feature is deployed successfully, append the update entry before or together with the repository submission so the changelog stays in sync with the codebase. + +## Session Policy + +- Username login must support multiple active sessions across multiple devices. +- New logins must not invalidate prior valid sessions for the same user. +- Session validation should be tolerant of older token payloads where optional display fields are absent. + +## Timezone Policy + +- User-facing time displays should use `Asia/Shanghai`. +- Daily aggregation keys and schedule-related server calculations should also use `Asia/Shanghai`. diff --git a/client/src/App.tsx b/client/src/App.tsx index 2b7ba0a..497067b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,6 +22,7 @@ import Reminders from "./pages/Reminders"; import VisionLab from "./pages/VisionLab"; import Logs from "./pages/Logs"; import AdminConsole from "./pages/AdminConsole"; +import ChangeLog from "./pages/ChangeLog"; function DashboardRoute({ component: Component }: { component: React.ComponentType }) { return ( @@ -78,6 +79,9 @@ function Router() { + + + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 0c5b6c9..b5a3afb 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -51,6 +51,7 @@ const menuItems: MenuItem[] = [ { icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" }, { icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" }, { icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" }, + { icon: ScrollText, label: "更新日志", path: "/changelog", group: "learn" }, { icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" }, { icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true }, { icon: Shield, label: "管理系统", path: "/admin", group: "learn", adminOnly: true }, diff --git a/client/src/components/TaskCenter.tsx b/client/src/components/TaskCenter.tsx index bce25c9..352c41e 100644 --- a/client/src/components/TaskCenter.tsx +++ b/client/src/components/TaskCenter.tsx @@ -6,6 +6,7 @@ import { Progress } from "@/components/ui/progress"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { formatDateTimeShanghai } from "@/lib/time"; import { toast } from "sonner"; import { AlertTriangle, BellRing, CheckCircle2, Loader2, RefreshCcw } from "lucide-react"; @@ -147,7 +148,7 @@ export function TaskCenter({ compact = false }: { compact?: boolean }) {
- {new Date(task.createdAt).toLocaleString("zh-CN")} · 耗时 {formatTaskTiming(task)} + {formatDateTimeShanghai(task.createdAt)} · 耗时 {formatTaskTiming(task)} {task.status === "failed" ? (
- {task.userName || task.userId} · {new Date(task.createdAt).toLocaleString("zh-CN")} + {task.userName || task.userId} · {formatDateTimeShanghai(task.createdAt)}
@@ -300,7 +301,7 @@ export default function AdminConsole() { {item.targetUserId ? 目标用户 {item.targetUserId} : null}
- 管理员 {item.adminName || item.adminUserId} · {new Date(item.createdAt).toLocaleString("zh-CN")} + 管理员 {item.adminName || item.adminUserId} · {formatDateTimeShanghai(item.createdAt)}
{item.entityId ?
实体 {item.entityId}
: null} diff --git a/client/src/pages/ChangeLog.tsx b/client/src/pages/ChangeLog.tsx new file mode 100644 index 0000000..15f68a8 --- /dev/null +++ b/client/src/pages/ChangeLog.tsx @@ -0,0 +1,66 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { CHANGE_LOG_ENTRIES } from "@/lib/changelog"; +import { formatDateShanghai } from "@/lib/time"; +import { GitBranch, ListChecks, ScrollText } from "lucide-react"; + +export default function ChangeLog() { + return ( +
+
+
+
+ +
+
+

更新日志

+

+ 这里会按版本记录已上线的新功能、对应仓库版本和验证结果。后续每次改动测试通过并提交后,都会继续追加到这里。 +

+
+
+
+ +
+ {CHANGE_LOG_ENTRIES.map((entry) => ( + + +
+
+ {entry.version} + {entry.summary} +
+
+ {formatDateShanghai(entry.releaseDate)} + + + {entry.repoVersion} + +
+
+
+ +
+
上线内容
+
+ {entry.features.map((feature) => ( + {feature} + ))} +
+
+
+
+ + 验证记录 +
+
    + {entry.tests.map((item) =>
  • {item}
  • )} +
+
+
+
+ ))} +
+
+ ); +} diff --git a/client/src/pages/Checkin.tsx b/client/src/pages/Checkin.tsx index 4aa14b2..f4f55a6 100644 --- a/client/src/pages/Checkin.tsx +++ b/client/src/pages/Checkin.tsx @@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; +import { formatDateShanghai } from "@/lib/time"; import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-react"; const CATEGORY_META: Record = { @@ -17,11 +18,12 @@ const CATEGORY_META: Record = { rating: { label: "评分", tone: "bg-violet-500/10 text-violet-700" }, pk: { label: "训练 PK", tone: "bg-orange-500/10 text-orange-700" }, plan: { label: "计划匹配", tone: "bg-cyan-500/10 text-cyan-700" }, + tutorial: { label: "教程路径", tone: "bg-violet-500/10 text-violet-700" }, }; function getProgressText(item: any) { if (item.unlockedAt) { - return `已于 ${new Date(item.unlockedAt).toLocaleDateString("zh-CN")} 解锁`; + return `已于 ${formatDateShanghai(item.unlockedAt)} 解锁`; } return `${Math.round(item.currentValue || 0)} / ${Math.round(item.targetValue || 0)}`; } diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 4cd4fbc..5a7de01 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; +import { formatDateTimeShanghai } from "@/lib/time"; import { Activity, Award, ChevronRight, Clock3, Sparkles, Swords, Target, Video } from "lucide-react"; import { useLocation } from "wouter"; @@ -199,7 +200,7 @@ export default function Dashboard() {
{session.title}
-
{new Date(session.createdAt).toLocaleString("zh-CN")}
+
{formatDateTimeShanghai(session.createdAt)}
{Math.round(session.overallScore || 0)} 分
diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index 1546a9c..2b51bb7 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Progress } from "@/components/ui/progress"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; +import { formatDateTimeShanghai } from "@/lib/time"; import { toast } from "sonner"; import { applyTrackZoom, type CameraQualityPreset, getCameraVideoConstraints, getLiveAnalysisBitrate, readTrackZoomState } from "@/lib/camera"; import { @@ -910,7 +911,10 @@ export default function LiveCamera() { const format = recorderMimeTypeRef.current.includes("mp4") ? "mp4" : "webm"; const fileBase64 = await blobToBase64(recordedBlob); uploadedVideo = await uploadMutation.mutateAsync({ - title: `实时分析 ${new Date().toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}`, + title: `实时分析 ${formatDateTimeShanghai(new Date(), { + year: undefined, + second: undefined, + })}`, format, fileSize: recordedBlob.size, exerciseType: dominantAction, @@ -1605,7 +1609,7 @@ export default function LiveCamera() {
{session.title}
- {new Date(session.createdAt).toLocaleString("zh-CN")} + {formatDateTimeShanghai(session.createdAt)}
diff --git a/client/src/pages/Logs.tsx b/client/src/pages/Logs.tsx index b39e148..dbc5893 100644 --- a/client/src/pages/Logs.tsx +++ b/client/src/pages/Logs.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { formatDateTimeShanghai } from "@/lib/time"; import { toast } from "sonner"; import { AlertTriangle, BellRing, CheckCircle2, ClipboardList, Loader2, RefreshCcw } from "lucide-react"; @@ -155,7 +156,7 @@ export default function Logs() {
{task.title} - {new Date(task.createdAt).toLocaleString("zh-CN")} · {task.type} + {formatDateTimeShanghai(task.createdAt)} · {task.type}
@@ -233,7 +234,7 @@ export default function Logs() {
{item.title} - {new Date(item.createdAt).toLocaleString("zh-CN")} · {item.notificationType} + {formatDateTimeShanghai(item.createdAt)} · {item.notificationType}
diff --git a/client/src/pages/Progress.tsx b/client/src/pages/Progress.tsx index f07746e..8c109ac 100644 --- a/client/src/pages/Progress.tsx +++ b/client/src/pages/Progress.tsx @@ -4,7 +4,9 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; -import { Activity, Calendar, CheckCircle2, Clock, TrendingUp, Target, Sparkles } from "lucide-react"; +import { Activity, Calendar, CheckCircle2, ChevronDown, ChevronUp, Clock, TrendingUp, Target, Sparkles } from "lucide-react"; +import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time"; +import { useState } from "react"; import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, LineChart, Line, Legend @@ -17,6 +19,7 @@ export default function Progress() { const { data: analyses } = trpc.analysis.list.useQuery(); const { data: stats } = trpc.profile.stats.useQuery(); const [, setLocation] = useLocation(); + const [expandedRecordId, setExpandedRecordId] = useState(null); if (isLoading) { return ( @@ -29,7 +32,7 @@ export default function Progress() { // Aggregate data by date for charts const dateMap = new Map(); (records || []).forEach((r: any) => { - const date = new Date(r.trainingDate || r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }); + const date = formatMonthDayShanghai(r.trainingDate || r.createdAt); const existing = dateMap.get(date) || { date, sessions: 0, minutes: 0, avgScore: 0, scores: [] }; existing.sessions++; existing.minutes += r.durationMinutes || 0; @@ -44,7 +47,7 @@ export default function Progress() { // Analysis score trend const scoreTrend = (analyses || []).map((a: any) => ({ - date: new Date(a.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }), + date: formatMonthDayShanghai(a.createdAt), overall: Math.round(a.overallScore || 0), consistency: Math.round(a.strokeConsistency || 0), footwork: Math.round(a.footworkScore || 0), @@ -179,32 +182,104 @@ export default function Progress() { {(records?.length || 0) > 0 ? (
{(records || []).slice(0, 20).map((record: any) => ( -
-
-
- {record.completed ? : } +
+
+
+
+ {record.completed ? : } +
+
+

{record.exerciseName}

+

+ {formatDateTimeShanghai(record.trainingDate || record.createdAt)} + {record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""} + {record.sourceType ? ` · ${record.sourceType}` : ""} +

+ {record.actionCount ? ( +

+ 动作数 {record.actionCount} +

+ ) : null} +
-
-

{record.exerciseName}

-

- {new Date(record.trainingDate || record.createdAt).toLocaleDateString("zh-CN")} - {record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""} - {record.sourceType ? ` · ${record.sourceType}` : ""} -

+
+ {record.poseScore && ( + {Math.round(record.poseScore)}分 + )} + {record.completed ? ( + 已完成 + ) : ( + 进行中 + )} +
-
- {record.poseScore && ( - {Math.round(record.poseScore)}分 - )} - {record.completed ? ( - 已完成 - ) : ( - 进行中 - )} -
+ + {expandedRecordId === record.id ? ( +
+
+
+
记录时间
+
{formatDateTimeShanghai(record.trainingDate || record.createdAt, { second: "2-digit" })}
+
+
+
动作数据
+
动作数 {record.actionCount || 0}
+
+
+ + {record.metadata ? ( +
+ {record.metadata.dominantAction ? ( +
+
主动作
+
{String(record.metadata.dominantAction)}
+
+ ) : null} + + {record.metadata.actionSummary && Object.keys(record.metadata.actionSummary).length > 0 ? ( +
+
动作明细
+
+ {Object.entries(record.metadata.actionSummary as Record) + .filter(([, count]) => Number(count) > 0) + .map(([actionType, count]) => ( + + {actionType} {count} 次 + + ))} +
+
+ ) : null} + + {record.metadata.validityStatus ? ( +
+
录制有效性
+
{String(record.metadata.validityStatus)}
+ {record.metadata.invalidReason ? ( +
{String(record.metadata.invalidReason)}
+ ) : null} +
+ ) : null} + + {record.notes ? ( +
+
备注
+
{record.notes}
+
+ ) : null} +
+ ) : null} +
+ ) : null}
))}
diff --git a/client/src/pages/Rating.tsx b/client/src/pages/Rating.tsx index 939dd3f..890209d 100644 --- a/client/src/pages/Rating.tsx +++ b/client/src/pages/Rating.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Skeleton } from "@/components/ui/skeleton"; import { useBackgroundTask } from "@/hooks/useBackgroundTask"; +import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time"; import { toast } from "sonner"; import { Activity, Award, Loader2, RefreshCw, Radar, TrendingUp } from "lucide-react"; import { @@ -69,7 +70,7 @@ export default function Rating() { const trendData = useMemo( () => history.map((item: any) => ({ - date: new Date(item.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }), + date: formatMonthDayShanghai(item.createdAt), rating: item.rating, })).reverse(), [history], @@ -131,7 +132,7 @@ export default function Rating() { {latestSnapshot?.triggerType ? 来源 {latestSnapshot.triggerType} : null} {latestSnapshot?.createdAt ? ( - 刷新于 {new Date(latestSnapshot.createdAt).toLocaleString("zh-CN")} + 刷新于 {formatDateTimeShanghai(latestSnapshot.createdAt)} ) : null}
@@ -252,7 +253,7 @@ export default function Rating() { NTRP {Number(item.rating || 0).toFixed(1)} {item.triggerType}
-
{new Date(item.createdAt).toLocaleString("zh-CN")}
+
{formatDateTimeShanghai(item.createdAt)}
diff --git a/client/src/pages/Recorder.tsx b/client/src/pages/Recorder.tsx index 370fbc2..b91a092 100644 --- a/client/src/pages/Recorder.tsx +++ b/client/src/pages/Recorder.tsx @@ -23,7 +23,16 @@ import { Slider } from "@/components/ui/slider"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { useBackgroundTask } from "@/hooks/useBackgroundTask"; import { toast } from "sonner"; +import { + ACTION_LABELS as RECOGNIZED_ACTION_LABELS, + type ActionObservation, + type ActionType, + type TrackingState, + recognizeActionFrame, + stabilizeActionFrame, +} from "@/lib/actionRecognition"; import { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera"; +import { formatDateTimeShanghai } from "@/lib/time"; import { Activity, Camera, @@ -68,6 +77,8 @@ const SEGMENT_LENGTH_MS = 60_000; const MOTION_SAMPLE_MS = 1_500; const MOTION_THRESHOLD = 18; const MOTION_COOLDOWN_MS = 8_000; +const ACTION_SAMPLE_MS = 2_500; +const INVALID_RECORDING_WINDOW_MS = 60_000; const QUALITY_PRESETS = { economy: { @@ -151,6 +162,30 @@ function formatFileSize(bytes: number) { return `${(bytes / 1024 / 1024).toFixed(bytes > 20 * 1024 * 1024 ? 1 : 2)} MB`; } +function createActionSummary(): Record { + return { + forehand: 0, + backhand: 0, + serve: 0, + volley: 0, + overhead: 0, + slice: 0, + lob: 0, + unknown: 0, + }; +} + +function summarizeActions(actionSummary: Record) { + return Object.entries(actionSummary) + .filter(([actionType, count]) => actionType !== "unknown" && count > 0) + .sort((left, right) => right[1] - left[1]) + .map(([actionType, count]) => ({ + actionType: actionType as ActionType, + label: RECOGNIZED_ACTION_LABELS[actionType as ActionType] || actionType, + count, + })); +} + export default function Recorder() { const { user } = useAuth(); const utils = trpc.useUtils(); @@ -170,11 +205,22 @@ export default function Recorder() { const streamRef = useRef(null); const recorderRef = useRef(null); const peerRef = useRef(null); + const actionPoseRef = useRef(null); const currentSegmentStartedAtRef = useRef(0); const recordingStartedAtRef = useRef(0); const segmentSequenceRef = useRef(0); const motionFrameRef = useRef(null); const lastMotionMarkerAtRef = useRef(0); + const actionTickerRef = useRef | null>(null); + const actionFrameInFlightRef = useRef(false); + const actionTrackingRef = useRef({}); + const actionHistoryRef = useRef([]); + const actionSummaryRef = useRef>(createActionSummary()); + const lastRecognizedActionAtRef = useRef(0); + const lastActionMarkerAtRef = useRef(0); + const latestRecognizedActionRef = useRef("unknown"); + const validityOverrideRef = useRef<"valid" | "invalid" | null>(null); + const invalidAutoMarkedRef = useRef(false); const pendingUploadsRef = useRef([]); const uploadInFlightRef = useRef(false); const currentSessionRef = useRef(null); @@ -202,13 +248,17 @@ export default function Recorder() { const [uploadedSegments, setUploadedSegments] = useState(0); const [uploadBytes, setUploadBytes] = useState(0); const [cameraError, setCameraError] = useState(""); - const [title, setTitle] = useState(() => `训练录制 ${new Date().toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}`); + const [title, setTitle] = useState(() => `训练录制 ${formatDateTimeShanghai(new Date(), { year: undefined, second: undefined })}`); const [mediaSession, setMediaSession] = useState(null); const [markers, setMarkers] = useState([]); const [connectionState, setConnectionState] = useState("new"); const [immersivePreview, setImmersivePreview] = useState(false); const [archiveTaskId, setArchiveTaskId] = useState(null); const [zoomState, setZoomState] = useState(() => readTrackZoomState(null)); + const [actionSummary, setActionSummary] = useState>(() => createActionSummary()); + const [currentDetectedAction, setCurrentDetectedAction] = useState("unknown"); + const [recordingValidity, setRecordingValidity] = useState<"pending" | "valid" | "invalid">("pending"); + const [recordingValidityReason, setRecordingValidityReason] = useState(""); const mobile = useMemo(() => isMobileDevice(), []); const mimeType = useMemo(() => pickRecorderMimeType(), []); @@ -224,6 +274,7 @@ export default function Recorder() { mediaSession?.archiveStatus === "queued" || mediaSession?.archiveStatus === "processing"; const canLeaveRecorderPage = !uploadStillDraining && (archiveRunning || mode === "archived"); + const recognizedActionItems = useMemo(() => summarizeActions(actionSummary), [actionSummary]); const syncSessionState = useCallback((session: MediaSession | null) => { currentSessionRef.current = session; @@ -273,9 +324,11 @@ export default function Recorder() { if (segmentTickerRef.current) clearInterval(segmentTickerRef.current); if (timerTickerRef.current) clearInterval(timerTickerRef.current); if (motionTickerRef.current) clearInterval(motionTickerRef.current); + if (actionTickerRef.current) clearInterval(actionTickerRef.current); segmentTickerRef.current = null; timerTickerRef.current = null; motionTickerRef.current = null; + actionTickerRef.current = null; }, []); const closePeer = useCallback(() => { @@ -479,7 +532,11 @@ export default function Recorder() { await stopped; }, [stopTickers]); - const createManualMarker = useCallback(async (type: "manual" | "motion", label: string, confidence?: number) => { + const createManualMarker = useCallback(async ( + type: "manual" | "motion" | "action_detected" | "invalid_auto" | "invalid_manual" | "valid_manual", + label: string, + confidence?: number, + ) => { const sessionId = currentSessionRef.current?.id; if (!sessionId) return; @@ -508,6 +565,132 @@ export default function Recorder() { } }, [syncSessionState]); + const setValidityState = useCallback(( + nextStatus: "pending" | "valid" | "invalid", + reason: string, + override: "valid" | "invalid" | null = validityOverrideRef.current, + ) => { + validityOverrideRef.current = override; + setRecordingValidity(nextStatus); + setRecordingValidityReason(reason); + }, []); + + const startActionSampling = useCallback(async () => { + if (typeof window === "undefined") return; + const liveVideo = liveVideoRef.current; + if (!liveVideo) return; + + if (!actionPoseRef.current) { + const testFactory = ( + window as typeof window & { + __TEST_MEDIAPIPE_FACTORY__?: () => Promise<{ Pose: any }>; + } + ).__TEST_MEDIAPIPE_FACTORY__; + const { Pose } = testFactory ? await testFactory() : await import("@mediapipe/pose"); + const pose = new Pose({ + locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`, + }); + pose.setOptions({ + modelComplexity: 0, + smoothLandmarks: true, + enableSegmentation: false, + minDetectionConfidence: 0.5, + minTrackingConfidence: 0.5, + }); + pose.onResults((results: { poseLandmarks?: Array<{ x: number; y: number; visibility?: number }> }) => { + if (!results.poseLandmarks) { + return; + } + + const analyzed = stabilizeActionFrame( + recognizeActionFrame(results.poseLandmarks, actionTrackingRef.current, performance.now()), + actionHistoryRef.current, + ); + setCurrentDetectedAction(analyzed.action); + + if (analyzed.action === "unknown" || analyzed.confidence < 0.55) { + return; + } + + latestRecognizedActionRef.current = analyzed.action; + lastRecognizedActionAtRef.current = Date.now(); + actionSummaryRef.current = { + ...actionSummaryRef.current, + [analyzed.action]: (actionSummaryRef.current[analyzed.action] || 0) + 1, + }; + setActionSummary({ ...actionSummaryRef.current }); + + if (validityOverrideRef.current !== "invalid") { + setValidityState("valid", `已识别到 ${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, validityOverrideRef.current); + } + + if (Date.now() - lastActionMarkerAtRef.current >= 15_000) { + lastActionMarkerAtRef.current = Date.now(); + void createManualMarker("action_detected", `识别到${RECOGNIZED_ACTION_LABELS[analyzed.action]}`, analyzed.confidence); + } + }); + actionPoseRef.current = pose; + } + + const checkInvalidWindow = () => { + const elapsedMs = Date.now() - recordingStartedAtRef.current; + if (elapsedMs < INVALID_RECORDING_WINDOW_MS || invalidAutoMarkedRef.current) { + return; + } + if (Date.now() - lastRecognizedActionAtRef.current < INVALID_RECORDING_WINDOW_MS) { + return; + } + invalidAutoMarkedRef.current = true; + setValidityState("invalid", "连续 60 秒未识别到有效动作,已自动标记为无效录制", validityOverrideRef.current); + void createManualMarker("invalid_auto", "连续60秒未识别到有效动作,自动标记为无效录制"); + }; + + actionTickerRef.current = setInterval(() => { + const video = liveVideoRef.current; + if (!video || video.readyState < 2 || !actionPoseRef.current || actionFrameInFlightRef.current) { + checkInvalidWindow(); + return; + } + + actionFrameInFlightRef.current = true; + actionPoseRef.current.send({ image: video }) + .catch(() => undefined) + .finally(() => { + actionFrameInFlightRef.current = false; + checkInvalidWindow(); + }); + }, ACTION_SAMPLE_MS); + }, [createManualMarker, setValidityState]); + + const stopActionSampling = useCallback(async () => { + if (actionTickerRef.current) { + clearInterval(actionTickerRef.current); + actionTickerRef.current = null; + } + if (actionPoseRef.current?.close) { + try { + await actionPoseRef.current.close(); + } catch { + // ignore pose teardown failures during recorder stop/reset + } + } + actionPoseRef.current = null; + actionFrameInFlightRef.current = false; + }, []); + + const updateRecordingValidity = useCallback(async (next: "valid" | "invalid") => { + validityOverrideRef.current = next; + if (next === "valid") { + setValidityState("valid", "已手工恢复为有效录制", "valid"); + invalidAutoMarkedRef.current = false; + await createManualMarker("valid_manual", "手工恢复为有效录制"); + return; + } + + setValidityState("invalid", "已手工标记为无效录制", "invalid"); + await createManualMarker("invalid_manual", "手工标记为无效录制"); + }, [createManualMarker, setValidityState]); + const sampleMotion = useCallback(() => { const video = liveVideoRef.current; const canvas = motionCanvasRef.current; @@ -680,11 +863,22 @@ export default function Recorder() { segmentSequenceRef.current = 0; motionFrameRef.current = null; pendingUploadsRef.current = []; + actionTrackingRef.current = {}; + actionHistoryRef.current = []; + actionSummaryRef.current = createActionSummary(); + setActionSummary(createActionSummary()); + setCurrentDetectedAction("unknown"); + setRecordingValidity("pending"); + setRecordingValidityReason("正在抽样动作帧,持续 60 秒未识别到有效动作将自动标记无效。"); + validityOverrideRef.current = null; + invalidAutoMarkedRef.current = false; + latestRecognizedActionRef.current = "unknown"; + lastActionMarkerAtRef.current = 0; const stream = await ensurePreviewStream(); const sessionResponse = await createMediaSession({ userId: String(user.id), - title: title.trim() || `训练录制 ${new Date().toLocaleString("zh-CN")}`, + title: title.trim() || `训练录制 ${formatDateTimeShanghai(new Date())}`, format: "webm", mimeType, qualityPreset, @@ -695,14 +889,16 @@ export default function Recorder() { await startRealtimePush(stream, sessionResponse.session.id); recordingStartedAtRef.current = Date.now(); + lastRecognizedActionAtRef.current = recordingStartedAtRef.current; startRecorderLoop(stream); + await startActionSampling(); setMode("recording"); toast.success("录制已开始,已同步启动实时推流"); } catch (error: any) { setMode("idle"); toast.error(`启动录制失败: ${error?.message || "未知错误"}`); } - }, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startRealtimePush, startRecorderLoop, syncSessionState, title, user]); + }, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startActionSampling, startRealtimePush, startRecorderLoop, syncSessionState, title, user]); const finishRecording = useCallback(async () => { const session = currentSessionRef.current; @@ -712,6 +908,7 @@ export default function Recorder() { try { setMode("finalizing"); + await stopActionSampling(); await stopRecorder(); await flushPendingSegments(); closePeer(); @@ -728,13 +925,25 @@ export default function Recorder() { exerciseType: "recording", sessionMode, durationMinutes: Math.max(1, Math.round((Date.now() - recordingStartedAtRef.current) / 60000)), + actionCount: Object.entries(actionSummaryRef.current) + .filter(([actionType]) => actionType !== "unknown") + .reduce((sum, [, count]) => sum + count, 0), + actionSummary: actionSummaryRef.current, + dominantAction: latestRecognizedActionRef.current !== "unknown" ? latestRecognizedActionRef.current : undefined, + validityStatus: + recordingValidity === "invalid" + ? validityOverrideRef.current === "invalid" ? "invalid_manual" : "invalid_auto" + : recordingValidity === "valid" + ? validityOverrideRef.current === "valid" ? "valid_manual" : "valid" + : "pending", + invalidReason: recordingValidity === "invalid" ? recordingValidityReason : undefined, }); toast.success("录制已提交,后台正在整理回放文件"); } catch (error: any) { toast.error(`结束录制失败: ${error?.message || "未知错误"}`); setMode("recording"); } - }, [closePeer, finalizeTaskMutation, flushPendingSegments, sessionMode, stopCamera, stopRecorder, syncSessionState, title]); + }, [closePeer, finalizeTaskMutation, flushPendingSegments, recordingValidity, recordingValidityReason, sessionMode, stopActionSampling, stopCamera, stopRecorder, syncSessionState, title]); const resetRecorder = useCallback(async () => { if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); @@ -745,10 +954,18 @@ export default function Recorder() { pendingUploadsRef.current = []; uploadInFlightRef.current = false; motionFrameRef.current = null; + await stopActionSampling().catch(() => {}); + actionTrackingRef.current = {}; + actionHistoryRef.current = []; + actionSummaryRef.current = createActionSummary(); currentSessionRef.current = null; setArchiveTaskId(null); setMediaSession(null); setMarkers([]); + setActionSummary(createActionSummary()); + setCurrentDetectedAction("unknown"); + setRecordingValidity("pending"); + setRecordingValidityReason(""); setDurationMs(0); setQueuedSegments(0); setQueuedBytes(0); @@ -758,7 +975,7 @@ export default function Recorder() { setConnectionState("new"); setCameraError(""); setMode("idle"); - }, [closePeer, stopCamera, stopRecorder, stopTickers]); + }, [closePeer, stopActionSampling, stopCamera, stopRecorder, stopTickers]); const flipCamera = useCallback(async () => { const nextFacingMode = facingMode === "user" ? "environment" : "user"; @@ -844,6 +1061,7 @@ export default function Recorder() { return () => { if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); stopTickers(); + void stopActionSampling(); if (recorderRef.current && recorderRef.current.state !== "inactive") { try { recorderRef.current.stop(); @@ -856,7 +1074,7 @@ export default function Recorder() { streamRef.current.getTracks().forEach((track) => track.stop()); } }; - }, [closePeer, stopTickers]); + }, [closePeer, stopActionSampling, stopTickers]); const statusBadge = useMemo(() => { if (mode === "finalizing") { @@ -1333,6 +1551,53 @@ export default function Recorder() {
+
+
+
动作有效性
+
+ {recordingValidity === "valid" ? "有效录制" : recordingValidity === "invalid" ? "无效录制" : "待判定"} +
+
+ {recordingValidityReason || "录制中会自动抽样动作帧并进行判定。"} +
+
+
+
当前识别动作
+
+ {RECOGNIZED_ACTION_LABELS[currentDetectedAction] || "未知动作"} +
+
+ 每 {Math.round(ACTION_SAMPLE_MS / 1000)} 秒抽样动作帧;连续 {Math.round(INVALID_RECORDING_WINDOW_MS / 1000)} 秒无有效动作会自动标记无效。 +
+
+
+ +
+ + + {mediaSession?.playback.previewUrl ? ( + + ) : null} +
+
已上传文件 @@ -1354,6 +1619,13 @@ export default function Recorder() { 服务端状态 {mediaSession?.status || "idle"}
+
+ 滚动预归档 + + {mediaSession?.previewStatus || "idle"} + {typeof mediaSession?.previewSegments === "number" ? ` · ${mediaSession.previewSegments} 段` : ""} + +
{(mode === "finalizing" || mode === "archived" || mediaSession?.archiveStatus === "failed") && ( @@ -1404,6 +1676,19 @@ export default function Recorder() { )} + {recognizedActionItems.length > 0 ? ( +
+
识别到的动作数据
+
+ {recognizedActionItems.map((item) => ( + + {item.label} {item.count} 次 + + ))} +
+
+ ) : null} + {cameraError && (
{cameraError} diff --git a/client/src/pages/Videos.tsx b/client/src/pages/Videos.tsx index ebb4735..7953278 100644 --- a/client/src/pages/Videos.tsx +++ b/client/src/pages/Videos.tsx @@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; import { Input } from "@/components/ui/input"; +import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time"; import { toast } from "sonner"; import { BarChart3, @@ -145,7 +146,7 @@ function buildClipCueSheet(title: string, clips: ClipDraft[]) { ` 时长: ${formatSeconds(Math.max(0, clip.endSec - clip.startSec))}\n` + ` 来源: ${clip.source === "manual" ? "手动" : "分析建议"}\n` + ` 备注: ${clip.notes || "无"}` - )).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${new Date().toLocaleString("zh-CN")}\n`; + )).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${formatDateTimeShanghai(new Date())}\n`; } function createEmptyVideoDraft(): VideoCreateDraft { @@ -440,7 +441,7 @@ export default function Videos() { ) : null} - {new Date(video.createdAt).toLocaleDateString("zh-CN")} + {formatDateShanghai(video.createdAt)} {((video.fileSize || 0) / 1024 / 1024).toFixed(1)}MB diff --git a/client/src/pages/VisionLab.tsx b/client/src/pages/VisionLab.tsx index dc18360..cda7af9 100644 --- a/client/src/pages/VisionLab.tsx +++ b/client/src/pages/VisionLab.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Skeleton } from "@/components/ui/skeleton"; +import { formatDateTimeShanghai } from "@/lib/time"; import { toast } from "sonner"; import { useBackgroundTask } from "@/hooks/useBackgroundTask"; import { Database, Image as ImageIcon, Loader2, Microscope, ShieldCheck, Sparkles } from "lucide-react"; @@ -358,7 +359,7 @@ export default function VisionLab() { {run.exerciseType}

- {new Date(run.createdAt).toLocaleString("zh-CN")} + {formatDateTimeShanghai(run.createdAt)} {user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}

diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 77a4adb..9806375 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,29 @@ # Tennis Training Hub - 变更日志 +## 2026.03.15-session-changelog (2026-03-15) + +### 功能更新 + +- 用户名登录生成独立 `sid`,同一账号在多个设备或浏览器上下文中登录时不再互相顶掉 session +- 新增应用内更新日志页面 `/changelog`,展示版本号、发布日期、仓库版本和测试记录 +- 训练进度页最近训练记录支持展开,展示具体上海时间、动作数、主动作、动作明细、录制有效性和备注 +- 录制页补齐动作抽样摘要、无效录制标记与 media 预归档状态的前端展示 +- Dashboard、任务中心、管理台、训练页、评分页、日志页、视觉测试页、视频库等高频页面统一使用 `Asia/Shanghai` 时间显示 + +### 测试 + +- `pnpm check` +- `pnpm test` +- `pnpm test:go` +- `pnpm build` +- Playwright 线上 smoke: + - `https://te.hao.work/` 使用两个浏览器上下文分别登录 `H1`,两端 dashboard 均保持有效 + - 当前线上 `/changelog` 仍返回旧前端构建,待部署最新版本后需要复测该页面 + +### 仓库版本 + +- `pending-commit` + ## v3.0.0 (2026-03-14) ### 新增功能 diff --git a/media/main.go b/media/main.go index 1c12478..b54a5ea 100644 --- a/media/main.go +++ b/media/main.go @@ -44,6 +44,15 @@ const ( ArchiveFailed ArchiveStatus = "failed" ) +type PreviewStatus string + +const ( + PreviewIdle PreviewStatus = "idle" + PreviewProcessing PreviewStatus = "processing" + PreviewReady PreviewStatus = "ready" + PreviewFailed PreviewStatus = "failed" +) + type PlaybackInfo struct { WebMURL string `json:"webmUrl,omitempty"` MP4URL string `json:"mp4Url,omitempty"` @@ -77,6 +86,7 @@ type Session struct { Title string `json:"title"` Status SessionStatus `json:"status"` ArchiveStatus ArchiveStatus `json:"archiveStatus"` + PreviewStatus PreviewStatus `json:"previewStatus"` Format string `json:"format"` MimeType string `json:"mimeType"` QualityPreset string `json:"qualityPreset"` @@ -85,11 +95,13 @@ type Session struct { ReconnectCount int `json:"reconnectCount"` UploadedSegments int `json:"uploadedSegments"` UploadedBytes int64 `json:"uploadedBytes"` + PreviewSegments int `json:"previewSegments"` DurationMS int64 `json:"durationMs"` LastError string `json:"lastError,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` FinalizedAt string `json:"finalizedAt,omitempty"` + PreviewUpdatedAt string `json:"previewUpdatedAt,omitempty"` StreamConnected bool `json:"streamConnected"` LastStreamAt string `json:"lastStreamAt,omitempty"` Playback PlaybackInfo `json:"playback"` @@ -159,7 +171,7 @@ func newSessionStore(rootDir string) (*sessionStore, error) { if err := os.MkdirAll(store.public, 0o755); err != nil { return nil, err } - if err := store.load(); err != nil { + if err := store.refreshFromDisk(); err != nil { return nil, err } for _, session := range store.sessions { @@ -168,12 +180,13 @@ func newSessionStore(rootDir string) (*sessionStore, error) { return store, nil } -func (s *sessionStore) load() error { +func (s *sessionStore) loadSessionsFromDisk() (map[string]*Session, error) { pattern := filepath.Join(s.rootDir, "sessions", "*", "session.json") files, err := filepath.Glob(pattern) if err != nil { - return err + return nil, err } + sessions := make(map[string]*Session, len(files)) for _, file := range files { body, readErr := os.ReadFile(file) if readErr != nil { @@ -183,8 +196,19 @@ func (s *sessionStore) load() error { if unmarshalErr := json.Unmarshal(body, &session); unmarshalErr != nil { continue } - s.sessions[session.ID] = &session + sessions[session.ID] = &session } + return sessions, nil +} + +func (s *sessionStore) refreshFromDisk() error { + sessions, err := s.loadSessionsFromDisk() + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.sessions = sessions return nil } @@ -228,6 +252,7 @@ func (s *sessionStore) createSession(input CreateSessionRequest) (*Session, erro Title: strings.TrimSpace(input.Title), Status: StatusCreated, ArchiveStatus: ArchiveIdle, + PreviewStatus: PreviewIdle, Format: defaultString(input.Format, "webm"), MimeType: defaultString(input.MimeType, "video/webm"), QualityPreset: defaultString(input.QualityPreset, "balanced"), @@ -295,13 +320,20 @@ func (s *sessionStore) updateSession(id string, update func(*Session) error) (*S return cloneSession(session), nil } -func (s *sessionStore) listFinalizingSessions() []*Session { +func (s *sessionStore) listProcessableSessions() []*Session { s.mu.RLock() defer s.mu.RUnlock() items := make([]*Session, 0, len(s.sessions)) for _, session := range s.sessions { + if len(session.Segments) == 0 { + continue + } if session.ArchiveStatus == ArchiveQueued || session.ArchiveStatus == ArchiveProcessing { items = append(items, cloneSession(session)) + continue + } + if session.PreviewSegments < len(session.Segments) && session.PreviewStatus != PreviewProcessing { + items = append(items, cloneSession(session)) } } return items @@ -315,6 +347,10 @@ func newMediaServer(store *sessionStore) *mediaServer { return &mediaServer{store: store} } +func (m *mediaServer) refreshSessionsForRead() error { + return m.store.refreshFromDisk() +} + func (m *mediaServer) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/media/health", m.handleHealth) @@ -359,6 +395,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) { } sessionID := parts[0] if len(parts) == 1 && r.Method == http.MethodGet { + if err := m.refreshSessionsForRead(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } session, err := m.store.getSession(sessionID) if err != nil { writeError(w, http.StatusNotFound, err.Error()) @@ -402,6 +442,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + if err := m.refreshSessionsForRead(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } session, err := m.store.getSession(sessionID) if err != nil { writeError(w, http.StatusNotFound, err.Error()) @@ -632,7 +676,11 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat case <-ctx.Done(): return case <-ticker.C: - sessions := store.listFinalizingSessions() + if err := store.refreshFromDisk(); err != nil { + log.Printf("[worker] failed to refresh session store: %v", err) + continue + } + sessions := store.listProcessableSessions() for _, session := range sessions { if err := processSession(store, session.ID); err != nil { log.Printf("[worker] failed to process session %s: %v", session.ID, err) @@ -643,6 +691,42 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat } func processSession(store *sessionStore, sessionID string) error { + current, err := store.getSession(sessionID) + if err != nil { + return err + } + + if current.ArchiveStatus == ArchiveQueued || current.ArchiveStatus == ArchiveProcessing { + return processFinalArchive(store, sessionID) + } + + if current.PreviewSegments < len(current.Segments) { + return processRollingPreview(store, sessionID) + } + + return nil +} + +func processRollingPreview(store *sessionStore, sessionID string) error { + session, err := store.updateSession(sessionID, func(session *Session) error { + if session.PreviewStatus == PreviewProcessing { + return errors.New("preview already processing") + } + session.PreviewStatus = PreviewProcessing + session.LastError = "" + return nil + }) + if err != nil { + if strings.Contains(err.Error(), "preview already processing") { + return nil + } + return err + } + + return buildPlaybackArtifacts(store, session, false) +} + +func processFinalArchive(store *sessionStore, sessionID string) error { session, err := store.updateSession(sessionID, func(session *Session) error { if session.ArchiveStatus == ArchiveProcessing { return errors.New("already processing") @@ -668,12 +752,22 @@ func processSession(store *sessionStore, sessionID string) error { return errors.New("no uploaded segments found") } + return buildPlaybackArtifacts(store, session, true) +} + +func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool) error { + sessionID := session.ID + publicDir := store.publicDir(sessionID) if err := os.MkdirAll(publicDir, 0o755); err != nil { return err } - outputWebM := filepath.Join(publicDir, "recording.webm") - outputMP4 := filepath.Join(publicDir, "recording.mp4") + baseName := "preview" + if finalize { + baseName = "recording" + } + outputWebM := filepath.Join(publicDir, baseName+".webm") + outputMP4 := filepath.Join(publicDir, baseName+".mp4") listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt") inputs := make([]string, 0, len(session.Segments)) @@ -684,23 +778,23 @@ func processSession(store *sessionStore, sessionID string) error { inputs = append(inputs, filepath.Join(store.segmentsDir(sessionID), segment.Filename)) } if err := writeConcatList(listFile, inputs); err != nil { - return markArchiveError(store, sessionID, err) + return markProcessingError(store, sessionID, err, finalize) } if len(inputs) == 1 { body, copyErr := os.ReadFile(inputs[0]) if copyErr != nil { - return markArchiveError(store, sessionID, copyErr) + return markProcessingError(store, sessionID, copyErr, finalize) } if writeErr := os.WriteFile(outputWebM, body, 0o644); writeErr != nil { - return markArchiveError(store, sessionID, writeErr) + return markProcessingError(store, sessionID, writeErr, finalize) } } else { copyErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputWebM) if copyErr != nil { reencodeErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c:v", "libvpx-vp9", "-b:v", "1800k", "-c:a", "libopus", outputWebM) if reencodeErr != nil { - return markArchiveError(store, sessionID, fmt.Errorf("concat failed: %w / %v", copyErr, reencodeErr)) + return markProcessingError(store, sessionID, fmt.Errorf("concat failed: %w / %v", copyErr, reencodeErr), finalize) } } } @@ -712,7 +806,7 @@ func processSession(store *sessionStore, sessionID string) error { webmInfo, webmStatErr := os.Stat(outputWebM) if webmStatErr != nil { - return markArchiveError(store, sessionID, webmStatErr) + return markProcessingError(store, sessionID, webmStatErr, finalize) } var mp4Size int64 var mp4URL string @@ -720,27 +814,41 @@ func processSession(store *sessionStore, sessionID string) error { mp4Size = info.Size() mp4URL = fmt.Sprintf("/media/assets/sessions/%s/recording.mp4", sessionID) } - _, err = store.updateSession(sessionID, func(session *Session) error { - session.ArchiveStatus = ArchiveCompleted - session.Status = StatusArchived - session.Playback = PlaybackInfo{ - WebMURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID), - MP4URL: mp4URL, - WebMSize: webmInfo.Size(), - MP4Size: mp4Size, - Ready: true, - PreviewURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID), - } + previewURL := fmt.Sprintf("/media/assets/sessions/%s/%s.webm", sessionID, baseName) + if mp4URL != "" { + previewURL = mp4URL + } + + _, updateErr := store.updateSession(sessionID, func(session *Session) error { + session.Playback.PreviewURL = previewURL + session.PreviewSegments = len(inputs) + session.PreviewUpdatedAt = time.Now().UTC().Format(time.RFC3339) + session.PreviewStatus = PreviewReady session.LastError = "" + if finalize { + session.ArchiveStatus = ArchiveCompleted + session.Status = StatusArchived + session.Playback = PlaybackInfo{ + WebMURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID), + MP4URL: mp4URL, + WebMSize: webmInfo.Size(), + MP4Size: mp4Size, + Ready: true, + PreviewURL: previewURL, + } + } return nil }) - return err + return updateErr } -func markArchiveError(store *sessionStore, sessionID string, err error) error { +func markProcessingError(store *sessionStore, sessionID string, err error, finalize bool) error { _, _ = store.updateSession(sessionID, func(session *Session) error { - session.ArchiveStatus = ArchiveFailed - session.Status = StatusFailed + session.PreviewStatus = PreviewFailed + if finalize { + session.ArchiveStatus = ArchiveFailed + session.Status = StatusFailed + } session.LastError = err.Error() return nil }) diff --git a/media/main_test.go b/media/main_test.go index edba0a6..d1af01e 100644 --- a/media/main_test.go +++ b/media/main_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "net/http" "net/http/httptest" "os" @@ -128,3 +129,130 @@ func TestProcessSessionArchivesPlayback(t *testing.T) { t.Fatalf("expected webm playback url, got %#v", archived.Playback) } } + +func TestRefreshFromDiskPicksUpSessionsCreatedAfterWorkerStartup(t *testing.T) { + tempDir := t.TempDir() + + workerStore, err := newSessionStore(tempDir) + if err != nil { + t.Fatalf("newSessionStore(worker): %v", err) + } + if got := len(workerStore.listProcessableSessions()); got != 0 { + t.Fatalf("expected no processable sessions at startup, got %d", got) + } + + appStore, err := newSessionStore(tempDir) + if err != nil { + t.Fatalf("newSessionStore(app): %v", err) + } + + session, err := appStore.createSession(CreateSessionRequest{UserID: "1", Title: "Queued Session"}) + if err != nil { + t.Fatalf("createSession: %v", err) + } + if err := os.WriteFile(filepath.Join(appStore.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil { + t.Fatalf("write segment: %v", err) + } + if _, err := appStore.updateSession(session.ID, func(current *Session) error { + current.Segments = append(current.Segments, SegmentMeta{ + Sequence: 0, + Filename: "000000.webm", + DurationMS: 60000, + SizeBytes: 7, + ContentType: "video/webm", + }) + current.ArchiveStatus = ArchiveQueued + current.Status = StatusFinalizing + return nil + }); err != nil { + t.Fatalf("updateSession: %v", err) + } + + if err := workerStore.refreshFromDisk(); err != nil { + t.Fatalf("refreshFromDisk: %v", err) + } + + processable := workerStore.listProcessableSessions() + if len(processable) != 1 { + t.Fatalf("expected worker to pick up queued session after refresh, got %d", len(processable)) + } + if processable[0].ID != session.ID { + t.Fatalf("expected session %s, got %s", session.ID, processable[0].ID) + } +} + +func TestHandleSessionGetRefreshesSessionStateFromDisk(t *testing.T) { + tempDir := t.TempDir() + + serverStore, err := newSessionStore(tempDir) + if err != nil { + t.Fatalf("newSessionStore(server): %v", err) + } + server := newMediaServer(serverStore) + + writerStore, err := newSessionStore(tempDir) + if err != nil { + t.Fatalf("newSessionStore(writer): %v", err) + } + + session, err := writerStore.createSession(CreateSessionRequest{UserID: "1", Title: "Fresh Session"}) + if err != nil { + t.Fatalf("createSession: %v", err) + } + if _, err := writerStore.updateSession(session.ID, func(current *Session) error { + current.Status = StatusFinalizing + current.ArchiveStatus = ArchiveQueued + return nil + }); err != nil { + t.Fatalf("queue session: %v", err) + } + + getReq := httptest.NewRequest(http.MethodGet, "/media/sessions/"+session.ID, nil) + getRes := httptest.NewRecorder() + server.routes().ServeHTTP(getRes, getReq) + if getRes.Code != http.StatusOK { + t.Fatalf("expected get session 200, got %d", getRes.Code) + } + + var queuedResponse struct { + Session Session `json:"session"` + } + if err := json.NewDecoder(getRes.Body).Decode(&queuedResponse); err != nil { + t.Fatalf("decode queued response: %v", err) + } + if queuedResponse.Session.ArchiveStatus != ArchiveQueued { + t.Fatalf("expected queued archive status, got %s", queuedResponse.Session.ArchiveStatus) + } + + if _, err := writerStore.updateSession(session.ID, func(current *Session) error { + current.Status = StatusArchived + current.ArchiveStatus = ArchiveCompleted + current.Playback = PlaybackInfo{ + WebMURL: "/media/assets/sessions/" + session.ID + "/recording.webm", + Ready: true, + } + return nil + }); err != nil { + t.Fatalf("complete session: %v", err) + } + + refreshReq := httptest.NewRequest(http.MethodGet, "/media/sessions/"+session.ID, nil) + refreshRes := httptest.NewRecorder() + server.routes().ServeHTTP(refreshRes, refreshReq) + if refreshRes.Code != http.StatusOK { + t.Fatalf("expected refreshed get session 200, got %d", refreshRes.Code) + } + + var completedResponse struct { + Session Session `json:"session"` + } + if err := json.NewDecoder(refreshRes.Body).Decode(&completedResponse); err != nil { + t.Fatalf("decode completed response: %v", err) + } + if completedResponse.Session.ArchiveStatus != ArchiveCompleted { + t.Fatalf("expected completed archive status, got %s", completedResponse.Session.ArchiveStatus) + } + if !completedResponse.Session.Playback.Ready { + t.Fatalf("expected playback ready after refresh") + } +} diff --git a/server/_core/sdk.ts b/server/_core/sdk.ts index 230e762..f1033d7 100644 --- a/server/_core/sdk.ts +++ b/server/_core/sdk.ts @@ -21,7 +21,8 @@ const isNonEmptyString = (value: unknown): value is string => export type SessionPayload = { openId: string; appId: string; - name: string; + name?: string; + sid?: string; }; const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`; @@ -173,6 +174,7 @@ class SDKServer { openId, appId: ENV.appId, name: options.name || "", + sid: crypto.randomUUID(), }, options ); @@ -190,7 +192,8 @@ class SDKServer { return new SignJWT({ openId: payload.openId, appId: payload.appId, - name: payload.name, + name: payload.name || "", + sid: payload.sid || crypto.randomUUID(), }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setExpirationTime(expirationSeconds) @@ -199,7 +202,7 @@ class SDKServer { async verifySession( cookieValue: string | undefined | null - ): Promise<{ openId: string; appId: string; name: string } | null> { + ): Promise<{ openId: string; appId: string; name?: string; sid?: string } | null> { if (!cookieValue) { console.warn("[Auth] Missing session cookie"); return null; @@ -210,12 +213,11 @@ class SDKServer { const { payload } = await jwtVerify(cookieValue, secretKey, { algorithms: ["HS256"], }); - const { openId, appId, name } = payload as Record; + const { openId, appId, name, sid } = payload as Record; if ( !isNonEmptyString(openId) || - !isNonEmptyString(appId) || - !isNonEmptyString(name) + !isNonEmptyString(appId) ) { console.warn("[Auth] Session payload missing required fields"); return null; @@ -224,7 +226,8 @@ class SDKServer { return { openId, appId, - name, + name: typeof name === "string" ? name : undefined, + sid: typeof sid === "string" ? sid : undefined, }; } catch (error) { console.warn("[Auth] Session verification failed", String(error)); diff --git a/server/mediaService.ts b/server/mediaService.ts index 706a9c8..430c7ed 100644 --- a/server/mediaService.ts +++ b/server/mediaService.ts @@ -6,6 +6,16 @@ export type RemoteMediaSession = { userId: string; title: string; archiveStatus: "idle" | "queued" | "processing" | "completed" | "failed"; + previewStatus?: "idle" | "processing" | "ready" | "failed"; + previewSegments?: number; + markers?: Array<{ + id: string; + type: string; + label: string; + timestampMs: number; + confidence?: number; + createdAt: string; + }>; playback: { webmUrl?: string; mp4Url?: string; diff --git a/server/routers.ts b/server/routers.ts index ba28eda..49333d7 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -664,6 +664,11 @@ export const appRouter = router({ exerciseType: z.string().optional(), sessionMode: z.enum(["practice", "pk"]).default("practice"), durationMinutes: z.number().min(1).max(720).optional(), + actionCount: z.number().min(0).max(100000).optional(), + actionSummary: z.record(z.string(), z.number()).optional(), + dominantAction: z.string().optional(), + validityStatus: z.enum(["pending", "valid", "valid_manual", "invalid_auto", "invalid_manual"]).optional(), + invalidReason: z.string().max(512).optional(), })) .mutation(async ({ ctx, input }) => { return enqueueTask({ diff --git a/server/taskWorker.ts b/server/taskWorker.ts index 98a0caa..2810494 100644 --- a/server/taskWorker.ts +++ b/server/taskWorker.ts @@ -34,8 +34,13 @@ type StructuredParams = { }; }; parse: (content: unknown) => T; + timeoutMs?: number; + retryCount?: number; }; +const TRAINING_PLAN_LLM_TIMEOUT_MS = Math.max(ENV.llmTimeoutMs, 120_000); +const TRAINING_PLAN_LLM_RETRY_COUNT = Math.max(ENV.llmRetryCount, 2); + async function invokeStructured(params: StructuredParams) { let lastError: unknown; @@ -56,6 +61,8 @@ async function invokeStructured(params: StructuredParams) { model: params.model, messages: [...params.baseMessages, ...retryHint], response_format: params.responseFormat, + timeoutMs: params.timeoutMs, + retryCount: params.retryCount, }); try { @@ -136,6 +143,17 @@ async function runTrainingPlanGenerateTask(task: NonNullable) { durationDays: number; focusAreas?: string[]; }; + const user = await db.getUserById(task.userId); + if (!user) { + throw new Error("User not found"); + } + const latestSnapshot = await db.getLatestNtrpSnapshot(task.userId); + const trainingProfileStatus = db.getTrainingProfileStatus(user, latestSnapshot); + if (!trainingProfileStatus.isComplete) { + const missingLabels = trainingProfileStatus.missingFields.map((field) => db.TRAINING_PROFILE_FIELD_LABELS[field]).join("、"); + throw new Error(`训练计划生成前请先完善训练档案:${missingLabels}`); + } + const analyses = await db.getUserAnalyses(task.userId); const recentScores = analyses.slice(0, 5).map((analysis) => ({ score: analysis.overallScore ?? null, @@ -154,6 +172,9 @@ async function runTrainingPlanGenerateTask(task: NonNullable) { content: buildTrainingPlanPrompt({ ...payload, recentScores, + effectiveNtrpRating: trainingProfileStatus.effectiveNtrp, + ntrpSource: trainingProfileStatus.ntrpSource, + assessmentSnapshot: trainingProfileStatus.assessmentSnapshot, }), }, ], @@ -194,6 +215,8 @@ async function runTrainingPlanGenerateTask(task: NonNullable) { content, fallbackTitle: `${payload.durationDays}天训练计划`, }), + timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS, + retryCount: TRAINING_PLAN_LLM_RETRY_COUNT, }); const planId = await db.createTrainingPlan({ @@ -280,6 +303,8 @@ async function runTrainingPlanAdjustTask(task: NonNullable) { content, fallbackTitle: currentPlan.title, }), + timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS, + retryCount: TRAINING_PLAN_LLM_RETRY_COUNT, }); await db.updateTrainingPlan(payload.planId, { @@ -418,6 +443,11 @@ async function runMediaFinalizeTask(task: NonNullable) { exerciseType?: string; sessionMode?: "practice" | "pk"; durationMinutes?: number; + actionCount?: number; + actionSummary?: Record; + dominantAction?: string; + validityStatus?: string; + invalidReason?: string; }; const session = await getRemoteMediaSession(payload.sessionId); @@ -495,6 +525,11 @@ async function runMediaFinalizeTask(task: NonNullable) { title: payload.title || session.title, sessionMode: payload.sessionMode || "practice", durationMinutes: payload.durationMinutes ?? 5, + actionCount: payload.actionCount ?? 0, + actionSummary: payload.actionSummary ?? {}, + dominantAction: payload.dominantAction ?? null, + validityStatus: payload.validityStatus ?? "pending", + invalidReason: payload.invalidReason ?? null, }); return { diff --git a/server/trainingAutomation.ts b/server/trainingAutomation.ts index 5551bd7..6adad2f 100644 --- a/server/trainingAutomation.ts +++ b/server/trainingAutomation.ts @@ -199,21 +199,28 @@ export async function syncRecordingTrainingData(input: { title: string; sessionMode?: "practice" | "pk"; durationMinutes?: number | null; + actionCount?: number | null; + actionSummary?: Record | null; + dominantAction?: string | null; + validityStatus?: string | null; + invalidReason?: string | null; }) { const trainingDate = db.getDateKey(); - const planMatch = await db.matchActivePlanForExercise(input.userId, input.exerciseType); - const exerciseLabel = ACTION_LABELS[input.exerciseType || "unknown"] || input.exerciseType || input.title; + const resolvedExerciseType = input.exerciseType || input.dominantAction || "recording"; + const planMatch = await db.matchActivePlanForExercise(input.userId, resolvedExerciseType); + const exerciseLabel = ACTION_LABELS[resolvedExerciseType || "unknown"] || resolvedExerciseType || input.title; + const totalActions = Math.max(0, input.actionCount ?? 0); const recordResult = await db.upsertTrainingRecordBySource({ userId: input.userId, planId: planMatch?.planId ?? null, linkedPlanId: planMatch?.planId ?? null, matchConfidence: planMatch?.confidence ?? null, exerciseName: exerciseLabel, - exerciseType: input.exerciseType || "unknown", + exerciseType: resolvedExerciseType, sourceType: "recording", sourceId: `recording:${input.videoId}`, videoId: input.videoId, - actionCount: 0, + actionCount: totalActions, durationMinutes: Math.max(1, input.durationMinutes ?? 5), completed: 1, poseScore: null, @@ -222,8 +229,15 @@ export async function syncRecordingTrainingData(input: { source: "recording", sessionMode: input.sessionMode || "practice", title: input.title, + actionCount: totalActions, + actionSummary: input.actionSummary ?? {}, + dominantAction: input.dominantAction ?? null, + validityStatus: input.validityStatus ?? "pending", + invalidReason: input.invalidReason ?? null, }, - notes: "自动写入:录制归档", + notes: input.validityStatus?.startsWith("invalid") + ? `自动写入:录制归档(无效录制)${input.invalidReason ? ` · ${input.invalidReason}` : ""}` + : "自动写入:录制归档", }); if (recordResult.isNew) { @@ -234,7 +248,12 @@ export async function syncRecordingTrainingData(input: { deltaSessions: 1, deltaRecordingCount: 1, deltaPkCount: input.sessionMode === "pk" ? 1 : 0, - metadata: { latestRecordingExerciseType: input.exerciseType || "unknown" }, + deltaTotalActions: totalActions, + deltaEffectiveActions: input.validityStatus?.startsWith("invalid") ? 0 : totalActions, + metadata: { + latestRecordingExerciseType: resolvedExerciseType, + latestRecordingValidity: input.validityStatus ?? "pending", + }, }); }