From bd8998166bdf3e8eafebbaf1a3ed41b47c10c3aa Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Sun, 15 Mar 2026 14:17:59 +0800 Subject: [PATCH] Add CRUD support for training videos --- client/src/pages/Videos.tsx | 229 ++++++++++++++++++++- docs/verified-features.md | 2 + server/db.ts | 397 +++++++++++++++++++++++++++++++----- server/features.test.ts | 232 ++++++++++++++++++++- server/routers.ts | 88 +++++++- 5 files changed, 877 insertions(+), 71 deletions(-) diff --git a/client/src/pages/Videos.tsx b/client/src/pages/Videos.tsx index e25fa99..ebb4735 100644 --- a/client/src/pages/Videos.tsx +++ b/client/src/pages/Videos.tsx @@ -16,8 +16,10 @@ import { Copy, Download, FileVideo, + Pencil, Play, PlayCircle, + Plus, Scissors, Sparkles, Trash2, @@ -35,6 +37,21 @@ type ClipDraft = { source: "manual" | "suggested"; }; +type VideoCreateDraft = { + title: string; + url: string; + format: string; + exerciseType: string; + fileSizeMb: string; + durationSec: string; +}; + +type VideoEditDraft = { + videoId: number | null; + title: string; + exerciseType: string; +}; + const statusMap: Record = { pending: { label: "待分析", color: "bg-yellow-100 text-yellow-700" }, analyzing: { label: "分析中", color: "bg-blue-100 text-blue-700" }, @@ -131,11 +148,47 @@ function buildClipCueSheet(title: string, clips: ClipDraft[]) { )).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${new Date().toLocaleString("zh-CN")}\n`; } +function createEmptyVideoDraft(): VideoCreateDraft { + return { + title: "", + url: "", + format: "mp4", + exerciseType: "recording", + fileSizeMb: "", + durationSec: "", + }; +} + export default function Videos() { useAuth(); + const utils = trpc.useUtils(); const { data: videos, isLoading } = trpc.video.list.useQuery(); const { data: analyses } = trpc.analysis.list.useQuery(); const [, setLocation] = useLocation(); + const registerExternalMutation = trpc.video.registerExternal.useMutation({ + onSuccess: async () => { + await utils.video.list.invalidate(); + toast.success("视频记录已新增"); + }, + onError: (error) => toast.error(`新增失败: ${error.message}`), + }); + const updateVideoMutation = trpc.video.update.useMutation({ + onSuccess: async () => { + await utils.video.list.invalidate(); + toast.success("视频信息已更新"); + }, + onError: (error) => toast.error(`更新失败: ${error.message}`), + }); + const deleteVideoMutation = trpc.video.delete.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.video.list.invalidate(), + utils.analysis.list.invalidate(), + ]); + toast.success("视频记录已删除"); + }, + onError: (error) => toast.error(`删除失败: ${error.message}`), + }); const previewRef = useRef(null); const [editorOpen, setEditorOpen] = useState(false); @@ -147,6 +200,10 @@ export default function Videos() { const [clipNotes, setClipNotes] = useState(""); const [clipDrafts, setClipDrafts] = useState([]); const [activePreviewRange, setActivePreviewRange] = useState<[number, number] | null>(null); + const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [createDraft, setCreateDraft] = useState(() => createEmptyVideoDraft()); + const [editDraft, setEditDraft] = useState({ videoId: null, title: "", exerciseType: "" }); const getAnalysis = useCallback((videoId: number) => { return analyses?.find((analysis: any) => analysis.videoId === videoId); @@ -222,6 +279,63 @@ export default function Videos() { setActivePreviewRange(null); }, []); + const openEditDialog = useCallback((video: any) => { + setEditDraft({ + videoId: video.id, + title: video.title || "", + exerciseType: video.exerciseType || "", + }); + setEditOpen(true); + }, []); + + const handleCreateVideo = useCallback(async () => { + if (!createDraft.title.trim() || !createDraft.url.trim() || !createDraft.format.trim()) { + toast.error("请填写标题、视频地址和格式"); + return; + } + + const fileKey = `external/manual/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${createDraft.format}`; + await registerExternalMutation.mutateAsync({ + title: createDraft.title.trim(), + url: createDraft.url.trim(), + fileKey, + format: createDraft.format.trim(), + fileSize: createDraft.fileSizeMb.trim() ? Math.round(Number(createDraft.fileSizeMb) * 1024 * 1024) : undefined, + duration: createDraft.durationSec.trim() ? Number(createDraft.durationSec) : undefined, + exerciseType: createDraft.exerciseType.trim() || undefined, + }); + setCreateOpen(false); + setCreateDraft(createEmptyVideoDraft()); + }, [createDraft, registerExternalMutation]); + + const handleUpdateVideo = useCallback(async () => { + if (!editDraft.videoId || !editDraft.title.trim()) { + toast.error("请填写视频标题"); + return; + } + + await updateVideoMutation.mutateAsync({ + videoId: editDraft.videoId, + title: editDraft.title.trim(), + exerciseType: editDraft.exerciseType.trim() || undefined, + }); + setEditOpen(false); + setEditDraft({ videoId: null, title: "", exerciseType: "" }); + }, [editDraft, updateVideoMutation]); + + const handleDeleteVideo = useCallback(async (video: any) => { + if (!window.confirm(`确认删除视频“${video.title}”?该视频的分析结果和视频索引会一并移除。`)) { + return; + } + + await deleteVideoMutation.mutateAsync({ videoId: video.id }); + + if (selectedVideo?.id === video.id) { + setEditorOpen(false); + setSelectedVideo(null); + } + }, [deleteVideoMutation, selectedVideo]); + const addClip = useCallback((source: "manual" | "suggested", preset?: ClipDraft) => { const nextStart = preset?.startSec ?? clipRange[0]; const nextEnd = preset?.endSec ?? clipRange[1]; @@ -265,6 +379,10 @@ export default function Videos() {

+ +
+ + +
) : ( @@ -330,10 +454,23 @@ export default function Videos() { 播放 ) : null} + +
@@ -686,6 +823,90 @@ export default function Videos() { + + + + + 新增视频记录 + + 可录入已有外部视频地址或历史归档链接,纳入当前视频库统一管理。 + + +
+ setCreateDraft((current) => ({ ...current, title: event.target.value }))} + placeholder="视频标题" + /> + setCreateDraft((current) => ({ ...current, url: event.target.value }))} + placeholder="视频地址,例如 https://... 或 /uploads/..." + /> +
+ setCreateDraft((current) => ({ ...current, format: event.target.value }))} + placeholder="格式,例如 mp4 / webm" + /> + setCreateDraft((current) => ({ ...current, exerciseType: event.target.value }))} + placeholder="动作类型,例如 forehand / recording" + /> +
+
+ setCreateDraft((current) => ({ ...current, fileSizeMb: event.target.value }))} + placeholder="文件大小(MB,可选)" + inputMode="decimal" + /> + setCreateDraft((current) => ({ ...current, durationSec: event.target.value }))} + placeholder="时长(秒,可选)" + inputMode="decimal" + /> +
+
+ + + + +
+
+ + + + + 编辑视频信息 + + 可调整视频标题和动作类型,列表与分析归档会同步显示最新信息。 + + +
+ setEditDraft((current) => ({ ...current, title: event.target.value }))} + placeholder="视频标题" + /> + setEditDraft((current) => ({ ...current, exerciseType: event.target.value }))} + placeholder="动作类型,例如 forehand / recording" + /> +
+ + + + +
+
); } diff --git a/docs/verified-features.md b/docs/verified-features.md index b2abff0..f63dbc3 100644 --- a/docs/verified-features.md +++ b/docs/verified-features.md @@ -28,6 +28,7 @@ | 生产视觉标准图库页面 | Playwright 登录后访问 `/vision-lab`,未捕获 `pageerror` / `console.error` | 通过 | | 生产视觉历史修复 | 重跑历史 3 条 `fallback` 标准图记录后,`visionStatus` 全部恢复为 `ok` | 通过 | | 生产视频库轻剪辑入口 | 本地 `pnpm test:e2e` + 真实站点 `/videos` smoke | 通过 | +| 生产视频库 CRUD | Playwright 真实站点登录 `H1` 后完成 `/videos` 新增外部视频记录、编辑标题、删除记录整链路验证 | 通过 | | 生产训练计划后台任务提交 | Playwright 点击训练计划生成按钮并收到后台任务反馈 | 通过 | | 生产移动端录制焦点视图 | Playwright 移动端视口打开 `/recorder` 并验证焦点入口与操作壳层 | 通过 | | 生产前端运行时异常检查 | Playwright `pageerror` / `console.error` 检查 | 通过 | @@ -87,6 +88,7 @@ | 仪表盘 | 认证后主标题与入口按钮渲染 | 通过 | | 训练计划 | 训练计划页加载与生成入口可见 | 通过 | | 视频库 | 视频卡片渲染 | 通过 | +| 视频库 CRUD | 新增视频记录、编辑视频信息、删除视频记录 | 通过 | | 视频库轻剪辑 | 打开轻剪辑工作台、显示建议片段、展示导出草稿入口 | 通过 | | 视频库轻剪辑增强 | 循环预览、区间快速载入、草稿复制、cue sheet 导出 | 通过 | | 实时分析 | 摄像头启动入口渲染 | 通过 | diff --git a/server/db.ts b/server/db.ts index faa4627..20aefe4 100644 --- a/server/db.ts +++ b/server/db.ts @@ -27,6 +27,7 @@ import { visionTestRuns, InsertVisionTestRun, } from "../drizzle/schema"; import { ENV } from './_core/env'; +import { fetchTutorialMetrics, shouldRefreshTutorialMetrics } from "./tutorialMetrics"; let _db: ReturnType | null = null; @@ -94,6 +95,11 @@ export const ACHIEVEMENT_DEFINITION_SEED_DATA: Omit 0 ? result[0] : undefined; } +export async function getUserById(userId: number) { + const db = await getDb(); + if (!db) return undefined; + const result = await db.select().from(users).where(eq(users.id, userId)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + export async function getUserByUsername(username: string) { const db = await getDb(); if (!db) return undefined; @@ -352,6 +365,19 @@ export async function updateUserProfile(userId: number, data: { skillLevel?: "beginner" | "intermediate" | "advanced"; trainingGoals?: string; ntrpRating?: number; + manualNtrpRating?: number | null; + manualNtrpCapturedAt?: Date | null; + heightCm?: number | null; + weightKg?: number | null; + sprintSpeedScore?: number | null; + explosivePowerScore?: number | null; + agilityScore?: number | null; + enduranceScore?: number | null; + flexibilityScore?: number | null; + coreStabilityScore?: number | null; + shoulderMobilityScore?: number | null; + hipMobilityScore?: number | null; + assessmentNotes?: string | null; totalSessions?: number; totalMinutes?: number; currentStreak?: number; @@ -363,6 +389,81 @@ export async function updateUserProfile(userId: number, data: { await db.update(users).set(data).where(eq(users.id, userId)); } +export const TRAINING_PROFILE_FIELD_LABELS = { + heightCm: "身高", + weightKg: "体重", + sprintSpeedScore: "速度", + explosivePowerScore: "爆发力", + agilityScore: "敏捷性", + enduranceScore: "耐力", + flexibilityScore: "柔韧性", + coreStabilityScore: "核心稳定性", + shoulderMobilityScore: "肩部灵活性", + hipMobilityScore: "髋部灵活性", + manualNtrpRating: "人工 NTRP 基线", +} as const; + +export type TrainingProfileFieldKey = keyof typeof TRAINING_PROFILE_FIELD_LABELS; + +const TRAINING_PROFILE_REQUIRED_FIELDS: TrainingProfileFieldKey[] = [ + "heightCm", + "weightKg", + "sprintSpeedScore", + "explosivePowerScore", + "agilityScore", + "enduranceScore", + "flexibilityScore", + "coreStabilityScore", + "shoulderMobilityScore", + "hipMobilityScore", +]; + +export function getMissingTrainingProfileFields( + user: typeof users.$inferSelect, + hasSystemNtrp: boolean, +) { + const missing = TRAINING_PROFILE_REQUIRED_FIELDS.filter((field) => user[field] == null); + if (!hasSystemNtrp && user.manualNtrpRating == null) { + missing.push("manualNtrpRating"); + } + return missing; +} + +export function getTrainingProfileStatus( + user: typeof users.$inferSelect, + latestSnapshot?: { rating?: number | null } | null, +) { + const hasSystemNtrp = latestSnapshot?.rating != null; + const missingFields = getMissingTrainingProfileFields(user, hasSystemNtrp); + const effectiveNtrp = latestSnapshot?.rating ?? user.manualNtrpRating ?? user.ntrpRating ?? 1.5; + const ntrpSource: "system" | "manual" | "default" = hasSystemNtrp + ? "system" + : user.manualNtrpRating != null + ? "manual" + : "default"; + + return { + hasSystemNtrp, + isComplete: missingFields.length === 0, + missingFields, + effectiveNtrp, + ntrpSource, + assessmentSnapshot: { + heightCm: user.heightCm ?? null, + weightKg: user.weightKg ?? null, + sprintSpeedScore: user.sprintSpeedScore ?? null, + explosivePowerScore: user.explosivePowerScore ?? null, + agilityScore: user.agilityScore ?? null, + enduranceScore: user.enduranceScore ?? null, + flexibilityScore: user.flexibilityScore ?? null, + coreStabilityScore: user.coreStabilityScore ?? null, + shoulderMobilityScore: user.shoulderMobilityScore ?? null, + hipMobilityScore: user.hipMobilityScore ?? null, + assessmentNotes: user.assessmentNotes ?? null, + }, + }; +} + // ===== TRAINING PLAN OPERATIONS ===== export async function createTrainingPlan(plan: InsertTrainingPlan) { @@ -450,6 +551,15 @@ export async function getVideoById(videoId: number) { return result.length > 0 ? result[0] : undefined; } +export async function getUserVideoById(userId: number, videoId: number) { + const db = await getDb(); + if (!db) return undefined; + const result = await db.select().from(trainingVideos) + .where(and(eq(trainingVideos.userId, userId), eq(trainingVideos.id, videoId))) + .limit(1); + return result.length > 0 ? result[0] : undefined; +} + export async function getVideoByFileKey(userId: number, fileKey: string) { const db = await getDb(); if (!db) return undefined; @@ -465,6 +575,54 @@ export async function updateVideoStatus(videoId: number, status: "pending" | "an await db.update(trainingVideos).set({ analysisStatus: status }).where(eq(trainingVideos.id, videoId)); } +export async function updateUserVideo( + userId: number, + videoId: number, + patch: { + title?: string; + exerciseType?: string | null; + }, +) { + const db = await getDb(); + if (!db) return false; + + const video = await getUserVideoById(userId, videoId); + if (!video) return false; + + await db.update(trainingVideos) + .set({ + title: patch.title ?? video.title, + exerciseType: patch.exerciseType === undefined ? video.exerciseType : patch.exerciseType, + }) + .where(and(eq(trainingVideos.userId, userId), eq(trainingVideos.id, videoId))); + + return true; +} + +export async function deleteUserVideo(userId: number, videoId: number) { + const db = await getDb(); + if (!db) return false; + + const video = await getUserVideoById(userId, videoId); + if (!video) return false; + + await db.delete(poseAnalyses) + .where(and(eq(poseAnalyses.userId, userId), eq(poseAnalyses.videoId, videoId))); + + await db.update(trainingRecords) + .set({ videoId: null }) + .where(and(eq(trainingRecords.userId, userId), eq(trainingRecords.videoId, videoId))); + + await db.update(liveAnalysisSessions) + .set({ videoId: null, videoUrl: null }) + .where(and(eq(liveAnalysisSessions.userId, userId), eq(liveAnalysisSessions.videoId, videoId))); + + await db.delete(trainingVideos) + .where(and(eq(trainingVideos.userId, userId), eq(trainingVideos.id, videoId))); + + return true; +} + // ===== POSE ANALYSIS OPERATIONS ===== export async function createPoseAnalysis(analysis: InsertPoseAnalysis) { @@ -864,6 +1022,9 @@ function metricValueFromContext(metricKey: string, context: { ntrpRating: number; pkCount: number; planMatches: number; + tutorialCompletedCount: number; + aiDeployCompletedCount: number; + aiTestingCompletedCount: number; }) { const metricMap: Record = { training_days: context.trainingDays, @@ -877,6 +1038,9 @@ function metricValueFromContext(metricKey: string, context: { ntrp_rating: context.ntrpRating, pk_count: context.pkCount, plan_matches: context.planMatches, + tutorial_completed_count: context.tutorialCompletedCount, + ai_deploy_completed_count: context.aiDeployCompletedCount, + ai_testing_completed_count: context.aiTestingCompletedCount, }; return metricMap[metricKey] ?? 0; } @@ -890,8 +1054,22 @@ export async function refreshAchievementsForUser(userId: number) { const records = await db.select().from(trainingRecords).where(and(eq(trainingRecords.userId, userId), eq(trainingRecords.completed, 1))); const aggregates = await db.select().from(dailyTrainingAggregates).where(eq(dailyTrainingAggregates.userId, userId)); const liveSessions = await db.select().from(liveAnalysisSessions).where(eq(liveAnalysisSessions.userId, userId)); + const tutorialRows = await db.select({ + id: tutorialVideos.id, + topicArea: tutorialVideos.topicArea, + }).from(tutorialVideos); + const tutorialProgressRows = await db.select().from(tutorialProgress).where(eq(tutorialProgress.userId, userId)); const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + const tutorialTopicById = new Map(tutorialRows.map((row) => [row.id, row.topicArea || "tennis_skill"])); + const completedTutorials = tutorialProgressRows.filter((row) => row.completed === 1 || row.watched === 1); + const tutorialCompletedCount = completedTutorials.filter((row) => { + const topicArea = tutorialTopicById.get(row.tutorialId); + return topicArea === "ai_deploy" || topicArea === "ai_testing"; + }).length; + const aiDeployCompletedCount = completedTutorials.filter((row) => tutorialTopicById.get(row.tutorialId) === "ai_deploy").length; + const aiTestingCompletedCount = completedTutorials.filter((row) => tutorialTopicById.get(row.tutorialId) === "ai_testing").length; + const bestScore = Math.max( 0, ...records.map((record) => record.poseScore || 0), @@ -910,6 +1088,9 @@ export async function refreshAchievementsForUser(userId: number) { ntrpRating: userRow?.ntrpRating || 1.5, pkCount: records.filter(record => ((record.metadata as Record | null)?.sessionMode) === "pk").length, planMatches, + tutorialCompletedCount, + aiDeployCompletedCount, + aiTestingCompletedCount, }; const unlockedKeys: string[] = []; @@ -1364,143 +1545,236 @@ export async function failVisionTestRun(taskId: string, error: string) { // ===== TUTORIAL OPERATIONS ===== -export const TUTORIAL_SEED_DATA: Omit[] = [ +function tutorialSection(title: string, items: string[]) { + return { title, items }; +} + +const TENNIS_TUTORIAL_BASE = [ { + slug: "forehand-fundamentals", title: "正手击球基础", category: "forehand", - skillLevel: "beginner", + skillLevel: "beginner" as const, description: "学习正手击球的基本站位、握拍方式和挥拍轨迹,建立稳定的正手基础。", - keyPoints: JSON.stringify(["东方式或半西方式握拍", "侧身引拍,肩膀转动90度", "从低到高的挥拍轨迹", "随挥至对侧肩膀", "重心转移从后脚到前脚"]), - commonMistakes: JSON.stringify(["手腕过度发力", "没有转体", "击球点太靠后", "随挥不充分"]), + keyPoints: ["东方式或半西方式握拍", "侧身引拍,肩膀转动90度", "从低到高的挥拍轨迹", "随挥至对侧肩膀", "重心转移从后脚到前脚"], + commonMistakes: ["手腕过度发力", "没有转体", "击球点太靠后", "随挥不充分"], duration: 300, - sortOrder: 1, + sortOrder: 101, }, { + slug: "backhand-fundamentals", title: "反手击球基础", category: "backhand", - skillLevel: "beginner", + skillLevel: "beginner" as const, description: "掌握单手和双手反手的核心技术,包括握拍转换和击球时机。", - keyPoints: JSON.stringify(["双手反手更适合初学者", "早引拍,肩膀充分转动", "击球点在身体前方", "保持手臂伸展"]), - commonMistakes: JSON.stringify(["只用手臂发力", "击球点太迟", "缺少随挥", "脚步不到位"]), + keyPoints: ["双手反手更适合初学者", "早引拍,肩膀充分转动", "击球点在身体前方", "保持手臂伸展"], + commonMistakes: ["只用手臂发力", "击球点太迟", "缺少随挥", "脚步不到位"], duration: 300, - sortOrder: 2, + sortOrder: 102, }, { + slug: "serve-fundamentals", title: "发球技术", category: "serve", - skillLevel: "beginner", + skillLevel: "beginner" as const, description: "从抛球、引拍到击球的完整发球动作分解与练习。", - keyPoints: JSON.stringify(["稳定的抛球是关键", "大陆式握拍", "引拍时身体充分弓身", "最高点击球", "手腕内旋加速"]), - commonMistakes: JSON.stringify(["抛球不稳定", "手臂弯曲击球", "重心没有向前", "发力时机不对"]), + keyPoints: ["稳定的抛球是关键", "大陆式握拍", "引拍时身体充分弓身", "最高点击球", "手腕内旋加速"], + commonMistakes: ["抛球不稳定", "手臂弯曲击球", "重心没有向前", "发力时机不对"], duration: 360, - sortOrder: 3, + sortOrder: 103, }, { + slug: "volley-fundamentals", title: "截击技术", category: "volley", - skillLevel: "intermediate", + skillLevel: "intermediate" as const, description: "网前截击的站位、准备姿势和击球技巧。", - keyPoints: JSON.stringify(["分腿弯膝准备姿势", "拍头保持在视线前方", "短促的击球动作", "步伐迎向球"]), - commonMistakes: JSON.stringify(["挥拍幅度太大", "站位太远", "拍面角度不对", "重心太高"]), + keyPoints: ["分腿弯膝准备姿势", "拍头保持在视线前方", "短促的击球动作", "步伐迎向球"], + commonMistakes: ["挥拍幅度太大", "站位太远", "拍面角度不对", "重心太高"], duration: 240, - sortOrder: 4, + sortOrder: 104, }, { + slug: "footwork-fundamentals", title: "脚步移动训练", category: "footwork", - skillLevel: "beginner", + skillLevel: "beginner" as const, description: "网球基础脚步训练,包括分步、交叉步、滑步和回位。", - keyPoints: JSON.stringify(["分步判断球的方向", "交叉步快速移动", "小碎步调整位置", "击球后快速回中"]), - commonMistakes: JSON.stringify(["脚步懒散不移动", "重心太高", "回位太慢", "没有分步"]), + keyPoints: ["分步判断球的方向", "交叉步快速移动", "小碎步调整位置", "击球后快速回中"], + commonMistakes: ["脚步懒散不移动", "重心太高", "回位太慢", "没有分步"], duration: 240, - sortOrder: 5, + sortOrder: 105, }, { + slug: "forehand-topspin", title: "正手上旋", category: "forehand", - skillLevel: "intermediate", + skillLevel: "intermediate" as const, description: "掌握正手上旋球的发力技巧和拍面角度控制。", - keyPoints: JSON.stringify(["半西方式或西方式握拍", "从低到高的刷球动作", "加速手腕内旋", "随挥结束在头部上方"]), - commonMistakes: JSON.stringify(["拍面太开放", "没有刷球动作", "随挥不充分"]), + keyPoints: ["半西方式或西方式握拍", "从低到高的刷球动作", "加速手腕内旋", "随挥结束在头部上方"], + commonMistakes: ["拍面太开放", "没有刷球动作", "随挥不充分"], duration: 300, - sortOrder: 6, + sortOrder: 106, }, { + slug: "serve-spin-variations", title: "发球变化(切削/上旋)", category: "serve", - skillLevel: "advanced", - description: "高级发球技术,包括切削发球和Kick发球的动作要领。", - keyPoints: JSON.stringify(["切削发球:侧旋切球", "Kick发球:从下到上刷球", "抛球位置根据发球类型调整", "手腕加速是关键"]), - commonMistakes: JSON.stringify(["抛球位置没有变化", "旋转不足", "发力方向错误"]), + skillLevel: "advanced" as const, + description: "高级发球技术,包括切削发球和 Kick 发球的动作要领。", + keyPoints: ["切削发球:侧旋切球", "Kick 发球:从下到上刷球", "抛球位置根据发球类型调整", "手腕加速是关键"], + commonMistakes: ["抛球位置没有变化", "旋转不足", "发力方向错误"], duration: 360, - sortOrder: 7, + sortOrder: 107, }, { + slug: "shadow-swing", title: "影子挥拍练习", category: "shadow", - skillLevel: "beginner", + skillLevel: "beginner" as const, description: "不需要球的挥拍练习,专注于动作轨迹和肌肉记忆。", - keyPoints: JSON.stringify(["慢动作分解每个环节", "关注脚步和重心转移", "对着镜子检查姿势", "逐渐加快速度"]), - commonMistakes: JSON.stringify(["动作太快不规范", "忽略脚步", "没有完整的随挥"]), + keyPoints: ["慢动作分解每个环节", "关注脚步和重心转移", "对着镜子检查姿势", "逐渐加快速度"], + commonMistakes: ["动作太快不规范", "忽略脚步", "没有完整的随挥"], duration: 180, - sortOrder: 8, + sortOrder: 108, }, { + slug: "wall-drills", title: "墙壁练习技巧", category: "wall", - skillLevel: "beginner", + skillLevel: "beginner" as const, description: "利用墙壁进行的各种练习方法,提升控球和反应能力。", - keyPoints: JSON.stringify(["保持适当距离", "控制力量和方向", "交替练习正反手", "注意脚步移动"]), - commonMistakes: JSON.stringify(["力量太大控制不住", "站位太近或太远", "只练习一种击球"]), + keyPoints: ["保持适当距离", "控制力量和方向", "交替练习正反手", "注意脚步移动"], + commonMistakes: ["力量太大控制不住", "站位太近或太远", "只练习一种击球"], duration: 240, - sortOrder: 9, + sortOrder: 109, }, { + slug: "tennis-fitness", title: "体能训练", category: "fitness", - skillLevel: "beginner", + skillLevel: "beginner" as const, description: "网球专项体能训练,提升爆发力、敏捷性和耐力。", - keyPoints: JSON.stringify(["核心力量训练", "下肢爆发力练习", "敏捷性梯子训练", "拉伸和灵活性"]), - commonMistakes: JSON.stringify(["忽略热身", "训练过度", "动作不标准"]), + keyPoints: ["核心力量训练", "下肢爆发力练习", "敏捷性梯子训练", "拉伸和灵活性"], + commonMistakes: ["忽略热身", "训练过度", "动作不标准"], duration: 300, - sortOrder: 10, + sortOrder: 110, }, { + slug: "match-strategy", title: "比赛策略基础", category: "strategy", - skillLevel: "intermediate", + skillLevel: "intermediate" as const, description: "网球比赛中的基本战术和策略运用。", - keyPoints: JSON.stringify(["控制球场深度", "变换节奏和方向", "利用对手弱点", "网前战术时机"]), - commonMistakes: JSON.stringify(["打法单一", "没有计划", "心态波动大"]), + keyPoints: ["控制球场深度", "变换节奏和方向", "利用对手弱点", "网前战术时机"], + commonMistakes: ["打法单一", "没有计划", "心态波动大"], duration: 300, - sortOrder: 11, + sortOrder: 111, }, ]; +const TENNIS_TUTORIAL_SEED_DATA: Omit[] = TENNIS_TUTORIAL_BASE.map((tutorial) => ({ + ...tutorial, + topicArea: "tennis_skill", + contentFormat: "video", + sourcePlatform: "none", + heroSummary: tutorial.description, + estimatedEffortMinutes: Math.round((tutorial.duration || 0) / 60), + stepSections: [ + tutorialSection("训练目标", tutorial.keyPoints), + tutorialSection("常见错误", tutorial.commonMistakes), + ], + deliverables: [ + "明确当前动作的关键检查点", + "完成一轮自评并记录练习感受", + ], + relatedDocPaths: [], + isFeatured: 0, + featuredOrder: 0, +})); + +export const TUTORIAL_SEED_DATA: Omit[] = TENNIS_TUTORIAL_SEED_DATA; + export async function seedTutorials() { const db = await getDb(); if (!db) return; - const existing = await db.select().from(tutorialVideos).limit(1); - if (existing.length > 0) return; // Already seeded - for (const t of TUTORIAL_SEED_DATA) { - await db.insert(tutorialVideos).values(t); + + const existingRows = await db.select({ + id: tutorialVideos.id, + slug: tutorialVideos.slug, + title: tutorialVideos.title, + }).from(tutorialVideos); + + const bySlug = new Map(existingRows.filter((row) => row.slug).map((row) => [row.slug as string, row])); + const byTitle = new Map(existingRows.map((row) => [row.title, row])); + + for (const tutorial of TUTORIAL_SEED_DATA) { + const existing = (tutorial.slug ? bySlug.get(tutorial.slug) : undefined) || byTitle.get(tutorial.title); + if (existing) { + await db.update(tutorialVideos).set(tutorial).where(eq(tutorialVideos.id, existing.id)); + continue; + } + await db.insert(tutorialVideos).values(tutorial); } } -export async function getTutorials(category?: string, skillLevel?: string) { +async function refreshTutorialMetricsCache(rows: T[]) { + const db = await getDb(); + if (!db) return rows; + + return Promise.all(rows.map(async (row) => { + if (!shouldRefreshTutorialMetrics(row)) return row; + + try { + const metrics = await fetchTutorialMetrics(row.sourcePlatform || "", row.platformVideoId || ""); + if (!metrics) return row; + + const patch = { + viewCount: metrics.viewCount ?? row.viewCount ?? null, + commentCount: metrics.commentCount ?? row.commentCount ?? null, + thumbnailUrl: metrics.thumbnailUrl ?? row.thumbnailUrl ?? null, + metricsFetchedAt: metrics.fetchedAt, + }; + + await db.update(tutorialVideos).set(patch).where(eq(tutorialVideos.id, row.id)); + return { ...row, ...patch }; + } catch (error) { + console.warn(`[TutorialMetrics] Failed to refresh tutorial ${row.id}:`, error); + return row; + } + })); +} + +export async function getTutorials(category?: string, skillLevel?: string, topicArea?: string) { const db = await getDb(); if (!db) return []; let conditions = [eq(tutorialVideos.isPublished, 1)]; if (category) conditions.push(eq(tutorialVideos.category, category)); if (skillLevel) conditions.push(eq(tutorialVideos.skillLevel, skillLevel as any)); - return db.select().from(tutorialVideos).where(and(...conditions)).orderBy(tutorialVideos.sortOrder); + if (topicArea) conditions.push(eq(tutorialVideos.topicArea, topicArea)); + + const tutorials = await db.select().from(tutorialVideos) + .where(and(...conditions)) + .orderBy(asc(tutorialVideos.featuredOrder), asc(tutorialVideos.sortOrder), asc(tutorialVideos.id)); + + return refreshTutorialMetricsCache(tutorials); } export async function getTutorialById(id: number) { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(tutorialVideos).where(eq(tutorialVideos.id, id)).limit(1); - return result.length > 0 ? result[0] : undefined; + if (result.length === 0) return undefined; + const [hydrated] = await refreshTutorialMetricsCache(result); + return hydrated; } export async function getUserTutorialProgress(userId: number) { @@ -1509,16 +1783,23 @@ export async function getUserTutorialProgress(userId: number) { return db.select().from(tutorialProgress).where(eq(tutorialProgress.userId, userId)); } -export async function updateTutorialProgress(userId: number, tutorialId: number, data: { watched?: number; selfScore?: number; notes?: string; comparisonVideoId?: number }) { +export async function updateTutorialProgress(userId: number, tutorialId: number, data: { watched?: number; completed?: number; selfScore?: number; notes?: string; comparisonVideoId?: number }) { const db = await getDb(); if (!db) return; + const nextData: { watched?: number; completed?: number; completedAt?: Date | null; selfScore?: number; notes?: string; comparisonVideoId?: number } = { ...data }; + if (data.completed === 1 || data.watched === 1) { + nextData.completed = 1; + nextData.completedAt = new Date(); + } else if (data.completed === 0) { + nextData.completedAt = null; + } const existing = await db.select().from(tutorialProgress) .where(and(eq(tutorialProgress.userId, userId), eq(tutorialProgress.tutorialId, tutorialId))) .limit(1); if (existing.length > 0) { - await db.update(tutorialProgress).set(data).where(eq(tutorialProgress.id, existing[0].id)); + await db.update(tutorialProgress).set(nextData).where(eq(tutorialProgress.id, existing[0].id)); } else { - await db.insert(tutorialProgress).values({ userId, tutorialId, ...data }); + await db.insert(tutorialProgress).values({ userId, tutorialId, ...nextData }); } } @@ -1729,8 +2010,10 @@ export async function retryBackgroundTask(userId: number, taskId: string) { message: "任务已重新排队", error: null, result: null, + attempts: 0, workerId: null, lockedAt: null, + startedAt: null, completedAt: null, runAfter: new Date(), }).where(eq(backgroundTasks.id, taskId)); @@ -1784,6 +2067,7 @@ export async function getUserStats(userId: number) { const liveSessions = await db.select().from(liveAnalysisSessions).where(eq(liveAnalysisSessions.userId, userId)).orderBy(desc(liveAnalysisSessions.createdAt)).limit(10); const latestSnapshot = await getLatestNtrpSnapshot(userId); const achievements = await listUserAchievements(userId); + const trainingProfileStatus = getTrainingProfileStatus(userRow, latestSnapshot); const completedRecords = records.filter(r => r.completed === 1); const totalShots = Math.max( @@ -1807,5 +2091,6 @@ export async function getUserStats(userId: number) { dailyTraining: daily.reverse(), achievements, latestNtrpSnapshot: latestSnapshot ?? null, + trainingProfileStatus, }; } diff --git a/server/features.test.ts b/server/features.test.ts index 89914e6..12dfa09 100644 --- a/server/features.test.ts +++ b/server/features.test.ts @@ -20,6 +20,19 @@ function createTestUser(overrides?: Partial): AuthenticatedUs skillLevel: "beginner", trainingGoals: null, ntrpRating: 1.5, + manualNtrpRating: null, + manualNtrpCapturedAt: null, + heightCm: null, + weightKg: null, + sprintSpeedScore: null, + explosivePowerScore: null, + agilityScore: null, + enduranceScore: null, + flexibilityScore: null, + coreStabilityScore: null, + shoulderMobilityScore: null, + hipMobilityScore: null, + assessmentNotes: null, totalSessions: 0, totalMinutes: 0, totalShots: 0, @@ -101,6 +114,28 @@ describe("auth.logout", () => { path: "/", }); }); + + it("uses lax non-secure cookies for plain http requests", async () => { + const user = createTestUser(); + const { ctx, clearedCookies } = createMockContext(user); + ctx.req = { + protocol: "http", + headers: {}, + } as TrpcContext["req"]; + const caller = appRouter.createCaller(ctx); + + const result = await caller.auth.logout(); + + expect(result).toEqual({ success: true }); + expect(clearedCookies).toHaveLength(1); + expect(clearedCookies[0]?.options).toMatchObject({ + maxAge: -1, + secure: false, + sameSite: "lax", + httpOnly: true, + path: "/", + }); + }); }); describe("auth.loginWithUsername input validation", () => { @@ -217,6 +252,30 @@ describe("profile.update input validation", () => { } } }); + + it("accepts training assessment fields", async () => { + const user = createTestUser(); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + try { + await caller.profile.update({ + heightCm: 178, + weightKg: 68, + sprintSpeedScore: 4, + explosivePowerScore: 3, + agilityScore: 4, + enduranceScore: 3, + flexibilityScore: 3, + coreStabilityScore: 4, + shoulderMobilityScore: 3, + hipMobilityScore: 4, + manualNtrpRating: 2.5, + }); + } catch (e: any) { + expect(e.message).not.toContain("invalid_type"); + } + }); }); // ===== TRAINING PLAN TESTS ===== @@ -259,6 +318,19 @@ describe("plan.generate input validation", () => { caller.plan.generate({ skillLevel: "beginner", durationDays: 7 }) ).rejects.toThrow(); }); + + it("rejects generation when training profile is incomplete", async () => { + const user = createTestUser(); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + vi.spyOn(db, "getUserById").mockResolvedValueOnce(user); + vi.spyOn(db, "getLatestNtrpSnapshot").mockResolvedValueOnce(null as any); + + await expect( + caller.plan.generate({ skillLevel: "beginner", durationDays: 7 }) + ).rejects.toThrow(/训练计划生成前请先完善训练档案/); + }); }); describe("plan.list", () => { @@ -376,6 +448,152 @@ describe("video.get input validation", () => { }); }); +describe("video.get", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the current user's video", async () => { + const user = createTestUser({ id: 42 }); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + const createdAt = new Date("2026-03-15T06:00:00.000Z"); + + vi.spyOn(db, "getUserVideoById").mockResolvedValueOnce({ + id: 9, + userId: 42, + title: "Forehand Session", + fileKey: "videos/42/forehand.mp4", + url: "https://cdn.example.com/videos/42/forehand.mp4", + format: "mp4", + fileSize: 1024, + duration: 12, + exerciseType: "forehand", + analysisStatus: "completed", + createdAt, + updatedAt: createdAt, + } as any); + + const result = await caller.video.get({ videoId: 9 }); + + expect(result.title).toBe("Forehand Session"); + expect(db.getUserVideoById).toHaveBeenCalledWith(42, 9); + }); + + it("throws not found for videos outside the current user scope", async () => { + const user = createTestUser({ id: 42 }); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + vi.spyOn(db, "getUserVideoById").mockResolvedValueOnce(undefined); + + await expect(caller.video.get({ videoId: 999 })).rejects.toThrow("视频不存在"); + }); +}); + +describe("video.update input validation", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.video.update({ videoId: 1, title: "updated title" }) + ).rejects.toThrow(); + }); + + it("rejects empty title", async () => { + const user = createTestUser(); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.video.update({ videoId: 1, title: "" }) + ).rejects.toThrow(); + }); +}); + +describe("video.update", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("updates the current user's video metadata", async () => { + const user = createTestUser({ id: 7 }); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + const updateSpy = vi.spyOn(db, "updateUserVideo").mockResolvedValueOnce(true); + + const result = await caller.video.update({ + videoId: 14, + title: "Updated Backhand Session", + exerciseType: "backhand", + }); + + expect(result).toEqual({ success: true }); + expect(updateSpy).toHaveBeenCalledWith(7, 14, { + title: "Updated Backhand Session", + exerciseType: "backhand", + }); + }); + + it("throws not found when the video cannot be updated by the current user", async () => { + const user = createTestUser({ id: 7 }); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + vi.spyOn(db, "updateUserVideo").mockResolvedValueOnce(false); + + await expect( + caller.video.update({ + videoId: 14, + title: "Updated Backhand Session", + exerciseType: "backhand", + }) + ).rejects.toThrow("视频不存在"); + }); +}); + +describe("video.delete input validation", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.video.delete({ videoId: 1 }) + ).rejects.toThrow(); + }); +}); + +describe("video.delete", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("deletes the current user's video", async () => { + const user = createTestUser({ id: 11 }); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + const deleteSpy = vi.spyOn(db, "deleteUserVideo").mockResolvedValueOnce(true); + + const result = await caller.video.delete({ videoId: 20 }); + + expect(result).toEqual({ success: true }); + expect(deleteSpy).toHaveBeenCalledWith(11, 20); + }); + + it("throws not found when the current user does not own the video", async () => { + const user = createTestUser({ id: 11 }); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + vi.spyOn(db, "deleteUserVideo").mockResolvedValueOnce(false); + + await expect(caller.video.delete({ videoId: 20 })).rejects.toThrow("视频不存在"); + }); +}); + // ===== ANALYSIS TESTS ===== describe("analysis.save input validation", () => { @@ -700,6 +918,17 @@ describe("tutorial.list", () => { expect(e.message).not.toContain("invalid_type"); } }); + + it("accepts topicArea filter", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + + try { + await caller.tutorial.list({ topicArea: "tennis_skill" }); + } catch (e: any) { + expect(e.message).not.toContain("invalid_type"); + } + }); }); describe("tutorial.progress", () => { @@ -729,7 +958,7 @@ describe("tutorial.updateProgress input validation", () => { ).rejects.toThrow(); }); - it("accepts optional watched, selfScore, notes", async () => { + it("accepts optional watched, completed, selfScore, notes", async () => { const user = createTestUser(); const { ctx } = createMockContext(user); const caller = appRouter.createCaller(ctx); @@ -738,6 +967,7 @@ describe("tutorial.updateProgress input validation", () => { await caller.tutorial.updateProgress({ tutorialId: 1, watched: 1, + completed: 1, selfScore: 4, notes: "Great tutorial", }); diff --git a/server/routers.ts b/server/routers.ts index 7b4314a..ba28eda 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -54,6 +54,25 @@ async function auditAdminAction(params: { }); } +const manualNtrpSchema = z.number().min(1).max(5); +const scoreSchema = z.number().int().min(1).max(5); +const trainingProfileUpdateSchema = z.object({ + skillLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(), + trainingGoals: z.string().max(2000).optional(), + manualNtrpRating: manualNtrpSchema.nullable().optional(), + heightCm: z.number().min(100).max(240).nullable().optional(), + weightKg: z.number().min(30).max(250).nullable().optional(), + sprintSpeedScore: scoreSchema.nullable().optional(), + explosivePowerScore: scoreSchema.nullable().optional(), + agilityScore: scoreSchema.nullable().optional(), + enduranceScore: scoreSchema.nullable().optional(), + flexibilityScore: scoreSchema.nullable().optional(), + coreStabilityScore: scoreSchema.nullable().optional(), + shoulderMobilityScore: scoreSchema.nullable().optional(), + hipMobilityScore: scoreSchema.nullable().optional(), + assessmentNotes: z.string().max(2000).nullable().optional(), +}); + export const appRouter = router({ system: systemRouter, @@ -92,12 +111,12 @@ export const appRouter = router({ // User profile management profile: router({ update: protectedProcedure - .input(z.object({ - skillLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(), - trainingGoals: z.string().optional(), - })) + .input(trainingProfileUpdateSchema) .mutation(async ({ ctx, input }) => { - await db.updateUserProfile(ctx.user.id, input); + await db.updateUserProfile(ctx.user.id, { + ...input, + manualNtrpCapturedAt: input.manualNtrpRating != null ? new Date() : input.manualNtrpRating === null ? null : undefined, + }); return { success: true }; }), stats: protectedProcedure.query(async ({ ctx }) => { @@ -114,6 +133,21 @@ export const appRouter = router({ focusAreas: z.array(z.string()).optional(), })) .mutation(async ({ ctx, input }) => { + const currentUser = await db.getUserById(ctx.user.id); + if (!currentUser) { + throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" }); + } + + const latestSnapshot = await db.getLatestNtrpSnapshot(ctx.user.id); + const missingFields = db.getMissingTrainingProfileFields(currentUser, Boolean(latestSnapshot?.rating != null)); + if (missingFields.length > 0) { + const missingLabels = missingFields.map((field) => db.TRAINING_PROFILE_FIELD_LABELS[field]).join("、"); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `训练计划生成前请先完善训练档案:${missingLabels}`, + }); + } + return enqueueTask({ userId: ctx.user.id, type: "training_plan_generate", @@ -210,8 +244,12 @@ export const appRouter = router({ get: protectedProcedure .input(z.object({ videoId: z.number() })) - .query(async ({ input }) => { - return db.getVideoById(input.videoId); + .query(async ({ ctx, input }) => { + const video = await db.getUserVideoById(ctx.user.id, input.videoId); + if (!video) { + throw new TRPCError({ code: "NOT_FOUND", message: "视频不存在" }); + } + return video; }), updateStatus: protectedProcedure @@ -223,6 +261,33 @@ export const appRouter = router({ await db.updateVideoStatus(input.videoId, input.status); return { success: true }; }), + + update: protectedProcedure + .input(z.object({ + videoId: z.number(), + title: z.string().trim().min(1).max(256), + exerciseType: z.string().trim().max(64).optional(), + })) + .mutation(async ({ ctx, input }) => { + const updated = await db.updateUserVideo(ctx.user.id, input.videoId, { + title: input.title, + exerciseType: input.exerciseType?.trim() ? input.exerciseType.trim() : null, + }); + if (!updated) { + throw new TRPCError({ code: "NOT_FOUND", message: "视频不存在" }); + } + return { success: true }; + }), + + delete: protectedProcedure + .input(z.object({ videoId: z.number() })) + .mutation(async ({ ctx, input }) => { + const deleted = await db.deleteUserVideo(ctx.user.id, input.videoId); + if (!deleted) { + throw new TRPCError({ code: "NOT_FOUND", message: "视频不存在" }); + } + return { success: true }; + }), }), // Pose analysis @@ -836,16 +901,17 @@ export const appRouter = router({ .input(z.object({ category: z.string().optional(), skillLevel: z.string().optional(), + topicArea: z.string().optional(), }).optional()) .query(async ({ input }) => { - // Auto-seed tutorials on first request await db.seedTutorials(); - return db.getTutorials(input?.category, input?.skillLevel); + return db.getTutorials(input?.category, input?.skillLevel, input?.topicArea); }), get: publicProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { + await db.seedTutorials(); return db.getTutorialById(input.id); }), @@ -857,6 +923,7 @@ export const appRouter = router({ .input(z.object({ tutorialId: z.number(), watched: z.number().optional(), + completed: z.number().optional(), selfScore: z.number().optional(), notes: z.string().optional(), comparisonVideoId: z.number().optional(), @@ -864,7 +931,8 @@ export const appRouter = router({ .mutation(async ({ ctx, input }) => { const { tutorialId, ...data } = input; await db.updateTutorialProgress(ctx.user.id, tutorialId, data); - return { success: true }; + const unlockedKeys = await db.refreshAchievementsForUser(ctx.user.id); + return { success: true, unlockedAchievementKeys: unlockedKeys }; }), }),