diff --git a/.gitkeep b/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.manus/db/db-query-1773487370636.json b/.manus/db/db-query-1773487370636.json
new file mode 100644
index 0000000..68f5b65
--- /dev/null
+++ b/.manus/db/db-query-1773487370636.json
@@ -0,0 +1,9 @@
+{
+ "query": "CREATE TABLE `pose_analyses` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`videoId` int NOT NULL,\n\t`userId` int NOT NULL,\n\t`overallScore` float,\n\t`poseMetrics` json,\n\t`detectedIssues` json,\n\t`corrections` json,\n\t`exerciseType` varchar(64),\n\t`framesAnalyzed` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `pose_analyses_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_plans` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') NOT NULL,\n\t`durationDays` int NOT NULL DEFAULT 7,\n\t`exercises` json NOT NULL,\n\t`isActive` int NOT NULL DEFAULT 1,\n\t`adjustmentNotes` text,\n\t`version` int NOT NULL DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_plans_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_records` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`planId` int,\n\t`exerciseName` varchar(128) NOT NULL,\n\t`durationMinutes` int,\n\t`completed` int NOT NULL DEFAULT 0,\n\t`notes` text,\n\t`poseScore` float,\n\t`trainingDate` timestamp NOT NULL DEFAULT (now()),\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `training_records_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`fileKey` varchar(512) NOT NULL,\n\t`url` text NOT NULL,\n\t`format` varchar(16) NOT NULL,\n\t`fileSize` int,\n\t`duration` float,\n\t`exerciseType` varchar(64),\n\t`analysisStatus` enum('pending','analyzing','completed','failed') DEFAULT 'pending',\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_videos_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `username_accounts` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`username` varchar(64) NOT NULL,\n\t`userId` int NOT NULL,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `username_accounts_id` PRIMARY KEY(`id`),\n\tCONSTRAINT `username_accounts_username_unique` UNIQUE(`username`)\n);\n\nALTER TABLE `users` ADD `skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner';\nALTER TABLE `users` ADD `trainingGoals` text;",
+ "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway04.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2DECURBBieadmmU.root --database auVVpV3E7dpuxwRrSUT9kL --execute CREATE TABLE `pose_analyses` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`videoId` int NOT NULL,\n\t`userId` int NOT NULL,\n\t`overallScore` float,\n\t`poseMetrics` json,\n\t`detectedIssues` json,\n\t`corrections` json,\n\t`exerciseType` varchar(64),\n\t`framesAnalyzed` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `pose_analyses_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_plans` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') NOT NULL,\n\t`durationDays` int NOT NULL DEFAULT 7,\n\t`exercises` json NOT NULL,\n\t`isActive` int NOT NULL DEFAULT 1,\n\t`adjustmentNotes` text,\n\t`version` int NOT NULL DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_plans_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_records` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`planId` int,\n\t`exerciseName` varchar(128) NOT NULL,\n\t`durationMinutes` int,\n\t`completed` int NOT NULL DEFAULT 0,\n\t`notes` text,\n\t`poseScore` float,\n\t`trainingDate` timestamp NOT NULL DEFAULT (now()),\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `training_records_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`fileKey` varchar(512) NOT NULL,\n\t`url` text NOT NULL,\n\t`format` varchar(16) NOT NULL,\n\t`fileSize` int,\n\t`duration` float,\n\t`exerciseType` varchar(64),\n\t`analysisStatus` enum('pending','analyzing','completed','failed') DEFAULT 'pending',\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_videos_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `username_accounts` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`username` varchar(64) NOT NULL,\n\t`userId` int NOT NULL,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `username_accounts_id` PRIMARY KEY(`id`),\n\tCONSTRAINT `username_accounts_username_unique` UNIQUE(`username`)\n);\n\nALTER TABLE `users` ADD `skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner';\nALTER TABLE `users` ADD `trainingGoals` text;",
+ "rows": [],
+ "messages": [],
+ "stdout": "",
+ "stderr": "",
+ "execution_time_ms": 5704
+}
\ No newline at end of file
diff --git a/.manus/db/db-query-1773487661413.json b/.manus/db/db-query-1773487661413.json
new file mode 100644
index 0000000..08893aa
--- /dev/null
+++ b/.manus/db/db-query-1773487661413.json
@@ -0,0 +1,9 @@
+{
+ "query": "CREATE TABLE `rating_history` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`rating` float NOT NULL,\n\t`reason` varchar(256),\n\t`dimensionScores` json,\n\t`analysisId` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `rating_history_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `pose_analyses` ADD `shotCount` int DEFAULT 0;\nALTER TABLE `pose_analyses` ADD `avgSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `maxSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `totalMovementDistance` float;\nALTER TABLE `pose_analyses` ADD `strokeConsistency` float;\nALTER TABLE `pose_analyses` ADD `footworkScore` float;\nALTER TABLE `pose_analyses` ADD `fluidityScore` float;\nALTER TABLE `pose_analyses` ADD `keyMoments` json;\nALTER TABLE `pose_analyses` ADD `movementTrajectory` json;\nALTER TABLE `users` ADD `ntrpRating` float DEFAULT 1.5;\nALTER TABLE `users` ADD `totalSessions` int DEFAULT 0;\nALTER TABLE `users` ADD `totalMinutes` int DEFAULT 0;",
+ "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway04.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2DECURBBieadmmU.root --database auVVpV3E7dpuxwRrSUT9kL --execute CREATE TABLE `rating_history` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`rating` float NOT NULL,\n\t`reason` varchar(256),\n\t`dimensionScores` json,\n\t`analysisId` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `rating_history_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `pose_analyses` ADD `shotCount` int DEFAULT 0;\nALTER TABLE `pose_analyses` ADD `avgSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `maxSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `totalMovementDistance` float;\nALTER TABLE `pose_analyses` ADD `strokeConsistency` float;\nALTER TABLE `pose_analyses` ADD `footworkScore` float;\nALTER TABLE `pose_analyses` ADD `fluidityScore` float;\nALTER TABLE `pose_analyses` ADD `keyMoments` json;\nALTER TABLE `pose_analyses` ADD `movementTrajectory` json;\nALTER TABLE `users` ADD `ntrpRating` float DEFAULT 1.5;\nALTER TABLE `users` ADD `totalSessions` int DEFAULT 0;\nALTER TABLE `users` ADD `totalMinutes` int DEFAULT 0;",
+ "rows": [],
+ "messages": [],
+ "stdout": "",
+ "stderr": "",
+ "execution_time_ms": 7485
+}
\ No newline at end of file
diff --git a/client/index.html b/client/index.html
index 0aafa3b..6310b19 100644
--- a/client/index.html
+++ b/client/index.html
@@ -1,17 +1,15 @@
-
+
- Tennis Training Hub - AI网球训练助手
-
+
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 5c7a610..f244875 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -4,32 +4,57 @@ import NotFound from "@/pages/NotFound";
import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
+import DashboardLayout from "./components/DashboardLayout";
import Home from "./pages/Home";
+import Login from "./pages/Login";
+import Dashboard from "./pages/Dashboard";
+import Training from "./pages/Training";
+import Analysis from "./pages/Analysis";
+import Videos from "./pages/Videos";
+import Progress from "./pages/Progress";
+import Rating from "./pages/Rating";
+
+function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
+ return (
+
+
+
+ );
+}
function Router() {
- // make sure to consider if you need authentication for certain routes
return (
-
-
- {/* Final fallback route */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
-// NOTE: About Theme
-// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css
-// to keep consistent foreground/background color across components
-// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook
-
function App() {
return (
-
+
diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx
index 0bf7437..3386b74 100644
--- a/client/src/components/DashboardLayout.tsx
+++ b/client/src/components/DashboardLayout.tsx
@@ -19,23 +19,28 @@ import {
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
-import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
-import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
+import {
+ LayoutDashboard, LogOut, PanelLeft, Target, Video,
+ Award, Activity, FileVideo
+} from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
-import { useLocation } from "wouter";
+import { useLocation, Redirect } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
-import { Button } from "./ui/button";
const menuItems = [
- { icon: LayoutDashboard, label: "Page 1", path: "/" },
- { icon: Users, label: "Page 2", path: "/some-path" },
+ { icon: LayoutDashboard, label: "仪表盘", path: "/dashboard" },
+ { icon: Target, label: "训练计划", path: "/training" },
+ { icon: Video, label: "视频分析", path: "/analysis" },
+ { icon: FileVideo, label: "视频库", path: "/videos" },
+ { icon: Activity, label: "训练进度", path: "/progress" },
+ { icon: Award, label: "NTRP评分", path: "/rating" },
];
const SIDEBAR_WIDTH_KEY = "sidebar-width";
-const DEFAULT_WIDTH = 280;
+const DEFAULT_WIDTH = 260;
const MIN_WIDTH = 200;
-const MAX_WIDTH = 480;
+const MAX_WIDTH = 400;
export default function DashboardLayout({
children,
@@ -57,29 +62,7 @@ export default function DashboardLayout({
}
if (!user) {
- return (
-
-
-
-
- Sign in to continue
-
-
- Access to this dashboard requires authentication. Continue to launch the login flow.
-
-
-
{
- window.location.href = getLoginUrl();
- }}
- size="lg"
- className="w-full shadow-lg hover:shadow-xl transition-all"
- >
- Sign in
-
-
-
- );
+ return ;
}
return (
@@ -124,7 +107,6 @@ function DashboardLayoutContent({
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
-
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
const newWidth = e.clientX - sidebarLeft;
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
@@ -170,8 +152,9 @@ function DashboardLayoutContent({
{!isCollapsed ? (
-
- Navigation
+
+
+ Tennis Hub
) : null}
@@ -206,16 +189,16 @@ function DashboardLayoutContent({
-
- {user?.name?.charAt(0).toUpperCase()}
+
+ {user?.name?.charAt(0).toUpperCase() || "U"}
- {user?.name || "-"}
+ {user?.name || "用户"}
- {user?.email || "-"}
+ {user?.email || "网球训练中"}
@@ -226,7 +209,7 @@ function DashboardLayoutContent({
className="cursor-pointer text-destructive focus:text-destructive"
>
- Sign out
+ 退出登录
@@ -249,15 +232,15 @@ function DashboardLayoutContent({
-
- {activeMenuItem?.label ?? "Menu"}
+
+ {activeMenuItem?.label ?? "Tennis Hub"}
)}
- {children}
+ {children}
>
);
diff --git a/client/src/index.css b/client/src/index.css
index 72b423d..8b81e49 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -40,77 +40,79 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
+ --font-sans: 'Inter', 'Noto Sans SC', system-ui, sans-serif;
}
:root {
- --primary: var(--color-blue-700);
- --primary-foreground: var(--color-blue-50);
- --sidebar-primary: var(--color-blue-600);
- --sidebar-primary-foreground: var(--color-blue-50);
- --chart-1: var(--color-blue-300);
- --chart-2: var(--color-blue-500);
- --chart-3: var(--color-blue-600);
- --chart-4: var(--color-blue-700);
- --chart-5: var(--color-blue-800);
- --radius: 0.65rem;
- --background: oklch(1 0 0);
- --foreground: oklch(0.235 0.015 65);
+ /* Tennis green theme */
+ --primary: oklch(0.55 0.16 145);
+ --primary-foreground: oklch(0.98 0 0);
+ --sidebar-primary: oklch(0.50 0.15 145);
+ --sidebar-primary-foreground: oklch(0.98 0 0);
+ --chart-1: oklch(0.65 0.18 145);
+ --chart-2: oklch(0.55 0.16 145);
+ --chart-3: oklch(0.72 0.12 80);
+ --chart-4: oklch(0.60 0.14 200);
+ --chart-5: oklch(0.50 0.10 280);
+ --radius: 0.625rem;
+ --background: oklch(0.985 0.002 100);
+ --foreground: oklch(0.18 0.02 260);
--card: oklch(1 0 0);
- --card-foreground: oklch(0.235 0.015 65);
+ --card-foreground: oklch(0.18 0.02 260);
--popover: oklch(1 0 0);
- --popover-foreground: oklch(0.235 0.015 65);
- --secondary: oklch(0.98 0.001 286.375);
- --secondary-foreground: oklch(0.4 0.015 65);
- --muted: oklch(0.967 0.001 286.375);
- --muted-foreground: oklch(0.552 0.016 285.938);
- --accent: oklch(0.967 0.001 286.375);
- --accent-foreground: oklch(0.141 0.005 285.823);
+ --popover-foreground: oklch(0.18 0.02 260);
+ --secondary: oklch(0.96 0.01 145);
+ --secondary-foreground: oklch(0.35 0.08 145);
+ --muted: oklch(0.96 0.005 260);
+ --muted-foreground: oklch(0.50 0.02 260);
+ --accent: oklch(0.95 0.02 145);
+ --accent-foreground: oklch(0.25 0.06 145);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
- --border: oklch(0.92 0.004 286.32);
- --input: oklch(0.92 0.004 286.32);
- --ring: oklch(0.623 0.214 259.815);
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.235 0.015 65);
- --sidebar-accent: oklch(0.967 0.001 286.375);
- --sidebar-accent-foreground: oklch(0.141 0.005 285.823);
- --sidebar-border: oklch(0.92 0.004 286.32);
- --sidebar-ring: oklch(0.623 0.214 259.815);
+ --border: oklch(0.91 0.005 145);
+ --input: oklch(0.91 0.005 145);
+ --ring: oklch(0.55 0.16 145);
+ --sidebar: oklch(0.98 0.005 145);
+ --sidebar-foreground: oklch(0.18 0.02 260);
+ --sidebar-accent: oklch(0.94 0.02 145);
+ --sidebar-accent-foreground: oklch(0.25 0.06 145);
+ --sidebar-border: oklch(0.91 0.005 145);
+ --sidebar-ring: oklch(0.55 0.16 145);
}
.dark {
- --primary: var(--color-blue-700);
- --primary-foreground: var(--color-blue-50);
- --sidebar-primary: var(--color-blue-500);
- --sidebar-primary-foreground: var(--color-blue-50);
- --background: oklch(0.141 0.005 285.823);
- --foreground: oklch(0.85 0.005 65);
- --card: oklch(0.21 0.006 285.885);
- --card-foreground: oklch(0.85 0.005 65);
- --popover: oklch(0.21 0.006 285.885);
- --popover-foreground: oklch(0.85 0.005 65);
- --secondary: oklch(0.24 0.006 286.033);
- --secondary-foreground: oklch(0.7 0.005 65);
- --muted: oklch(0.274 0.006 286.033);
- --muted-foreground: oklch(0.705 0.015 286.067);
- --accent: oklch(0.274 0.006 286.033);
- --accent-foreground: oklch(0.92 0.005 65);
+ --primary: oklch(0.65 0.18 145);
+ --primary-foreground: oklch(0.12 0.02 145);
+ --sidebar-primary: oklch(0.60 0.16 145);
+ --sidebar-primary-foreground: oklch(0.12 0.02 145);
+ --background: oklch(0.14 0.01 260);
+ --foreground: oklch(0.90 0.005 100);
+ --card: oklch(0.19 0.01 260);
+ --card-foreground: oklch(0.90 0.005 100);
+ --popover: oklch(0.19 0.01 260);
+ --popover-foreground: oklch(0.90 0.005 100);
+ --secondary: oklch(0.22 0.015 145);
+ --secondary-foreground: oklch(0.75 0.08 145);
+ --muted: oklch(0.25 0.01 260);
+ --muted-foreground: oklch(0.65 0.015 260);
+ --accent: oklch(0.25 0.02 145);
+ --accent-foreground: oklch(0.85 0.06 145);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
- --ring: oklch(0.488 0.243 264.376);
- --chart-1: var(--color-blue-300);
- --chart-2: var(--color-blue-500);
- --chart-3: var(--color-blue-600);
- --chart-4: var(--color-blue-700);
- --chart-5: var(--color-blue-800);
- --sidebar: oklch(0.21 0.006 285.885);
- --sidebar-foreground: oklch(0.85 0.005 65);
- --sidebar-accent: oklch(0.274 0.006 286.033);
- --sidebar-accent-foreground: oklch(0.985 0 0);
+ --ring: oklch(0.65 0.18 145);
+ --chart-1: oklch(0.65 0.18 145);
+ --chart-2: oklch(0.55 0.16 145);
+ --chart-3: oklch(0.72 0.12 80);
+ --chart-4: oklch(0.60 0.14 200);
+ --chart-5: oklch(0.50 0.10 280);
+ --sidebar: oklch(0.19 0.01 260);
+ --sidebar-foreground: oklch(0.90 0.005 100);
+ --sidebar-accent: oklch(0.25 0.02 145);
+ --sidebar-accent-foreground: oklch(0.85 0.06 145);
--sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.488 0.243 264.376);
+ --sidebar-ring: oklch(0.65 0.18 145);
}
@layer base {
@@ -134,24 +136,11 @@
}
@layer components {
- /**
- * Custom container utility that centers content and adds responsive padding.
- *
- * This overrides Tailwind's default container behavior to:
- * - Auto-center content (mx-auto)
- * - Add responsive horizontal padding
- * - Set max-width for large screens
- *
- * Usage: ...
- *
- * For custom widths, use max-w-* utilities directly:
- * ...
- */
.container {
width: 100%;
margin-left: auto;
margin-right: auto;
- padding-left: 1rem; /* 16px - mobile padding */
+ padding-left: 1rem;
padding-right: 1rem;
}
@@ -162,16 +151,16 @@
@media (min-width: 640px) {
.container {
- padding-left: 1.5rem; /* 24px - tablet padding */
+ padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.container {
- padding-left: 2rem; /* 32px - desktop padding */
+ padding-left: 2rem;
padding-right: 2rem;
- max-width: 1280px; /* Standard content width */
+ max-width: 1280px;
}
}
-}
\ No newline at end of file
+}
diff --git a/client/src/pages/Analysis.tsx b/client/src/pages/Analysis.tsx
new file mode 100644
index 0000000..9fbc498
--- /dev/null
+++ b/client/src/pages/Analysis.tsx
@@ -0,0 +1,669 @@
+import { useState, useRef, useCallback, useEffect } from "react";
+import { useAuth } from "@/_core/hooks/useAuth";
+import { trpc } from "@/lib/trpc";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Progress } from "@/components/ui/progress";
+import { Badge } from "@/components/ui/badge";
+import { toast } from "sonner";
+import {
+ Upload, Video, Loader2, Play, Pause, RotateCcw,
+ Zap, Target, Activity, TrendingUp, Eye
+} from "lucide-react";
+import { Streamdown } from "streamdown";
+
+type AnalysisResult = {
+ overallScore: number;
+ shotCount: number;
+ avgSwingSpeed: number;
+ maxSwingSpeed: number;
+ totalMovementDistance: number;
+ strokeConsistency: number;
+ footworkScore: number;
+ fluidityScore: number;
+ poseMetrics: any;
+ detectedIssues: string[];
+ keyMoments: any[];
+ movementTrajectory: any[];
+ framesAnalyzed: number;
+};
+
+export default function Analysis() {
+ const { user } = useAuth();
+ const [videoFile, setVideoFile] = useState(null);
+ const [videoUrl, setVideoUrl] = useState("");
+ const [exerciseType, setExerciseType] = useState("forehand");
+ const [isAnalyzing, setIsAnalyzing] = useState(false);
+ const [analysisProgress, setAnalysisProgress] = useState(0);
+ const [analysisResult, setAnalysisResult] = useState(null);
+ const [corrections, setCorrections] = useState("");
+ const [showSkeleton, setShowSkeleton] = useState(false);
+ const videoRef = useRef(null);
+ const canvasRef = useRef(null);
+ const fileInputRef = useRef(null);
+
+ const utils = trpc.useUtils();
+
+ const uploadMutation = trpc.video.upload.useMutation();
+ const saveMutation = trpc.analysis.save.useMutation({
+ onSuccess: () => {
+ utils.profile.stats.invalidate();
+ utils.analysis.list.invalidate();
+ utils.rating.current.invalidate();
+ utils.rating.history.invalidate();
+ },
+ });
+ const correctionMutation = trpc.analysis.getCorrections.useMutation();
+
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const ext = file.name.split(".").pop()?.toLowerCase();
+ if (!["mp4", "webm"].includes(ext || "")) {
+ toast.error("仅支持 MP4 和 WebM 格式");
+ return;
+ }
+ if (file.size > 100 * 1024 * 1024) {
+ toast.error("文件大小不能超过100MB");
+ return;
+ }
+ setVideoFile(file);
+ setVideoUrl(URL.createObjectURL(file));
+ setAnalysisResult(null);
+ setCorrections("");
+ };
+
+ const analyzeVideo = useCallback(async () => {
+ if (!videoRef.current || !canvasRef.current || !videoFile) return;
+
+ setIsAnalyzing(true);
+ setAnalysisProgress(0);
+ setShowSkeleton(true);
+
+ try {
+ // Load MediaPipe Pose
+ const { Pose } = await import("@mediapipe/pose");
+ const { drawConnectors, drawLandmarks } = await import("@mediapipe/drawing_utils");
+ const { POSE_CONNECTIONS } = await import("@mediapipe/pose");
+
+ const pose = new Pose({
+ locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`,
+ });
+
+ pose.setOptions({
+ modelComplexity: 1,
+ smoothLandmarks: true,
+ enableSegmentation: false,
+ minDetectionConfidence: 0.5,
+ minTrackingConfidence: 0.5,
+ });
+
+ const video = videoRef.current;
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext("2d")!;
+
+ // Analysis accumulators
+ let framesAnalyzed = 0;
+ let allPoseData: any[] = [];
+ let swingSpeedHistory: number[] = [];
+ let prevWristPos: { x: number; y: number } | null = null;
+ let shotCount = 0;
+ let prevWristSpeed = 0;
+ let isInSwing = false;
+ let movementTrajectory: { x: number; y: number; frame: number }[] = [];
+ let prevHipCenter: { x: number; y: number } | null = null;
+ let totalMovement = 0;
+ let keyMoments: { frame: number; type: string; description: string }[] = [];
+ let jointAnglesHistory: any[] = [];
+
+ // Pose callback
+ pose.onResults((results: any) => {
+ if (!results.poseLandmarks) return;
+
+ const landmarks = results.poseLandmarks;
+ framesAnalyzed++;
+
+ // Draw skeleton on canvas
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ ctx.drawImage(video, 0, 0);
+
+ if (showSkeleton) {
+ drawConnectors(ctx, landmarks, POSE_CONNECTIONS, { color: "#00FF00", lineWidth: 2 });
+ drawLandmarks(ctx, landmarks, { color: "#FF0000", lineWidth: 1, radius: 3 });
+ }
+
+ // Calculate joint angles
+ const angles = calculateJointAngles(landmarks);
+ jointAnglesHistory.push(angles);
+
+ // Wrist tracking for swing speed (right wrist = index 16)
+ const rightWrist = landmarks[16];
+ const leftWrist = landmarks[15];
+ const dominantWrist = exerciseType.includes("backhand") ? leftWrist : rightWrist;
+
+ if (dominantWrist && prevWristPos) {
+ const dx = (dominantWrist.x - prevWristPos.x) * canvas.width;
+ const dy = (dominantWrist.y - prevWristPos.y) * canvas.height;
+ const speed = Math.sqrt(dx * dx + dy * dy);
+ swingSpeedHistory.push(speed);
+
+ // Shot detection: speed spike above threshold
+ if (speed > 15 && prevWristSpeed < 15 && !isInSwing) {
+ isInSwing = true;
+ shotCount++;
+ keyMoments.push({
+ frame: framesAnalyzed,
+ type: "shot",
+ description: `第${shotCount}次击球 - 挥拍速度: ${speed.toFixed(1)}px/frame`,
+ });
+ }
+ if (speed < 5 && isInSwing) {
+ isInSwing = false;
+ }
+ prevWristSpeed = speed;
+ }
+ if (dominantWrist) {
+ prevWristPos = { x: dominantWrist.x, y: dominantWrist.y };
+ }
+
+ // Body center tracking (hip midpoint)
+ const leftHip = landmarks[23];
+ const rightHip = landmarks[24];
+ if (leftHip && rightHip) {
+ const hipCenter = {
+ x: (leftHip.x + rightHip.x) / 2,
+ y: (leftHip.y + rightHip.y) / 2,
+ };
+ movementTrajectory.push({ ...hipCenter, frame: framesAnalyzed });
+
+ if (prevHipCenter) {
+ const dx = (hipCenter.x - prevHipCenter.x) * canvas.width;
+ const dy = (hipCenter.y - prevHipCenter.y) * canvas.height;
+ totalMovement += Math.sqrt(dx * dx + dy * dy);
+ }
+ prevHipCenter = hipCenter;
+ }
+
+ allPoseData.push(landmarks);
+ });
+
+ // Process video frames
+ const fps = 15; // Sample at 15fps for performance
+ const duration = video.duration;
+ const totalFrames = Math.floor(duration * fps);
+ let currentFrame = 0;
+
+ video.currentTime = 0;
+
+ await new Promise((resolve) => {
+ const processFrame = async () => {
+ if (currentFrame >= totalFrames || video.currentTime >= duration) {
+ resolve();
+ return;
+ }
+
+ await pose.send({ image: video });
+ currentFrame++;
+ setAnalysisProgress(Math.round((currentFrame / totalFrames) * 100));
+
+ video.currentTime = currentFrame / fps;
+ video.onseeked = () => {
+ requestAnimationFrame(processFrame);
+ };
+ };
+
+ video.onseeked = () => processFrame();
+ video.currentTime = 0;
+ });
+
+ // Calculate final metrics
+ const avgSwingSpeed = swingSpeedHistory.length > 0
+ ? swingSpeedHistory.reduce((a, b) => a + b, 0) / swingSpeedHistory.length : 0;
+ const maxSwingSpeed = swingSpeedHistory.length > 0
+ ? Math.max(...swingSpeedHistory) : 0;
+
+ // Stroke consistency: std deviation of swing speeds during shots
+ const shotSpeeds = swingSpeedHistory.filter(s => s > 10);
+ const strokeConsistency = calculateConsistency(shotSpeeds);
+
+ // Footwork score based on movement patterns
+ const footworkScore = calculateFootworkScore(movementTrajectory, canvas.width, canvas.height);
+
+ // Fluidity score based on angle smoothness
+ const fluidityScore = calculateFluidityScore(jointAnglesHistory);
+
+ // Overall score
+ const overallScore = Math.round(
+ strokeConsistency * 0.25 +
+ footworkScore * 0.25 +
+ fluidityScore * 0.25 +
+ Math.min(100, avgSwingSpeed * 3) * 0.15 +
+ Math.min(100, shotCount * 10) * 0.10
+ );
+
+ // Detect issues
+ const detectedIssues = detectIssues(jointAnglesHistory, exerciseType, avgSwingSpeed, footworkScore);
+
+ const result: AnalysisResult = {
+ overallScore,
+ shotCount,
+ avgSwingSpeed: Math.round(avgSwingSpeed * 10) / 10,
+ maxSwingSpeed: Math.round(maxSwingSpeed * 10) / 10,
+ totalMovementDistance: Math.round(totalMovement),
+ strokeConsistency: Math.round(strokeConsistency),
+ footworkScore: Math.round(footworkScore),
+ fluidityScore: Math.round(fluidityScore),
+ poseMetrics: {
+ avgAngles: averageAngles(jointAnglesHistory),
+ frameCount: framesAnalyzed,
+ },
+ detectedIssues,
+ keyMoments,
+ movementTrajectory,
+ framesAnalyzed,
+ };
+
+ setAnalysisResult(result);
+
+ // Upload video and save analysis
+ const reader = new FileReader();
+ reader.onload = async () => {
+ const base64 = (reader.result as string).split(",")[1];
+ try {
+ const { videoId } = await uploadMutation.mutateAsync({
+ title: `${exerciseType}_${new Date().toISOString().slice(0, 10)}`,
+ format: videoFile.name.split(".").pop() || "mp4",
+ fileSize: videoFile.size,
+ exerciseType,
+ fileBase64: base64,
+ });
+
+ await saveMutation.mutateAsync({
+ videoId,
+ ...result,
+ });
+
+ toast.success("分析完成并已保存!NTRP评分已自动更新。");
+ } catch (err: any) {
+ toast.error("保存失败: " + err.message);
+ }
+ };
+ reader.readAsDataURL(videoFile);
+
+ // Get AI corrections
+ correctionMutation.mutate({
+ poseMetrics: result.poseMetrics,
+ exerciseType,
+ detectedIssues: result.detectedIssues,
+ }, {
+ onSuccess: (data) => setCorrections(data.corrections as string),
+ });
+
+ pose.close();
+ } catch (err: any) {
+ toast.error("分析失败: " + err.message);
+ console.error(err);
+ } finally {
+ setIsAnalyzing(false);
+ }
+ }, [videoFile, exerciseType, showSkeleton]);
+
+ return (
+
+
+
视频姿势分析
+
上传训练视频,AI自动识别姿势并给出矫正建议
+
+
+ {/* Upload section */}
+
+
+
+
+ 上传训练视频
+
+
+
+
+
+ 动作类型
+
+
+
+
+
+ 正手挥拍
+ 反手挥拍
+ 发球动作
+ 截击
+ 脚步移动
+ 影子挥拍
+ 墙壁练习
+
+
+
+
+ 选择视频
+
+
+
+
+ {videoUrl && (
+
+
+
+ 原始视频
+
+
+
+
+ 骨骼分析
+ setShowSkeleton(!showSkeleton)}
+ className="text-xs gap-1"
+ >
+
+ {showSkeleton ? "隐藏骨骼" : "显示骨骼"}
+
+
+
+
+
+
+
+
+ {isAnalyzing ? (
+ <> 分析中 {analysisProgress}%>
+ ) : (
+ <> 开始分析>
+ )}
+
+ {isAnalyzing && (
+
+ )}
+
+
+ )}
+
+
+
+ {/* Analysis results */}
+ {analysisResult && (
+ <>
+ {/* Score overview */}
+
+
+
+ 综合评分
+ {analysisResult.overallScore}
+ /100
+
+
+
+
+ 击球次数
+ {analysisResult.shotCount}
+ 次
+
+
+
+
+ 平均挥拍速度
+ {analysisResult.avgSwingSpeed}
+ px/帧
+
+
+
+
+ 移动距离
+ {analysisResult.totalMovementDistance}
+ px
+
+
+
+
+ {/* Dimension scores */}
+
+
+ 多维度评分
+
+
+
+ {[
+ { label: "击球一致性", value: analysisResult.strokeConsistency, color: "bg-blue-500" },
+ { label: "脚步移动", value: analysisResult.footworkScore, color: "bg-green-500" },
+ { label: "动作流畅性", value: analysisResult.fluidityScore, color: "bg-purple-500" },
+ { label: "最大挥拍速度", value: Math.min(100, analysisResult.maxSwingSpeed * 3), color: "bg-orange-500" },
+ ].map(item => (
+
+
+ {item.label}
+ {Math.round(item.value)}/100
+
+
+
+ ))}
+
+
+
+
+ {/* Detected issues */}
+ {analysisResult.detectedIssues.length > 0 && (
+
+
+
+
+ 检测到的问题
+
+
+
+
+ {analysisResult.detectedIssues.map((issue, i) => (
+
+ ))}
+
+
+
+ )}
+
+ {/* Key moments */}
+ {analysisResult.keyMoments.length > 0 && (
+
+
+
+
+ 关键时刻
+
+
+
+
+ {analysisResult.keyMoments.map((moment: any, i: number) => (
+
+ 帧 {moment.frame}
+ {moment.description}
+
+ ))}
+
+
+
+ )}
+
+ {/* AI Corrections */}
+
+
+
+
+ AI矫正建议
+
+
+
+ {correctionMutation.isPending ? (
+
+
+ AI正在生成矫正建议...
+
+ ) : corrections ? (
+
+ {corrections}
+
+ ) : (
+ 暂无矫正建议
+ )}
+
+
+ >
+ )}
+
+ );
+}
+
+// ===== Helper functions =====
+
+function calculateJointAngles(landmarks: any[]) {
+ const getAngle = (a: any, b: any, c: any) => {
+ const radians = Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(a.y - b.y, a.x - b.x);
+ let angle = Math.abs(radians * 180 / Math.PI);
+ if (angle > 180) angle = 360 - angle;
+ return angle;
+ };
+
+ return {
+ rightElbow: getAngle(landmarks[12], landmarks[14], landmarks[16]),
+ leftElbow: getAngle(landmarks[11], landmarks[13], landmarks[15]),
+ rightShoulder: getAngle(landmarks[14], landmarks[12], landmarks[24]),
+ leftShoulder: getAngle(landmarks[13], landmarks[11], landmarks[23]),
+ rightKnee: getAngle(landmarks[24], landmarks[26], landmarks[28]),
+ leftKnee: getAngle(landmarks[23], landmarks[25], landmarks[27]),
+ rightHip: getAngle(landmarks[12], landmarks[24], landmarks[26]),
+ leftHip: getAngle(landmarks[11], landmarks[23], landmarks[25]),
+ torsoLean: getAngle(landmarks[11], landmarks[23], { x: landmarks[23].x, y: 0 }),
+ };
+}
+
+function calculateConsistency(speeds: number[]): number {
+ if (speeds.length < 2) return 50;
+ const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
+ const variance = speeds.reduce((sum, s) => sum + Math.pow(s - mean, 2), 0) / speeds.length;
+ const cv = Math.sqrt(variance) / (mean || 1);
+ return Math.max(0, Math.min(100, 100 - cv * 100));
+}
+
+function calculateFootworkScore(trajectory: any[], width: number, height: number): number {
+ if (trajectory.length < 10) return 50;
+ // Score based on movement variety and smoothness
+ let directionChanges = 0;
+ let totalDist = 0;
+ for (let i = 2; i < trajectory.length; i++) {
+ const dx1 = trajectory[i - 1].x - trajectory[i - 2].x;
+ const dy1 = trajectory[i - 1].y - trajectory[i - 2].y;
+ const dx2 = trajectory[i].x - trajectory[i - 1].x;
+ const dy2 = trajectory[i].y - trajectory[i - 1].y;
+ if ((dx1 * dx2 + dy1 * dy2) < 0) directionChanges++;
+ totalDist += Math.sqrt(dx2 * dx2 + dy2 * dy2);
+ }
+ const changeRate = directionChanges / trajectory.length;
+ const movementRange = totalDist * 1000;
+ return Math.min(100, Math.max(20, changeRate * 200 + movementRange * 0.5));
+}
+
+function calculateFluidityScore(anglesHistory: any[]): number {
+ if (anglesHistory.length < 3) return 50;
+ let totalJerkiness = 0;
+ const keys = Object.keys(anglesHistory[0] || {});
+ for (let i = 2; i < anglesHistory.length; i++) {
+ for (const key of keys) {
+ const a0 = anglesHistory[i - 2][key] || 0;
+ const a1 = anglesHistory[i - 1][key] || 0;
+ const a2 = anglesHistory[i][key] || 0;
+ const jerk = Math.abs((a2 - a1) - (a1 - a0));
+ totalJerkiness += jerk;
+ }
+ }
+ const avgJerk = totalJerkiness / ((anglesHistory.length - 2) * keys.length);
+ return Math.max(0, Math.min(100, 100 - avgJerk * 2));
+}
+
+function detectIssues(anglesHistory: any[], exerciseType: string, avgSpeed: number, footworkScore: number): string[] {
+ const issues: string[] = [];
+ if (anglesHistory.length === 0) return issues;
+
+ const avgAngles = averageAngles(anglesHistory);
+
+ // Check elbow angle for strokes
+ if (exerciseType === "forehand" || exerciseType === "shadow") {
+ if (avgAngles.rightElbow < 90) issues.push("正手击球时肘部弯曲过大,建议保持手臂更加伸展");
+ if (avgAngles.rightElbow > 170) issues.push("正手击球时手臂过于僵直,建议略微弯曲肘部");
+ }
+ if (exerciseType === "backhand") {
+ if (avgAngles.leftElbow < 80) issues.push("反手击球时肘部弯曲过大");
+ }
+ if (exerciseType === "serve") {
+ if (avgAngles.rightShoulder < 140) issues.push("发球时肩部旋转不够充分,需要更大的肩部打开角度");
+ }
+
+ // Check knee bend
+ if (avgAngles.rightKnee > 170 && avgAngles.leftKnee > 170) {
+ issues.push("膝盖弯曲不足,建议保持适当的屈膝姿势以提高稳定性");
+ }
+
+ // Check torso
+ if (avgAngles.torsoLean < 70) {
+ issues.push("身体前倾过多,注意保持上身直立");
+ }
+
+ // Speed check
+ if (avgSpeed < 5) {
+ issues.push("挥拍速度偏慢,建议加快挥拍节奏");
+ }
+
+ // Footwork check
+ if (footworkScore < 40) {
+ issues.push("脚步移动不够活跃,建议增加脚步训练");
+ }
+
+ return issues;
+}
+
+function averageAngles(anglesHistory: any[]) {
+ if (anglesHistory.length === 0) return {};
+ const keys = Object.keys(anglesHistory[0] || {});
+ const avg: Record = {};
+ for (const key of keys) {
+ avg[key] = Math.round(
+ anglesHistory.reduce((sum, a) => sum + (a[key] || 0), 0) / anglesHistory.length
+ );
+ }
+ return avg;
+}
diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..75ed290
--- /dev/null
+++ b/client/src/pages/Dashboard.tsx
@@ -0,0 +1,279 @@
+import { useAuth } from "@/_core/hooks/useAuth";
+import { trpc } from "@/lib/trpc";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+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 {
+ Target, Video, Activity, TrendingUp, Award, Clock,
+ Zap, BarChart3, ChevronRight
+} from "lucide-react";
+import { useLocation } from "wouter";
+import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, AreaChart, Area } from "recharts";
+
+function NTRPBadge({ rating }: { rating: number }) {
+ let level = "初学者";
+ let color = "bg-gray-100 text-gray-700";
+ if (rating >= 4.0) { level = "高级竞技"; color = "bg-purple-100 text-purple-700"; }
+ else if (rating >= 3.0) { level = "中高级"; color = "bg-blue-100 text-blue-700"; }
+ else if (rating >= 2.5) { level = "中级"; color = "bg-green-100 text-green-700"; }
+ else if (rating >= 2.0) { level = "初中级"; color = "bg-yellow-100 text-yellow-700"; }
+ else if (rating >= 1.5) { level = "初级"; color = "bg-orange-100 text-orange-700"; }
+ return (
+
+ NTRP {rating.toFixed(1)} · {level}
+
+ );
+}
+
+export default function Dashboard() {
+ const { user } = useAuth();
+ const { data: stats, isLoading } = trpc.profile.stats.useQuery();
+ const [, setLocation] = useLocation();
+
+ if (isLoading) {
+ return (
+
+
+
+ {[1, 2, 3, 4].map(i => )}
+
+
+ );
+ }
+
+ const ratingData = stats?.ratingHistory?.map((r: any) => ({
+ date: new Date(r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
+ rating: r.rating,
+ ...((r.dimensionScores as any) || {}),
+ })) || [];
+
+ return (
+
+ {/* Welcome header */}
+
+
+
+ 欢迎回来,{user?.name || "球友"}
+
+
+
+
+ 已完成 {stats?.totalSessions || 0} 次训练
+
+
+
+
+ setLocation("/training")} className="gap-2">
+
+ 开始训练
+
+ setLocation("/analysis")} className="gap-2">
+
+ 视频分析
+
+
+
+
+ {/* Stats cards */}
+
+
+
+
+
+
NTRP评分
+
+ {(stats?.ntrpRating || 1.5).toFixed(1)}
+
+
+
+
+
+
+
+
+
+
+
+
训练次数
+
{stats?.totalSessions || 0}
+
+
+
+
+
+
+
+
+
+
+
训练时长
+
{stats?.totalMinutes || 0}分钟
+
+
+
+
+
+
+
+
+
+
+
+
+
总击球数
+
{stats?.totalShots || 0}
+
+
+
+
+
+
+
+
+
+ {/* Rating trend chart */}
+
+
+
+
+
+
+ NTRP评分趋势
+
+ setLocation("/rating")} className="text-xs gap-1">
+ 查看详情
+
+
+
+
+ {ratingData.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {/* Recent analyses */}
+
+
+
+
+
+ 最近分析
+
+ setLocation("/videos")} className="text-xs gap-1">
+ 查看全部
+
+
+
+
+ {(stats?.recentAnalyses?.length || 0) > 0 ? (
+
+ {stats!.recentAnalyses.slice(0, 4).map((a: any) => (
+
+
+
+ {Math.round(a.overallScore || 0)}
+
+
+
{a.exerciseType || "综合分析"}
+
+ {new Date(a.createdAt).toLocaleDateString("zh-CN")}
+ {a.shotCount ? ` · ${a.shotCount}次击球` : ""}
+
+
+
+
+
+
{Math.round(a.overallScore || 0)}分
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Quick actions */}
+
+
+ 快速开始
+
+
+
+
setLocation("/training")}
+ className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
+ >
+
+
+
+
+
+
setLocation("/analysis")}
+ className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
+ >
+
+
+
+
+
上传视频分析
+
MediaPipe AI姿势识别
+
+
+
setLocation("/rating")}
+ className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
+ >
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx
index 8b4b79b..77e0fd5 100644
--- a/client/src/pages/Home.tsx
+++ b/client/src/pages/Home.tsx
@@ -1,31 +1,150 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
-import { Loader2 } from "lucide-react";
-import { getLoginUrl } from "@/const";
-import { Streamdown } from 'streamdown';
+import { useLocation, Redirect } from "wouter";
+import {
+ Target, Video, Award, TrendingUp, Zap, Activity,
+ ChevronRight, Footprints, BarChart3
+} from "lucide-react";
-/**
- * All content in this page are only for example, replace with your own feature implementation
- * When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls
- */
export default function Home() {
- // The userAuth hooks provides authentication state
- // To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()
- let { user, loading, error, isAuthenticated, logout } = useAuth();
+ const { user, loading, isAuthenticated } = useAuth();
+ const [, setLocation] = useLocation();
- // If theme is switchable in App.tsx, we can implement theme toggling like this:
- // const { theme, toggleTheme } = useTheme();
+ if (loading) return null;
+ if (isAuthenticated) return ;
return (
-
-
- {/* Example: lucide-react for icons */}
-
- Example Page
- {/* Example: Streamdown for markdown rendering */}
- Any **markdown** content
- Example Button
-
+
+ {/* Hero */}
+
+
+
+
+
+
+ AI驱动的网球训练助手
+
+
+ 在家也能提升
+ 网球技术水平
+
+
+ 只需一支球拍,通过AI姿势识别和智能训练计划,在家高效训练。
+ 实时分析挥拍动作,自动评分,持续进步。
+
+
+ setLocation("/login")} size="lg" className="gap-2 h-12 px-6">
+ 免费开始训练
+
+
+
+
+
+
+ {/* Features */}
+
+ 核心功能
+
+ {[
+ {
+ icon: Video,
+ title: "AI姿势识别",
+ desc: "基于MediaPipe的浏览器端实时姿势分析,识别33个身体关键点,精准评估挥拍动作",
+ color: "bg-blue-50 text-blue-600",
+ },
+ {
+ icon: Target,
+ title: "智能训练计划",
+ desc: "根据您的水平和分析结果,AI自动生成和调整个性化训练方案,只需球拍即可在家训练",
+ color: "bg-green-50 text-green-600",
+ },
+ {
+ icon: Award,
+ title: "NTRP自动评分",
+ desc: "基于美国网球协会标准,从5个维度综合评估您的技术水平,自动更新评分",
+ color: "bg-purple-50 text-purple-600",
+ },
+ {
+ icon: Zap,
+ title: "击球统计分析",
+ desc: "自动检测击球次数、挥拍速度、击球一致性,量化每次训练效果",
+ color: "bg-orange-50 text-orange-600",
+ },
+ {
+ icon: Footprints,
+ title: "运动轨迹追踪",
+ desc: "记录身体重心移动轨迹,分析脚步移动模式,提升步法灵活性",
+ color: "bg-teal-50 text-teal-600",
+ },
+ {
+ icon: TrendingUp,
+ title: "进度可视化",
+ desc: "直观展示训练历史、能力提升趋势和评分变化,激励持续进步",
+ color: "bg-indigo-50 text-indigo-600",
+ },
+ ].map((feature) => (
+
+
+
+
+
{feature.title}
+
{feature.desc}
+
+ ))}
+
+
+
+ {/* How it works */}
+
+ 使用流程
+
+ {[
+ { step: "1", title: "输入用户名", desc: "无需注册,输入用户名即可开始" },
+ { step: "2", title: "生成训练计划", desc: "选择水平,AI生成个性化方案" },
+ { step: "3", title: "上传训练视频", desc: "录制挥拍视频并上传分析" },
+ { step: "4", title: "获取评分建议", desc: "查看分析结果和矫正建议" },
+ ].map((item) => (
+
+
+ {item.step}
+
+
{item.title}
+
{item.desc}
+
+ ))}
+
+
+
+ {/* CTA */}
+
+
+
准备好提升网球技术了吗?
+
完全免费,无需注册,输入用户名即可开始
+
setLocation("/login")} size="lg" className="gap-2">
+ 立即开始
+
+
+
+
+
+ {/* Footer */}
+
);
}
diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx
new file mode 100644
index 0000000..c2e0507
--- /dev/null
+++ b/client/src/pages/Login.tsx
@@ -0,0 +1,102 @@
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { trpc } from "@/lib/trpc";
+import { useLocation } from "wouter";
+import { toast } from "sonner";
+import { Target, Loader2 } from "lucide-react";
+
+export default function Login() {
+ const [username, setUsername] = useState("");
+ const [, setLocation] = useLocation();
+ const loginMutation = trpc.auth.loginWithUsername.useMutation({
+ onSuccess: (data) => {
+ toast.success(data.isNew ? `欢迎加入,${data.user.name}!` : `欢迎回来,${data.user.name}!`);
+ setLocation("/dashboard");
+ },
+ onError: (err) => {
+ toast.error("登录失败: " + err.message);
+ },
+ });
+
+ const handleLogin = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!username.trim()) {
+ toast.error("请输入用户名");
+ return;
+ }
+ loginMutation.mutate({ username: username.trim() });
+ };
+
+ return (
+
+
+
+
+
+
+
Tennis Training Hub
+
AI驱动的在家网球训练助手
+
+
+
+
+ 开始训练
+ 输入用户名即可开始使用
+
+
+
+
+
+
+
+
+
+ 无需注册,输入用户名即可使用全部功能
+
+
+
+ );
+}
diff --git a/client/src/pages/Progress.tsx b/client/src/pages/Progress.tsx
new file mode 100644
index 0000000..b0c8d5a
--- /dev/null
+++ b/client/src/pages/Progress.tsx
@@ -0,0 +1,215 @@
+import { useAuth } from "@/_core/hooks/useAuth";
+import { trpc } from "@/lib/trpc";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+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 } from "lucide-react";
+import {
+ ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
+ LineChart, Line, Legend
+} from "recharts";
+import { useLocation } from "wouter";
+
+export default function Progress() {
+ const { user } = useAuth();
+ const { data: records, isLoading } = trpc.record.list.useQuery({ limit: 100 });
+ const { data: analyses } = trpc.analysis.list.useQuery();
+ const { data: stats } = trpc.profile.stats.useQuery();
+ const [, setLocation] = useLocation();
+
+ if (isLoading) {
+ return (
+
+ {[1, 2, 3].map(i => )}
+
+ );
+ }
+
+ // 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 existing = dateMap.get(date) || { date, sessions: 0, minutes: 0, avgScore: 0, scores: [] };
+ existing.sessions++;
+ existing.minutes += r.durationMinutes || 0;
+ if (r.poseScore) existing.scores.push(r.poseScore);
+ dateMap.set(date, existing);
+ });
+
+ const chartData = Array.from(dateMap.values()).map(d => ({
+ ...d,
+ avgScore: d.scores.length > 0 ? Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length) : 0,
+ }));
+
+ // Analysis score trend
+ const scoreTrend = (analyses || []).map((a: any) => ({
+ date: new Date(a.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
+ overall: Math.round(a.overallScore || 0),
+ consistency: Math.round(a.strokeConsistency || 0),
+ footwork: Math.round(a.footworkScore || 0),
+ fluidity: Math.round(a.fluidityScore || 0),
+ }));
+
+ const completedRecords = (records || []).filter((r: any) => r.completed === 1);
+ const totalMinutes = (records || []).reduce((sum: number, r: any) => sum + (r.durationMinutes || 0), 0);
+
+ return (
+
+
+
训练进度
+
追踪您的训练历史和能力提升趋势
+
+
+ {/* Summary stats */}
+
+
+
+
+ {stats?.totalSessions || 0}
+
+
+
+
+
+ 总训练时长
+
+ {totalMinutes}分钟
+
+
+
+
+
+ 已完成
+
+ {completedRecords.length}
+
+
+
+
+
+ 视频分析
+
+ {analyses?.length || 0}次
+
+
+
+
+
+ {/* Training frequency chart */}
+
+
+
+
+ 训练频率
+
+
+
+ {chartData.length > 0 ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {/* Score improvement trend */}
+
+
+
+
+ 能力提升趋势
+
+
+
+ {scoreTrend.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Recent records */}
+
+
+ 最近训练记录
+
+
+ {(records?.length || 0) > 0 ? (
+
+ {(records || []).slice(0, 20).map((record: any) => (
+
+
+
+ {record.completed ?
:
}
+
+
+
{record.exerciseName}
+
+ {new Date(record.trainingDate || record.createdAt).toLocaleDateString("zh-CN")}
+ {record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
+
+
+
+
+ {record.poseScore && (
+ {Math.round(record.poseScore)}分
+ )}
+ {record.completed ? (
+ 已完成
+ ) : (
+ 进行中
+ )}
+
+
+ ))}
+
+ ) : (
+
+
+
还没有训练记录
+
setLocation("/training")} className="mt-2">
+ 开始第一次训练
+
+
+ )}
+
+
+
+ );
+}
diff --git a/client/src/pages/Rating.tsx b/client/src/pages/Rating.tsx
new file mode 100644
index 0000000..b9722f9
--- /dev/null
+++ b/client/src/pages/Rating.tsx
@@ -0,0 +1,230 @@
+import { useAuth } from "@/_core/hooks/useAuth";
+import { trpc } from "@/lib/trpc";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Award, TrendingUp, Target, Zap, Footprints, Activity, Wind } from "lucide-react";
+import {
+ ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis,
+ PolarRadiusAxis, Radar, AreaChart, Area, XAxis, YAxis,
+ CartesianGrid, Tooltip, Legend
+} from "recharts";
+
+const NTRP_LEVELS = [
+ { min: 1.0, max: 1.5, label: "初学者", desc: "刚开始学习网球,正在学习基本击球动作", color: "bg-gray-100 text-gray-700" },
+ { min: 1.5, max: 2.0, label: "初级", desc: "能够进行简单的来回击球,但缺乏一致性", color: "bg-orange-100 text-orange-700" },
+ { min: 2.0, max: 2.5, label: "初中级", desc: "击球更加稳定,开始理解基本策略", color: "bg-yellow-100 text-yellow-700" },
+ { min: 2.5, max: 3.0, label: "中级", desc: "能够稳定地进行中速击球,具备基本的网前技术", color: "bg-green-100 text-green-700" },
+ { min: 3.0, max: 3.5, label: "中高级", desc: "击球力量和控制力增强,开始使用旋转", color: "bg-blue-100 text-blue-700" },
+ { min: 3.5, max: 4.0, label: "高级", desc: "具备全面的技术,能够在比赛中运用战术", color: "bg-indigo-100 text-indigo-700" },
+ { min: 4.0, max: 4.5, label: "高级竞技", desc: "技术精湛,具备强大的进攻和防守能力", color: "bg-purple-100 text-purple-700" },
+ { min: 4.5, max: 5.0, label: "专业水平", desc: "接近职业水平,全面的技术和战术能力", color: "bg-red-100 text-red-700" },
+];
+
+function getNTRPLevel(rating: number) {
+ return NTRP_LEVELS.find(l => rating >= l.min && rating < l.max) || NTRP_LEVELS[0];
+}
+
+export default function Rating() {
+ const { user } = useAuth();
+ const { data: ratingData } = trpc.rating.current.useQuery();
+ const { data: history, isLoading } = trpc.rating.history.useQuery();
+ const { data: stats } = trpc.profile.stats.useQuery();
+
+ const currentRating = ratingData?.rating || 1.5;
+ const level = getNTRPLevel(currentRating);
+
+ // Get latest dimension scores
+ const latestWithDimensions = history?.find((h: any) => h.dimensionScores);
+ const dimensions = (latestWithDimensions as any)?.dimensionScores || {};
+
+ const radarData = [
+ { dimension: "姿势准确", value: dimensions.poseAccuracy || 0, fullMark: 100 },
+ { dimension: "击球一致", value: dimensions.strokeConsistency || 0, fullMark: 100 },
+ { dimension: "脚步移动", value: dimensions.footwork || 0, fullMark: 100 },
+ { dimension: "动作流畅", value: dimensions.fluidity || 0, fullMark: 100 },
+ { dimension: "力量", value: dimensions.power || 0, fullMark: 100 },
+ ];
+
+ const trendData = (history || []).map((h: any) => ({
+ date: new Date(h.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
+ rating: h.rating,
+ }));
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
NTRP评分系统
+
基于所有历史训练记录自动计算的综合评分
+
+
+ {/* Current rating card */}
+
+
+
+
+
+ {currentRating.toFixed(1)}
+
+
+
{level.label}
+
{level.desc}
+
+
+
NTRP {currentRating.toFixed(1)}
+
+
+
+
+
+
+
+
+ {/* Radar chart */}
+
+
+
+
+ 能力雷达图
+
+ 五维度综合能力评估
+
+
+ {Object.keys(dimensions).length > 0 ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {/* Rating trend */}
+
+
+
+
+ 评分变化趋势
+
+ NTRP评分随时间的变化
+
+
+ {trendData.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Dimension details */}
+
+
+ 评分维度说明
+ NTRP评分由以下五个维度加权计算
+
+
+
+ {[
+ { icon: Target, label: "姿势准确性", weight: "30%", desc: "关节角度与标准动作的匹配度", value: dimensions.poseAccuracy },
+ { icon: Zap, label: "击球一致性", weight: "25%", desc: "多次击球动作的稳定性", value: dimensions.strokeConsistency },
+ { icon: Footprints, label: "脚步移动", weight: "20%", desc: "步法灵活性和重心转移", value: dimensions.footwork },
+ { icon: Wind, label: "动作流畅性", weight: "15%", desc: "动作连贯性和平滑度", value: dimensions.fluidity },
+ { icon: Activity, label: "力量", weight: "10%", desc: "挥拍速度和爆发力", value: dimensions.power },
+ ].map(item => (
+
+
+
+ {item.label}
+
+
{item.value ? Math.round(item.value) : "--"}
+
权重 {item.weight}
+
{item.desc}
+
+ ))}
+
+
+
+
+ {/* NTRP level reference */}
+
+
+ NTRP等级参考
+ 美国网球协会(USTA)标准评级体系
+
+
+
+ {NTRP_LEVELS.map(l => (
+
= l.min && currentRating < l.max
+ ? "bg-primary/5 border border-primary/20"
+ : "hover:bg-muted/50"
+ }`}
+ >
+
+ {l.min.toFixed(1)}-{l.max.toFixed(1)}
+
+
+ {currentRating >= l.min && currentRating < l.max && (
+
当前等级
+ )}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/client/src/pages/Training.tsx b/client/src/pages/Training.tsx
new file mode 100644
index 0000000..1c3249a
--- /dev/null
+++ b/client/src/pages/Training.tsx
@@ -0,0 +1,287 @@
+import { useState, useMemo } from "react";
+import { useAuth } from "@/_core/hooks/useAuth";
+import { trpc } from "@/lib/trpc";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { toast } from "sonner";
+import {
+ Target, Loader2, CheckCircle2, Circle, Clock, Dumbbell,
+ RefreshCw, Footprints, Hand, ArrowRight, Sparkles
+} from "lucide-react";
+
+const categoryIcons: Record = {
+ "影子挥拍": ,
+ "脚步移动": ,
+ "体能训练": ,
+ "墙壁练习": ,
+};
+
+const categoryColors: Record = {
+ "影子挥拍": "bg-blue-50 text-blue-700 border-blue-200",
+ "脚步移动": "bg-green-50 text-green-700 border-green-200",
+ "体能训练": "bg-orange-50 text-orange-700 border-orange-200",
+ "墙壁练习": "bg-purple-50 text-purple-700 border-purple-200",
+};
+
+type Exercise = {
+ day: number;
+ name: string;
+ category: string;
+ duration: number;
+ description: string;
+ tips: string;
+ sets: number;
+ reps: number;
+};
+
+export default function Training() {
+ const { user } = useAuth();
+ const [skillLevel, setSkillLevel] = useState<"beginner" | "intermediate" | "advanced">("beginner");
+ const [durationDays, setDurationDays] = useState(7);
+ const [selectedDay, setSelectedDay] = useState(1);
+
+ const utils = trpc.useUtils();
+ const { data: activePlan, isLoading: planLoading } = trpc.plan.active.useQuery();
+
+ const generateMutation = trpc.plan.generate.useMutation({
+ onSuccess: () => {
+ toast.success("训练计划已生成!");
+ utils.plan.active.invalidate();
+ utils.plan.list.invalidate();
+ },
+ onError: (err) => toast.error("生成失败: " + err.message),
+ });
+
+ const adjustMutation = trpc.plan.adjust.useMutation({
+ onSuccess: (data) => {
+ toast.success("训练计划已调整!");
+ utils.plan.active.invalidate();
+ if (data.adjustmentNotes) toast.info("调整说明: " + data.adjustmentNotes);
+ },
+ onError: (err) => toast.error("调整失败: " + err.message),
+ });
+
+ const recordMutation = trpc.record.create.useMutation({
+ onSuccess: () => toast.success("训练记录已创建"),
+ });
+
+ const completeMutation = trpc.record.complete.useMutation({
+ onSuccess: () => {
+ toast.success("训练已完成!");
+ utils.profile.stats.invalidate();
+ },
+ });
+
+ const exercises = useMemo(() => {
+ if (!activePlan?.exercises) return [];
+ return (activePlan.exercises as Exercise[]).filter(e => e.day === selectedDay);
+ }, [activePlan, selectedDay]);
+
+ const totalDays = activePlan?.durationDays || 7;
+
+ if (planLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
训练计划
+
AI为您定制的在家网球训练方案
+
+
+
+ {!activePlan ? (
+ /* Generate new plan */
+
+
+
+
+ 生成训练计划
+
+
+ 根据您的水平和目标,AI将生成个性化的在家训练方案(只需球拍)
+
+
+
+
+
+ 技能水平
+ setSkillLevel(v)}>
+
+
+
+
+ 初级 - 刚开始学习网球
+ 中级 - 有一定基础
+ 高级 - 有丰富经验
+
+
+
+
+ 训练周期
+ setDurationDays(Number(v))}>
+
+
+
+
+ 3天计划
+ 7天计划
+ 14天计划
+ 30天计划
+
+
+
+
+ generateMutation.mutate({ skillLevel, durationDays })}
+ disabled={generateMutation.isPending}
+ className="w-full sm:w-auto gap-2"
+ >
+ {generateMutation.isPending ? (
+ <> AI生成中...>
+ ) : (
+ <> 生成训练计划>
+ )}
+
+
+
+ ) : (
+ /* Active plan display */
+ <>
+
+
+
+
+ {activePlan.title}
+
+
+ {activePlan.skillLevel === "beginner" ? "初级" : activePlan.skillLevel === "intermediate" ? "中级" : "高级"}
+
+ {activePlan.durationDays}天计划
+ {activePlan.version > 1 && (
+ v{activePlan.version} 已调整
+ )}
+
+
+
adjustMutation.mutate({ planId: activePlan.id })}
+ disabled={adjustMutation.isPending}
+ className="gap-1"
+ >
+ {adjustMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 智能调整
+
+
+ {activePlan.adjustmentNotes && (
+
+ 调整说明: {activePlan.adjustmentNotes}
+
+ )}
+
+
+ {/* Day selector */}
+
+ {Array.from({ length: totalDays }, (_, i) => i + 1).map(day => (
+ setSelectedDay(day)}
+ className={`shrink-0 w-10 h-10 rounded-xl text-sm font-medium transition-all ${
+ selectedDay === day
+ ? "bg-primary text-primary-foreground shadow-md"
+ : "bg-muted hover:bg-accent"
+ }`}
+ >
+ {day}
+
+ ))}
+
+
+ 第 {selectedDay} 天训练
+
+ {exercises.length > 0 ? (
+
+ {exercises.map((ex, idx) => (
+
+
+
+
+ {categoryIcons[ex.category] || }
+
+
+
{ex.name}
+
{ex.description}
+
+
+ {ex.duration}分钟
+
+ {ex.sets}组 × {ex.reps}次
+
+ {ex.tips && (
+
+ 💡 {ex.tips}
+
+ )}
+
+
+
{
+ recordMutation.mutate({
+ planId: activePlan.id,
+ exerciseName: ex.name,
+ durationMinutes: ex.duration,
+ });
+ }}
+ >
+
+
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+
+ {
+ generateMutation.mutate({ skillLevel, durationDays });
+ }}
+ disabled={generateMutation.isPending}
+ className="gap-2"
+ >
+
+ 重新生成计划
+
+
+ >
+ )}
+
+ );
+}
diff --git a/client/src/pages/Videos.tsx b/client/src/pages/Videos.tsx
new file mode 100644
index 0000000..3c8111f
--- /dev/null
+++ b/client/src/pages/Videos.tsx
@@ -0,0 +1,150 @@
+import { useAuth } from "@/_core/hooks/useAuth";
+import { trpc } from "@/lib/trpc";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Video, Play, BarChart3, Clock, Zap, ChevronRight, FileVideo } from "lucide-react";
+import { useLocation } from "wouter";
+
+const statusMap: Record = {
+ pending: { label: "待分析", color: "bg-yellow-100 text-yellow-700" },
+ analyzing: { label: "分析中", color: "bg-blue-100 text-blue-700" },
+ completed: { label: "已完成", color: "bg-green-100 text-green-700" },
+ failed: { label: "失败", color: "bg-red-100 text-red-700" },
+};
+
+const exerciseTypeMap: Record = {
+ forehand: "正手挥拍",
+ backhand: "反手挥拍",
+ serve: "发球",
+ volley: "截击",
+ footwork: "脚步移动",
+ shadow: "影子挥拍",
+ wall: "墙壁练习",
+};
+
+export default function Videos() {
+ const { user } = useAuth();
+ const { data: videos, isLoading } = trpc.video.list.useQuery();
+ const { data: analyses } = trpc.analysis.list.useQuery();
+ const [, setLocation] = useLocation();
+
+ const getAnalysis = (videoId: number) => {
+ return analyses?.find((a: any) => a.videoId === videoId);
+ };
+
+ if (isLoading) {
+ return (
+
+
+ {[1, 2, 3].map(i => )}
+
+ );
+ }
+
+ return (
+
+
+
+
训练视频库
+
+ 管理您的所有训练视频及分析结果 · 共 {videos?.length || 0} 个视频
+
+
+
setLocation("/analysis")} className="gap-2">
+
+ 上传新视频
+
+
+
+ {(!videos || videos.length === 0) ? (
+
+
+
+ 还没有训练视频
+ 上传您的训练视频,AI将自动分析姿势并给出建议
+ setLocation("/analysis")} className="gap-2">
+
+ 上传第一个视频
+
+
+
+ ) : (
+
+ {videos.map((video: any) => {
+ const analysis = getAnalysis(video.id);
+ const status = statusMap[video.analysisStatus] || statusMap.pending;
+
+ return (
+
+
+
+ {/* Thumbnail / icon */}
+
+ {video.url ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Info */}
+
+
+
+
{video.title}
+
+ {status.label}
+ {video.exerciseType && (
+
+ {exerciseTypeMap[video.exerciseType] || video.exerciseType}
+
+ )}
+
+
+ {new Date(video.createdAt).toLocaleDateString("zh-CN")}
+
+
+ {(video.fileSize / 1024 / 1024).toFixed(1)}MB
+
+
+
+
+
+ {/* Analysis summary */}
+ {analysis && (
+
+
+
+ {Math.round(analysis.overallScore || 0)}分
+
+ {(analysis.shotCount ?? 0) > 0 && (
+
+
+ {analysis.shotCount}次击球
+
+ )}
+ {(analysis.avgSwingSpeed ?? 0) > 0 && (
+
+ 速度 {(analysis.avgSwingSpeed ?? 0).toFixed(1)}
+
+ )}
+ {(analysis.strokeConsistency ?? 0) > 0 && (
+
+ 一致性 {Math.round(analysis.strokeConsistency ?? 0)}%
+
+ )}
+
+ )}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/drizzle/0000_absurd_ink.sql b/drizzle/0000_absurd_ink.sql
new file mode 100644
index 0000000..d0cd6eb
--- /dev/null
+++ b/drizzle/0000_absurd_ink.sql
@@ -0,0 +1,13 @@
+CREATE TABLE `users` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `openId` varchar(64) NOT NULL,
+ `name` text,
+ `email` varchar(320),
+ `loginMethod` varchar(64),
+ `role` enum('user','admin') NOT NULL DEFAULT 'user',
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ `lastSignedIn` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `users_id` PRIMARY KEY(`id`),
+ CONSTRAINT `users_openId_unique` UNIQUE(`openId`)
+);
diff --git a/drizzle/0001_public_prowler.sql b/drizzle/0001_public_prowler.sql
new file mode 100644
index 0000000..a62ae7e
--- /dev/null
+++ b/drizzle/0001_public_prowler.sql
@@ -0,0 +1,70 @@
+CREATE TABLE `pose_analyses` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `videoId` int NOT NULL,
+ `userId` int NOT NULL,
+ `overallScore` float,
+ `poseMetrics` json,
+ `detectedIssues` json,
+ `corrections` json,
+ `exerciseType` varchar(64),
+ `framesAnalyzed` int,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `pose_analyses_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `training_plans` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `title` varchar(256) NOT NULL,
+ `skillLevel` enum('beginner','intermediate','advanced') NOT NULL,
+ `durationDays` int NOT NULL DEFAULT 7,
+ `exercises` json NOT NULL,
+ `isActive` int NOT NULL DEFAULT 1,
+ `adjustmentNotes` text,
+ `version` int NOT NULL DEFAULT 1,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `training_plans_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `training_records` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `planId` int,
+ `exerciseName` varchar(128) NOT NULL,
+ `durationMinutes` int,
+ `completed` int NOT NULL DEFAULT 0,
+ `notes` text,
+ `poseScore` float,
+ `trainingDate` timestamp NOT NULL DEFAULT (now()),
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `training_records_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `training_videos` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `title` varchar(256) NOT NULL,
+ `fileKey` varchar(512) NOT NULL,
+ `url` text NOT NULL,
+ `format` varchar(16) NOT NULL,
+ `fileSize` int,
+ `duration` float,
+ `exerciseType` varchar(64),
+ `analysisStatus` enum('pending','analyzing','completed','failed') DEFAULT 'pending',
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `training_videos_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `username_accounts` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `username` varchar(64) NOT NULL,
+ `userId` int NOT NULL,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `username_accounts_id` PRIMARY KEY(`id`),
+ CONSTRAINT `username_accounts_username_unique` UNIQUE(`username`)
+);
+--> statement-breakpoint
+ALTER TABLE `users` ADD `skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner';--> statement-breakpoint
+ALTER TABLE `users` ADD `trainingGoals` text;
\ No newline at end of file
diff --git a/drizzle/0002_overrated_shriek.sql b/drizzle/0002_overrated_shriek.sql
new file mode 100644
index 0000000..507de3a
--- /dev/null
+++ b/drizzle/0002_overrated_shriek.sql
@@ -0,0 +1,23 @@
+CREATE TABLE `rating_history` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `rating` float NOT NULL,
+ `reason` varchar(256),
+ `dimensionScores` json,
+ `analysisId` int,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `rating_history_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+ALTER TABLE `pose_analyses` ADD `shotCount` int DEFAULT 0;--> statement-breakpoint
+ALTER TABLE `pose_analyses` ADD `avgSwingSpeed` float;--> statement-breakpoint
+ALTER TABLE `pose_analyses` ADD `maxSwingSpeed` float;--> statement-breakpoint
+ALTER TABLE `pose_analyses` ADD `totalMovementDistance` float;--> statement-breakpoint
+ALTER TABLE `pose_analyses` ADD `strokeConsistency` float;--> statement-breakpoint
+ALTER TABLE `pose_analyses` ADD `footworkScore` float;--> statement-breakpoint
+ALTER TABLE `pose_analyses` ADD `fluidityScore` float;--> statement-breakpoint
+ALTER TABLE `pose_analyses` ADD `keyMoments` json;--> statement-breakpoint
+ALTER TABLE `pose_analyses` ADD `movementTrajectory` json;--> statement-breakpoint
+ALTER TABLE `users` ADD `ntrpRating` float DEFAULT 1.5;--> statement-breakpoint
+ALTER TABLE `users` ADD `totalSessions` int DEFAULT 0;--> statement-breakpoint
+ALTER TABLE `users` ADD `totalMinutes` int DEFAULT 0;
\ No newline at end of file
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
new file mode 100644
index 0000000..8185bc3
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,110 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "2acf7bc5-0126-41ee-83bf-6a2725124288",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "tables": {
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "openId": {
+ "name": "openId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(320)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loginMethod": {
+ "name": "loginMethod",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "enum('user','admin')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "lastSignedIn": {
+ "name": "lastSignedIn",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "users_id": {
+ "name": "users_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "users_openId_unique": {
+ "name": "users_openId_unique",
+ "columns": [
+ "openId"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..17231c0
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,561 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "1bbd761e-f39b-4623-87fc-18e38f82bc98",
+ "prevId": "2acf7bc5-0126-41ee-83bf-6a2725124288",
+ "tables": {
+ "pose_analyses": {
+ "name": "pose_analyses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "overallScore": {
+ "name": "overallScore",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poseMetrics": {
+ "name": "poseMetrics",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "detectedIssues": {
+ "name": "detectedIssues",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "corrections": {
+ "name": "corrections",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "exerciseType": {
+ "name": "exerciseType",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "framesAnalyzed": {
+ "name": "framesAnalyzed",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "pose_analyses_id": {
+ "name": "pose_analyses_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "training_plans": {
+ "name": "training_plans",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "skillLevel": {
+ "name": "skillLevel",
+ "type": "enum('beginner','intermediate','advanced')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "durationDays": {
+ "name": "durationDays",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 7
+ },
+ "exercises": {
+ "name": "exercises",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "isActive": {
+ "name": "isActive",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "adjustmentNotes": {
+ "name": "adjustmentNotes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "training_plans_id": {
+ "name": "training_plans_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "training_records": {
+ "name": "training_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "planId": {
+ "name": "planId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "exerciseName": {
+ "name": "exerciseName",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "durationMinutes": {
+ "name": "durationMinutes",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed": {
+ "name": "completed",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poseScore": {
+ "name": "poseScore",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trainingDate": {
+ "name": "trainingDate",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "training_records_id": {
+ "name": "training_records_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "training_videos": {
+ "name": "training_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileKey": {
+ "name": "fileKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileSize": {
+ "name": "fileSize",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "exerciseType": {
+ "name": "exerciseType",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "analysisStatus": {
+ "name": "analysisStatus",
+ "type": "enum('pending','analyzing','completed','failed')",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "training_videos_id": {
+ "name": "training_videos_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "username_accounts": {
+ "name": "username_accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "username_accounts_id": {
+ "name": "username_accounts_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "username_accounts_username_unique": {
+ "name": "username_accounts_username_unique",
+ "columns": [
+ "username"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "openId": {
+ "name": "openId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(320)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loginMethod": {
+ "name": "loginMethod",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "enum('user','admin')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "skillLevel": {
+ "name": "skillLevel",
+ "type": "enum('beginner','intermediate','advanced')",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'beginner'"
+ },
+ "trainingGoals": {
+ "name": "trainingGoals",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "lastSignedIn": {
+ "name": "lastSignedIn",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "users_id": {
+ "name": "users_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "users_openId_unique": {
+ "name": "users_openId_unique",
+ "columns": [
+ "openId"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..f14efb5
--- /dev/null
+++ b/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,716 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "a9a3ce4f-a15b-4af1-b99f-d12a1644a83b",
+ "prevId": "1bbd761e-f39b-4623-87fc-18e38f82bc98",
+ "tables": {
+ "pose_analyses": {
+ "name": "pose_analyses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "videoId": {
+ "name": "videoId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "overallScore": {
+ "name": "overallScore",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poseMetrics": {
+ "name": "poseMetrics",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "detectedIssues": {
+ "name": "detectedIssues",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "corrections": {
+ "name": "corrections",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "exerciseType": {
+ "name": "exerciseType",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "framesAnalyzed": {
+ "name": "framesAnalyzed",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shotCount": {
+ "name": "shotCount",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 0
+ },
+ "avgSwingSpeed": {
+ "name": "avgSwingSpeed",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "maxSwingSpeed": {
+ "name": "maxSwingSpeed",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totalMovementDistance": {
+ "name": "totalMovementDistance",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "strokeConsistency": {
+ "name": "strokeConsistency",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "footworkScore": {
+ "name": "footworkScore",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fluidityScore": {
+ "name": "fluidityScore",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "keyMoments": {
+ "name": "keyMoments",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "movementTrajectory": {
+ "name": "movementTrajectory",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "pose_analyses_id": {
+ "name": "pose_analyses_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "rating_history": {
+ "name": "rating_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dimensionScores": {
+ "name": "dimensionScores",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "analysisId": {
+ "name": "analysisId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "rating_history_id": {
+ "name": "rating_history_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "training_plans": {
+ "name": "training_plans",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "skillLevel": {
+ "name": "skillLevel",
+ "type": "enum('beginner','intermediate','advanced')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "durationDays": {
+ "name": "durationDays",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 7
+ },
+ "exercises": {
+ "name": "exercises",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "isActive": {
+ "name": "isActive",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "adjustmentNotes": {
+ "name": "adjustmentNotes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "training_plans_id": {
+ "name": "training_plans_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "training_records": {
+ "name": "training_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "planId": {
+ "name": "planId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "exerciseName": {
+ "name": "exerciseName",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "durationMinutes": {
+ "name": "durationMinutes",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed": {
+ "name": "completed",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "poseScore": {
+ "name": "poseScore",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "trainingDate": {
+ "name": "trainingDate",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "training_records_id": {
+ "name": "training_records_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "training_videos": {
+ "name": "training_videos",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileKey": {
+ "name": "fileKey",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fileSize": {
+ "name": "fileSize",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "exerciseType": {
+ "name": "exerciseType",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "analysisStatus": {
+ "name": "analysisStatus",
+ "type": "enum('pending','analyzing','completed','failed')",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "training_videos_id": {
+ "name": "training_videos_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "username_accounts": {
+ "name": "username_accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "username_accounts_id": {
+ "name": "username_accounts_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "username_accounts_username_unique": {
+ "name": "username_accounts_username_unique",
+ "columns": [
+ "username"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "openId": {
+ "name": "openId",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(320)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loginMethod": {
+ "name": "loginMethod",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "enum('user','admin')",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "skillLevel": {
+ "name": "skillLevel",
+ "type": "enum('beginner','intermediate','advanced')",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'beginner'"
+ },
+ "trainingGoals": {
+ "name": "trainingGoals",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ntrpRating": {
+ "name": "ntrpRating",
+ "type": "float",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1.5
+ },
+ "totalSessions": {
+ "name": "totalSessions",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 0
+ },
+ "totalMinutes": {
+ "name": "totalMinutes",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 0
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "onUpdate": true,
+ "default": "(now())"
+ },
+ "lastSignedIn": {
+ "name": "lastSignedIn",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "users_id": {
+ "name": "users_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "users_openId_unique": {
+ "name": "users_openId_unique",
+ "columns": [
+ "openId"
+ ]
+ }
+ },
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 22fb7e8..9d75b46 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -1,5 +1,27 @@
{
"version": "7",
"dialect": "mysql",
- "entries": []
-}
+ "entries": [
+ {
+ "idx": 0,
+ "version": "5",
+ "when": 1773487141067,
+ "tag": "0000_absurd_ink",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "5",
+ "when": 1773487352919,
+ "tag": "0001_public_prowler",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "5",
+ "when": 1773487643444,
+ "tag": "0002_overrated_shriek",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 96f47f2..989af4c 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -1,22 +1,25 @@
-import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
+import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float } from "drizzle-orm/mysql-core";
/**
- * Core user table backing auth flow.
- * Extend this file with additional tables as your product grows.
- * Columns use camelCase to match both database fields and generated types.
+ * Core user table - supports both OAuth and simple username login
*/
export const users = mysqlTable("users", {
- /**
- * Surrogate primary key. Auto-incremented numeric value managed by the database.
- * Use this for relations between tables.
- */
id: int("id").autoincrement().primaryKey(),
- /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
openId: varchar("openId", { length: 64 }).notNull().unique(),
name: text("name"),
email: varchar("email", { length: 320 }),
loginMethod: varchar("loginMethod", { length: 64 }),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
+ /** Tennis skill level */
+ skillLevel: mysqlEnum("skillLevel", ["beginner", "intermediate", "advanced"]).default("beginner"),
+ /** User's training goals */
+ trainingGoals: text("trainingGoals"),
+ /** NTRP rating (1.0 - 5.0) */
+ ntrpRating: float("ntrpRating").default(1.5),
+ /** Total training sessions completed */
+ totalSessions: int("totalSessions").default(0),
+ /** Total training minutes */
+ totalMinutes: int("totalMinutes").default(0),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
@@ -25,4 +28,156 @@ export const users = mysqlTable("users", {
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
-// TODO: Add your tables here
\ No newline at end of file
+/**
+ * Simple username-based login accounts
+ */
+export const usernameAccounts = mysqlTable("username_accounts", {
+ id: int("id").autoincrement().primaryKey(),
+ username: varchar("username", { length: 64 }).notNull().unique(),
+ userId: int("userId").notNull(),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+});
+
+export type UsernameAccount = typeof usernameAccounts.$inferSelect;
+
+/**
+ * Training plans generated for users
+ */
+export const trainingPlans = mysqlTable("training_plans", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ title: varchar("title", { length: 256 }).notNull(),
+ skillLevel: mysqlEnum("skillLevel", ["beginner", "intermediate", "advanced"]).notNull(),
+ /** Plan duration in days */
+ durationDays: int("durationDays").notNull().default(7),
+ /** JSON array of training exercises */
+ exercises: json("exercises").notNull(),
+ /** Whether this plan is currently active */
+ isActive: int("isActive").notNull().default(1),
+ /** Auto-adjustment notes from AI analysis */
+ adjustmentNotes: text("adjustmentNotes"),
+ /** Plan generation version for tracking adjustments */
+ version: int("version").notNull().default(1),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type TrainingPlan = typeof trainingPlans.$inferSelect;
+export type InsertTrainingPlan = typeof trainingPlans.$inferInsert;
+
+/**
+ * Training videos uploaded by users
+ */
+export const trainingVideos = mysqlTable("training_videos", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ title: varchar("title", { length: 256 }).notNull(),
+ /** S3 file key */
+ fileKey: varchar("fileKey", { length: 512 }).notNull(),
+ /** CDN URL for the video */
+ url: text("url").notNull(),
+ /** Video format: webm or mp4 */
+ format: varchar("format", { length: 16 }).notNull(),
+ /** File size in bytes */
+ fileSize: int("fileSize"),
+ /** Duration in seconds */
+ duration: float("duration"),
+ /** Type of exercise in the video */
+ exerciseType: varchar("exerciseType", { length: 64 }),
+ /** Analysis status */
+ analysisStatus: mysqlEnum("analysisStatus", ["pending", "analyzing", "completed", "failed"]).default("pending"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type TrainingVideo = typeof trainingVideos.$inferSelect;
+export type InsertTrainingVideo = typeof trainingVideos.$inferInsert;
+
+/**
+ * Pose analysis results from MediaPipe - enhanced with tennis_analysis features
+ */
+export const poseAnalyses = mysqlTable("pose_analyses", {
+ id: int("id").autoincrement().primaryKey(),
+ videoId: int("videoId").notNull(),
+ userId: int("userId").notNull(),
+ /** Overall pose score (0-100) */
+ overallScore: float("overallScore"),
+ /** JSON object with detailed joint angles and metrics */
+ poseMetrics: json("poseMetrics"),
+ /** JSON array of detected issues */
+ detectedIssues: json("detectedIssues"),
+ /** JSON array of correction suggestions */
+ corrections: json("corrections"),
+ /** Exercise type analyzed */
+ exerciseType: varchar("exerciseType", { length: 64 }),
+ /** Number of frames analyzed */
+ framesAnalyzed: int("framesAnalyzed"),
+ /** --- tennis_analysis inspired fields --- */
+ /** Number of swings/shots detected */
+ shotCount: int("shotCount").default(0),
+ /** Average swing speed (estimated from keypoint displacement, px/frame) */
+ avgSwingSpeed: float("avgSwingSpeed"),
+ /** Max swing speed detected */
+ maxSwingSpeed: float("maxSwingSpeed"),
+ /** Total body movement distance in pixels */
+ totalMovementDistance: float("totalMovementDistance"),
+ /** Stroke consistency score (0-100) */
+ strokeConsistency: float("strokeConsistency"),
+ /** Footwork score (0-100) */
+ footworkScore: float("footworkScore"),
+ /** Fluidity/smoothness score (0-100) */
+ fluidityScore: float("fluidityScore"),
+ /** JSON array of key moments [{frame, type, description}] */
+ keyMoments: json("keyMoments"),
+ /** JSON array of movement trajectory points [{x, y, frame}] */
+ movementTrajectory: json("movementTrajectory"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+});
+
+export type PoseAnalysis = typeof poseAnalyses.$inferSelect;
+export type InsertPoseAnalysis = typeof poseAnalyses.$inferInsert;
+
+/**
+ * Training session records for progress tracking
+ */
+export const trainingRecords = mysqlTable("training_records", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ planId: int("planId"),
+ /** Exercise name/type */
+ exerciseName: varchar("exerciseName", { length: 128 }).notNull(),
+ /** Duration in minutes */
+ durationMinutes: int("durationMinutes"),
+ /** Completion status */
+ completed: int("completed").notNull().default(0),
+ /** Optional notes */
+ notes: text("notes"),
+ /** Pose score if video was analyzed */
+ poseScore: float("poseScore"),
+ /** Date of training */
+ trainingDate: timestamp("trainingDate").defaultNow().notNull(),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+});
+
+export type TrainingRecord = typeof trainingRecords.$inferSelect;
+export type InsertTrainingRecord = typeof trainingRecords.$inferInsert;
+
+/**
+ * NTRP Rating history - tracks rating changes over time
+ */
+export const ratingHistory = mysqlTable("rating_history", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ /** NTRP rating at this point */
+ rating: float("rating").notNull(),
+ /** What triggered this rating update */
+ reason: varchar("reason", { length: 256 }),
+ /** JSON breakdown of dimension scores */
+ dimensionScores: json("dimensionScores"),
+ /** Reference analysis ID if applicable */
+ analysisId: int("analysisId"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+});
+
+export type RatingHistory = typeof ratingHistory.$inferSelect;
+export type InsertRatingHistory = typeof ratingHistory.$inferInsert;
diff --git a/package.json b/package.json
index acde75b..9a0f743 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,8 @@
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0",
"@hookform/resolvers": "^5.2.2",
+ "@mediapipe/drawing_utils": "^0.3.1675466124",
+ "@mediapipe/pose": "^0.5.1675469404",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 25a9528..77b2cf7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,6 +25,12 @@ importers:
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
+ '@mediapipe/drawing_utils':
+ specifier: ^0.3.1675466124
+ version: 0.3.1675466124
+ '@mediapipe/pose':
+ specifier: ^0.5.1675469404
+ version: 0.5.1675469404
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -1049,6 +1055,12 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@mediapipe/drawing_utils@0.3.1675466124':
+ resolution: {integrity: sha512-/IWIB/iYRMtiUKe3k7yGqvwseWHCOqzVpRDfMgZ6gv9z7EEimg6iZbRluoPbcNKHbYSxN5yOvYTzUYb8KVf22Q==}
+
+ '@mediapipe/pose@0.5.1675469404':
+ resolution: {integrity: sha512-DFZsNWTsSphRIZppnUCuunzBiHP2FdJXR9ehc7mMi4KG+oPaOH0Em3d6kr7Py+TSyTXC1doH88KcF28k2sBxsQ==}
+
'@medv/finder@4.0.2':
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
@@ -5247,6 +5259,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@mediapipe/drawing_utils@0.3.1675466124': {}
+
+ '@mediapipe/pose@0.5.1675469404': {}
+
'@medv/finder@4.0.2': {}
'@mermaid-js/parser@0.6.3':
diff --git a/server/db.ts b/server/db.ts
index 795c205..64dbf46 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -1,11 +1,18 @@
-import { eq } from "drizzle-orm";
+import { eq, desc, and, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
-import { InsertUser, users } from "../drizzle/schema";
+import {
+ InsertUser, users,
+ usernameAccounts,
+ trainingPlans, InsertTrainingPlan,
+ trainingVideos, InsertTrainingVideo,
+ poseAnalyses, InsertPoseAnalysis,
+ trainingRecords, InsertTrainingRecord,
+ ratingHistory, InsertRatingHistory,
+} from "../drizzle/schema";
import { ENV } from './_core/env';
let _db: ReturnType | null = null;
-// Lazily create the drizzle instance so local tooling can run without a DB.
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
@@ -18,26 +25,19 @@ export async function getDb() {
return _db;
}
-export async function upsertUser(user: InsertUser): Promise {
- if (!user.openId) {
- throw new Error("User openId is required for upsert");
- }
+// ===== USER OPERATIONS =====
+export async function upsertUser(user: InsertUser): Promise {
+ if (!user.openId) throw new Error("User openId is required for upsert");
const db = await getDb();
- if (!db) {
- console.warn("[Database] Cannot upsert user: database not available");
- return;
- }
+ if (!db) { console.warn("[Database] Cannot upsert user: database not available"); return; }
try {
- const values: InsertUser = {
- openId: user.openId,
- };
+ const values: InsertUser = { openId: user.openId };
const updateSet: Record = {};
const textFields = ["name", "email", "loginMethod"] as const;
type TextField = (typeof textFields)[number];
-
const assignNullable = (field: TextField) => {
const value = user[field];
if (value === undefined) return;
@@ -45,48 +45,222 @@ export async function upsertUser(user: InsertUser): Promise {
values[field] = normalized;
updateSet[field] = normalized;
};
-
textFields.forEach(assignNullable);
- if (user.lastSignedIn !== undefined) {
- values.lastSignedIn = user.lastSignedIn;
- updateSet.lastSignedIn = user.lastSignedIn;
- }
- if (user.role !== undefined) {
- values.role = user.role;
- updateSet.role = user.role;
- } else if (user.openId === ENV.ownerOpenId) {
- values.role = 'admin';
- updateSet.role = 'admin';
- }
+ if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; }
+ if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; }
+ else if (user.openId === ENV.ownerOpenId) { values.role = 'admin'; updateSet.role = 'admin'; }
+ if (!values.lastSignedIn) values.lastSignedIn = new Date();
+ if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
- if (!values.lastSignedIn) {
- values.lastSignedIn = new Date();
- }
-
- if (Object.keys(updateSet).length === 0) {
- updateSet.lastSignedIn = new Date();
- }
-
- await db.insert(users).values(values).onDuplicateKeyUpdate({
- set: updateSet,
- });
- } catch (error) {
- console.error("[Database] Failed to upsert user:", error);
- throw error;
- }
+ await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
+ } catch (error) { console.error("[Database] Failed to upsert user:", error); throw error; }
}
export async function getUserByOpenId(openId: string) {
const db = await getDb();
- if (!db) {
- console.warn("[Database] Cannot get user: database not available");
- return undefined;
- }
-
+ if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
-
return result.length > 0 ? result[0] : undefined;
}
-// TODO: add feature queries here as your schema grows.
+export async function getUserByUsername(username: string) {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(usernameAccounts).where(eq(usernameAccounts.username, username)).limit(1);
+ if (result.length === 0) return undefined;
+ const userResult = await db.select().from(users).where(eq(users.id, result[0].userId)).limit(1);
+ return userResult.length > 0 ? userResult[0] : undefined;
+}
+
+export async function createUsernameAccount(username: string): Promise<{ user: typeof users.$inferSelect; isNew: boolean }> {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+
+ // Check if username already exists
+ const existing = await db.select().from(usernameAccounts).where(eq(usernameAccounts.username, username)).limit(1);
+ if (existing.length > 0) {
+ const user = await db.select().from(users).where(eq(users.id, existing[0].userId)).limit(1);
+ if (user.length > 0) {
+ await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, user[0].id));
+ return { user: user[0], isNew: false };
+ }
+ }
+
+ // Create new user with username as openId
+ const openId = `username_${username}_${Date.now()}`;
+ await db.insert(users).values({
+ openId,
+ name: username,
+ loginMethod: "username",
+ lastSignedIn: new Date(),
+ ntrpRating: 1.5,
+ totalSessions: 0,
+ totalMinutes: 0,
+ });
+
+ const newUser = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
+ if (newUser.length === 0) throw new Error("Failed to create user");
+
+ await db.insert(usernameAccounts).values({ username, userId: newUser[0].id });
+ return { user: newUser[0], isNew: true };
+}
+
+export async function updateUserProfile(userId: number, data: {
+ skillLevel?: "beginner" | "intermediate" | "advanced";
+ trainingGoals?: string;
+ ntrpRating?: number;
+ totalSessions?: number;
+ totalMinutes?: number;
+}) {
+ const db = await getDb();
+ if (!db) return;
+ await db.update(users).set(data).where(eq(users.id, userId));
+}
+
+// ===== TRAINING PLAN OPERATIONS =====
+
+export async function createTrainingPlan(plan: InsertTrainingPlan) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ // Deactivate existing active plans
+ await db.update(trainingPlans).set({ isActive: 0 }).where(and(eq(trainingPlans.userId, plan.userId), eq(trainingPlans.isActive, 1)));
+ const result = await db.insert(trainingPlans).values(plan);
+ return result[0].insertId;
+}
+
+export async function getUserTrainingPlans(userId: number) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(trainingPlans).where(eq(trainingPlans.userId, userId)).orderBy(desc(trainingPlans.createdAt));
+}
+
+export async function getActivePlan(userId: number) {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(trainingPlans).where(and(eq(trainingPlans.userId, userId), eq(trainingPlans.isActive, 1))).limit(1);
+ return result.length > 0 ? result[0] : undefined;
+}
+
+export async function updateTrainingPlan(planId: number, data: Partial) {
+ const db = await getDb();
+ if (!db) return;
+ await db.update(trainingPlans).set(data).where(eq(trainingPlans.id, planId));
+}
+
+// ===== VIDEO OPERATIONS =====
+
+export async function createVideo(video: InsertTrainingVideo) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ const result = await db.insert(trainingVideos).values(video);
+ return result[0].insertId;
+}
+
+export async function getUserVideos(userId: number) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(trainingVideos).where(eq(trainingVideos.userId, userId)).orderBy(desc(trainingVideos.createdAt));
+}
+
+export async function getVideoById(videoId: number) {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(trainingVideos).where(eq(trainingVideos.id, videoId)).limit(1);
+ return result.length > 0 ? result[0] : undefined;
+}
+
+export async function updateVideoStatus(videoId: number, status: "pending" | "analyzing" | "completed" | "failed") {
+ const db = await getDb();
+ if (!db) return;
+ await db.update(trainingVideos).set({ analysisStatus: status }).where(eq(trainingVideos.id, videoId));
+}
+
+// ===== POSE ANALYSIS OPERATIONS =====
+
+export async function createPoseAnalysis(analysis: InsertPoseAnalysis) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ const result = await db.insert(poseAnalyses).values(analysis);
+ return result[0].insertId;
+}
+
+export async function getAnalysisByVideoId(videoId: number) {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(poseAnalyses).where(eq(poseAnalyses.videoId, videoId)).orderBy(desc(poseAnalyses.createdAt)).limit(1);
+ return result.length > 0 ? result[0] : undefined;
+}
+
+export async function getUserAnalyses(userId: number) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(poseAnalyses).where(eq(poseAnalyses.userId, userId)).orderBy(desc(poseAnalyses.createdAt));
+}
+
+// ===== TRAINING RECORD OPERATIONS =====
+
+export async function createTrainingRecord(record: InsertTrainingRecord) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ const result = await db.insert(trainingRecords).values(record);
+ return result[0].insertId;
+}
+
+export async function getUserTrainingRecords(userId: number, limit = 50) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(trainingRecords).where(eq(trainingRecords.userId, userId)).orderBy(desc(trainingRecords.trainingDate)).limit(limit);
+}
+
+export async function markRecordCompleted(recordId: number, poseScore?: number) {
+ const db = await getDb();
+ if (!db) return;
+ await db.update(trainingRecords).set({ completed: 1, poseScore: poseScore ?? null }).where(eq(trainingRecords.id, recordId));
+}
+
+// ===== RATING HISTORY OPERATIONS =====
+
+export async function createRatingEntry(entry: InsertRatingHistory) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ const result = await db.insert(ratingHistory).values(entry);
+ return result[0].insertId;
+}
+
+export async function getUserRatingHistory(userId: number, limit = 30) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(limit);
+}
+
+// ===== STATS HELPERS =====
+
+export async function getUserStats(userId: number) {
+ const db = await getDb();
+ if (!db) return null;
+
+ const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
+ if (!userRow) return null;
+
+ const analyses = await db.select().from(poseAnalyses).where(eq(poseAnalyses.userId, userId));
+ const records = await db.select().from(trainingRecords).where(eq(trainingRecords.userId, userId));
+ const videos = await db.select().from(trainingVideos).where(eq(trainingVideos.userId, userId));
+ const ratings = await db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(30);
+
+ const completedRecords = records.filter(r => r.completed === 1);
+ const totalShots = analyses.reduce((sum, a) => sum + (a.shotCount || 0), 0);
+ const avgScore = analyses.length > 0 ? analyses.reduce((sum, a) => sum + (a.overallScore || 0), 0) / analyses.length : 0;
+
+ return {
+ ntrpRating: userRow.ntrpRating || 1.5,
+ totalSessions: completedRecords.length,
+ totalMinutes: records.reduce((sum, r) => sum + (r.durationMinutes || 0), 0),
+ totalVideos: videos.length,
+ analyzedVideos: videos.filter(v => v.analysisStatus === "completed").length,
+ totalShots,
+ averageScore: Math.round(avgScore * 10) / 10,
+ ratingHistory: ratings.reverse(),
+ recentAnalyses: analyses.slice(0, 10),
+ };
+}
diff --git a/server/features.test.ts b/server/features.test.ts
new file mode 100644
index 0000000..3614c09
--- /dev/null
+++ b/server/features.test.ts
@@ -0,0 +1,237 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { appRouter } from "./routers";
+import { COOKIE_NAME } from "../shared/const";
+import type { TrpcContext } from "./_core/context";
+
+type AuthenticatedUser = NonNullable;
+
+function createTestUser(overrides?: Partial): AuthenticatedUser {
+ return {
+ id: 1,
+ openId: "test-user-001",
+ email: "test@example.com",
+ name: "TestPlayer",
+ loginMethod: "username",
+ role: "user",
+ skillLevel: "beginner",
+ trainingGoals: null,
+ ntrpRating: 1.5,
+ totalSessions: 0,
+ totalMinutes: 0,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ lastSignedIn: new Date(),
+ ...overrides,
+ };
+}
+
+function createMockContext(user: AuthenticatedUser | null = null): {
+ ctx: TrpcContext;
+ clearedCookies: { name: string; options: Record }[];
+ setCookies: { name: string; value: string; options: Record }[];
+} {
+ const clearedCookies: { name: string; options: Record }[] = [];
+ const setCookies: { name: string; value: string; options: Record }[] = [];
+
+ return {
+ ctx: {
+ user,
+ req: {
+ protocol: "https",
+ headers: {},
+ } as TrpcContext["req"],
+ res: {
+ clearCookie: (name: string, options: Record) => {
+ clearedCookies.push({ name, options });
+ },
+ cookie: (name: string, value: string, options: Record) => {
+ setCookies.push({ name, value, options });
+ },
+ } as TrpcContext["res"],
+ },
+ clearedCookies,
+ setCookies,
+ };
+}
+
+describe("auth.me", () => {
+ it("returns null for unauthenticated users", async () => {
+ const { ctx } = createMockContext(null);
+ const caller = appRouter.createCaller(ctx);
+ const result = await caller.auth.me();
+ expect(result).toBeNull();
+ });
+
+ it("returns user data for authenticated users", async () => {
+ const user = createTestUser();
+ const { ctx } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+ const result = await caller.auth.me();
+ expect(result).toBeDefined();
+ expect(result?.name).toBe("TestPlayer");
+ expect(result?.openId).toBe("test-user-001");
+ });
+});
+
+describe("auth.logout", () => {
+ it("clears the session cookie and reports success", async () => {
+ const user = createTestUser();
+ const { ctx, clearedCookies } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+
+ const result = await caller.auth.logout();
+
+ expect(result).toEqual({ success: true });
+ expect(clearedCookies).toHaveLength(1);
+ expect(clearedCookies[0]?.name).toBe(COOKIE_NAME);
+ expect(clearedCookies[0]?.options).toMatchObject({
+ maxAge: -1,
+ secure: true,
+ sameSite: "none",
+ httpOnly: true,
+ path: "/",
+ });
+ });
+});
+
+describe("profile.stats", () => {
+ it("requires authentication", async () => {
+ const { ctx } = createMockContext(null);
+ const caller = appRouter.createCaller(ctx);
+ await expect(caller.profile.stats()).rejects.toThrow();
+ });
+});
+
+describe("plan.generate input validation", () => {
+ it("rejects invalid skill level", async () => {
+ const user = createTestUser();
+ const { ctx } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+
+ await expect(
+ caller.plan.generate({
+ skillLevel: "expert" as any,
+ durationDays: 7,
+ })
+ ).rejects.toThrow();
+ });
+
+ it("rejects invalid duration", async () => {
+ const user = createTestUser();
+ const { ctx } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+
+ await expect(
+ caller.plan.generate({
+ skillLevel: "beginner",
+ durationDays: 0,
+ })
+ ).rejects.toThrow();
+ });
+
+ it("rejects duration over 30", async () => {
+ const user = createTestUser();
+ const { ctx } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+
+ await expect(
+ caller.plan.generate({
+ skillLevel: "beginner",
+ durationDays: 31,
+ })
+ ).rejects.toThrow();
+ });
+});
+
+describe("video.upload input validation", () => {
+ it("requires authentication", async () => {
+ const { ctx } = createMockContext(null);
+ const caller = appRouter.createCaller(ctx);
+
+ await expect(
+ caller.video.upload({
+ title: "test",
+ format: "mp4",
+ fileSize: 1000,
+ fileBase64: "dGVzdA==",
+ })
+ ).rejects.toThrow();
+ });
+});
+
+describe("analysis.save input validation", () => {
+ it("requires authentication", async () => {
+ const { ctx } = createMockContext(null);
+ const caller = appRouter.createCaller(ctx);
+
+ await expect(
+ caller.analysis.save({
+ videoId: 1,
+ overallScore: 75,
+ })
+ ).rejects.toThrow();
+ });
+});
+
+describe("analysis.getCorrections input validation", () => {
+ it("requires authentication", async () => {
+ const { ctx } = createMockContext(null);
+ const caller = appRouter.createCaller(ctx);
+
+ await expect(
+ caller.analysis.getCorrections({
+ poseMetrics: {},
+ exerciseType: "forehand",
+ detectedIssues: [],
+ })
+ ).rejects.toThrow();
+ });
+});
+
+describe("record.create input validation", () => {
+ it("requires authentication", async () => {
+ const { ctx } = createMockContext(null);
+ const caller = appRouter.createCaller(ctx);
+
+ await expect(
+ caller.record.create({
+ exerciseName: "正手挥拍",
+ durationMinutes: 30,
+ })
+ ).rejects.toThrow();
+ });
+
+ it("accepts valid exercise name", async () => {
+ const user = createTestUser();
+ const { ctx } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+
+ // This should not throw on input validation (may throw on DB)
+ // We just verify the input schema accepts a valid name
+ try {
+ await caller.record.create({
+ exerciseName: "正手挥拍",
+ durationMinutes: 30,
+ });
+ } catch (e: any) {
+ // DB errors are expected in test env, but input validation should pass
+ expect(e.message).not.toContain("invalid_type");
+ }
+ });
+});
+
+describe("rating.history", () => {
+ it("requires authentication", async () => {
+ const { ctx } = createMockContext(null);
+ const caller = appRouter.createCaller(ctx);
+ await expect(caller.rating.history()).rejects.toThrow();
+ });
+});
+
+describe("rating.current", () => {
+ it("requires authentication", async () => {
+ const { ctx } = createMockContext(null);
+ const caller = appRouter.createCaller(ctx);
+ await expect(caller.rating.current()).rejects.toThrow();
+ });
+});
diff --git a/server/routers.ts b/server/routers.ts
index 21836ad..16e9521 100644
--- a/server/routers.ts
+++ b/server/routers.ts
@@ -1,28 +1,479 @@
-import { COOKIE_NAME } from "@shared/const";
+import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
-import { publicProcedure, router } from "./_core/trpc";
+import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
+import { z } from "zod";
+import { sdk } from "./_core/sdk";
+import { invokeLLM } from "./_core/llm";
+import { storagePut } from "./storage";
+import * as db from "./db";
+import { nanoid } from "nanoid";
export const appRouter = router({
- // if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly
system: systemRouter,
+
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
- return {
- success: true,
- } as const;
+ return { success: true } as const;
+ }),
+
+ // Username-based login
+ loginWithUsername: publicProcedure
+ .input(z.object({ username: z.string().min(1).max(64) }))
+ .mutation(async ({ ctx, input }) => {
+ const { user, isNew } = await db.createUsernameAccount(input.username);
+ const sessionToken = await sdk.createSessionToken(user.openId, {
+ name: user.name || input.username,
+ expiresInMs: ONE_YEAR_MS,
+ });
+ const cookieOptions = getSessionCookieOptions(ctx.req);
+ ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
+ return { user, isNew };
+ }),
+ }),
+
+ // User profile management
+ profile: router({
+ update: protectedProcedure
+ .input(z.object({
+ skillLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(),
+ trainingGoals: z.string().optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ await db.updateUserProfile(ctx.user.id, input);
+ return { success: true };
+ }),
+ stats: protectedProcedure.query(async ({ ctx }) => {
+ return db.getUserStats(ctx.user.id);
}),
}),
- // TODO: add feature routers here, e.g.
- // todo: router({
- // list: protectedProcedure.query(({ ctx }) =>
- // db.getUserTodos(ctx.user.id)
- // ),
- // }),
+ // Training plan management
+ plan: router({
+ generate: protectedProcedure
+ .input(z.object({
+ skillLevel: z.enum(["beginner", "intermediate", "advanced"]),
+ durationDays: z.number().min(1).max(30).default(7),
+ focusAreas: z.array(z.string()).optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ const user = ctx.user;
+ // Get user's recent analyses for personalization
+ const analyses = await db.getUserAnalyses(user.id);
+ const recentScores = analyses.slice(0, 5).map(a => ({
+ score: a.overallScore,
+ issues: a.detectedIssues,
+ exerciseType: a.exerciseType,
+ shotCount: a.shotCount,
+ strokeConsistency: a.strokeConsistency,
+ footworkScore: a.footworkScore,
+ }));
+
+ const prompt = `你是一位专业网球教练。请为一位${
+ input.skillLevel === "beginner" ? "初级" : input.skillLevel === "intermediate" ? "中级" : "高级"
+ }水平的网球学员生成一个${input.durationDays}天的在家训练计划。
+
+要求:
+- 只需要球拍,不需要球场和球网
+- 包含影子挥拍、墙壁练习、脚步移动、体能训练等
+- 每天训练30-60分钟
+${input.focusAreas?.length ? `- 重点关注: ${input.focusAreas.join(", ")}` : ""}
+${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(recentScores)}` : ""}
+
+请返回JSON格式,包含每天的训练内容。`;
+
+ const response = await invokeLLM({
+ messages: [
+ { role: "system", content: "你是专业网球教练AI助手。返回严格的JSON格式。" },
+ { role: "user", content: prompt },
+ ],
+ response_format: {
+ type: "json_schema",
+ json_schema: {
+ name: "training_plan",
+ strict: true,
+ schema: {
+ type: "object",
+ properties: {
+ title: { type: "string", description: "训练计划标题" },
+ exercises: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ day: { type: "number" },
+ name: { type: "string" },
+ category: { type: "string" },
+ duration: { type: "number", description: "分钟" },
+ description: { type: "string" },
+ tips: { type: "string" },
+ sets: { type: "number" },
+ reps: { type: "number" },
+ },
+ required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
+ additionalProperties: false,
+ },
+ },
+ },
+ required: ["title", "exercises"],
+ additionalProperties: false,
+ },
+ },
+ },
+ });
+
+ const content = response.choices[0]?.message?.content;
+ const parsed = typeof content === "string" ? JSON.parse(content) : null;
+ if (!parsed) throw new Error("Failed to generate training plan");
+
+ const planId = await db.createTrainingPlan({
+ userId: user.id,
+ title: parsed.title,
+ skillLevel: input.skillLevel,
+ durationDays: input.durationDays,
+ exercises: parsed.exercises,
+ isActive: 1,
+ version: 1,
+ });
+
+ return { planId, plan: parsed };
+ }),
+
+ list: protectedProcedure.query(async ({ ctx }) => {
+ return db.getUserTrainingPlans(ctx.user.id);
+ }),
+
+ active: protectedProcedure.query(async ({ ctx }) => {
+ return db.getActivePlan(ctx.user.id);
+ }),
+
+ adjust: protectedProcedure
+ .input(z.object({ planId: z.number() }))
+ .mutation(async ({ ctx, input }) => {
+ const analyses = await db.getUserAnalyses(ctx.user.id);
+ const recentAnalyses = analyses.slice(0, 5);
+ const currentPlan = (await db.getUserTrainingPlans(ctx.user.id)).find(p => p.id === input.planId);
+ if (!currentPlan) throw new Error("Plan not found");
+
+ const prompt = `基于以下用户的姿势分析结果,调整训练计划:
+
+当前计划: ${JSON.stringify(currentPlan.exercises)}
+最近分析结果: ${JSON.stringify(recentAnalyses.map(a => ({
+ score: a.overallScore,
+ issues: a.detectedIssues,
+ corrections: a.corrections,
+ shotCount: a.shotCount,
+ strokeConsistency: a.strokeConsistency,
+ footworkScore: a.footworkScore,
+ fluidityScore: a.fluidityScore,
+ })))}
+
+请根据分析结果调整训练计划,增加针对薄弱环节的训练,返回与原计划相同格式的JSON。`;
+
+ const response = await invokeLLM({
+ messages: [
+ { role: "system", content: "你是专业网球教练AI助手。返回严格的JSON格式。" },
+ { role: "user", content: prompt },
+ ],
+ response_format: {
+ type: "json_schema",
+ json_schema: {
+ name: "adjusted_plan",
+ strict: true,
+ schema: {
+ type: "object",
+ properties: {
+ title: { type: "string" },
+ adjustmentNotes: { type: "string", description: "调整说明" },
+ exercises: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ day: { type: "number" },
+ name: { type: "string" },
+ category: { type: "string" },
+ duration: { type: "number" },
+ description: { type: "string" },
+ tips: { type: "string" },
+ sets: { type: "number" },
+ reps: { type: "number" },
+ },
+ required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
+ additionalProperties: false,
+ },
+ },
+ },
+ required: ["title", "adjustmentNotes", "exercises"],
+ additionalProperties: false,
+ },
+ },
+ },
+ });
+
+ const content = response.choices[0]?.message?.content;
+ const parsed = typeof content === "string" ? JSON.parse(content) : null;
+ if (!parsed) throw new Error("Failed to adjust plan");
+
+ await db.updateTrainingPlan(input.planId, {
+ exercises: parsed.exercises,
+ adjustmentNotes: parsed.adjustmentNotes,
+ version: (currentPlan.version || 1) + 1,
+ });
+
+ return { success: true, adjustmentNotes: parsed.adjustmentNotes };
+ }),
+ }),
+
+ // Video management
+ video: router({
+ upload: protectedProcedure
+ .input(z.object({
+ title: z.string(),
+ format: z.string(),
+ fileSize: z.number(),
+ exerciseType: z.string().optional(),
+ fileBase64: z.string(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ const fileBuffer = Buffer.from(input.fileBase64, "base64");
+ const fileKey = `videos/${ctx.user.id}/${nanoid()}.${input.format}`;
+ const contentType = input.format === "webm" ? "video/webm" : "video/mp4";
+ const { url } = await storagePut(fileKey, fileBuffer, contentType);
+
+ const videoId = await db.createVideo({
+ userId: ctx.user.id,
+ title: input.title,
+ fileKey,
+ url,
+ format: input.format,
+ fileSize: input.fileSize,
+ exerciseType: input.exerciseType || null,
+ analysisStatus: "pending",
+ });
+
+ return { videoId, url };
+ }),
+
+ list: protectedProcedure.query(async ({ ctx }) => {
+ return db.getUserVideos(ctx.user.id);
+ }),
+
+ get: protectedProcedure
+ .input(z.object({ videoId: z.number() }))
+ .query(async ({ input }) => {
+ return db.getVideoById(input.videoId);
+ }),
+
+ updateStatus: protectedProcedure
+ .input(z.object({
+ videoId: z.number(),
+ status: z.enum(["pending", "analyzing", "completed", "failed"]),
+ }))
+ .mutation(async ({ input }) => {
+ await db.updateVideoStatus(input.videoId, input.status);
+ return { success: true };
+ }),
+ }),
+
+ // Pose analysis
+ analysis: router({
+ save: protectedProcedure
+ .input(z.object({
+ videoId: z.number(),
+ overallScore: z.number().optional(),
+ poseMetrics: z.any().optional(),
+ detectedIssues: z.any().optional(),
+ corrections: z.any().optional(),
+ exerciseType: z.string().optional(),
+ framesAnalyzed: z.number().optional(),
+ shotCount: z.number().optional(),
+ avgSwingSpeed: z.number().optional(),
+ maxSwingSpeed: z.number().optional(),
+ totalMovementDistance: z.number().optional(),
+ strokeConsistency: z.number().optional(),
+ footworkScore: z.number().optional(),
+ fluidityScore: z.number().optional(),
+ keyMoments: z.any().optional(),
+ movementTrajectory: z.any().optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ const analysisId = await db.createPoseAnalysis({
+ ...input,
+ userId: ctx.user.id,
+ });
+ await db.updateVideoStatus(input.videoId, "completed");
+
+ // Auto-update NTRP rating after analysis
+ await recalculateNTRPRating(ctx.user.id, analysisId);
+
+ return { analysisId };
+ }),
+
+ getByVideo: protectedProcedure
+ .input(z.object({ videoId: z.number() }))
+ .query(async ({ input }) => {
+ return db.getAnalysisByVideoId(input.videoId);
+ }),
+
+ list: protectedProcedure.query(async ({ ctx }) => {
+ return db.getUserAnalyses(ctx.user.id);
+ }),
+
+ // Generate AI correction suggestions
+ getCorrections: protectedProcedure
+ .input(z.object({
+ poseMetrics: z.any(),
+ exerciseType: z.string(),
+ detectedIssues: z.any(),
+ }))
+ .mutation(async ({ input }) => {
+ const response = await invokeLLM({
+ messages: [
+ {
+ role: "system",
+ content: "你是一位专业网球教练。根据MediaPipe姿势分析数据,给出具体的姿势矫正建议。用中文回答。",
+ },
+ {
+ role: "user",
+ content: `分析以下网球动作数据并给出矫正建议:
+动作类型: ${input.exerciseType}
+姿势指标: ${JSON.stringify(input.poseMetrics)}
+检测到的问题: ${JSON.stringify(input.detectedIssues)}
+
+请给出:
+1. 每个问题的具体矫正方法
+2. 推荐的练习动作
+3. 需要注意的关键点`,
+ },
+ ],
+ });
+
+ return {
+ corrections: response.choices[0]?.message?.content || "暂无建议",
+ };
+ }),
+ }),
+
+ // Training records
+ record: router({
+ create: protectedProcedure
+ .input(z.object({
+ planId: z.number().optional(),
+ exerciseName: z.string(),
+ durationMinutes: z.number().optional(),
+ notes: z.string().optional(),
+ poseScore: z.number().optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ const recordId = await db.createTrainingRecord({
+ userId: ctx.user.id,
+ ...input,
+ completed: 0,
+ });
+ return { recordId };
+ }),
+
+ complete: protectedProcedure
+ .input(z.object({
+ recordId: z.number(),
+ poseScore: z.number().optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ await db.markRecordCompleted(input.recordId, input.poseScore);
+ // Update user stats
+ const records = await db.getUserTrainingRecords(ctx.user.id, 1000);
+ const completed = records.filter(r => r.completed === 1);
+ const totalMinutes = records.reduce((sum, r) => sum + (r.durationMinutes || 0), 0);
+ await db.updateUserProfile(ctx.user.id, {
+ totalSessions: completed.length,
+ totalMinutes,
+ });
+ return { success: true };
+ }),
+
+ list: protectedProcedure
+ .input(z.object({ limit: z.number().default(50) }).optional())
+ .query(async ({ ctx, input }) => {
+ return db.getUserTrainingRecords(ctx.user.id, input?.limit || 50);
+ }),
+ }),
+
+ // Rating system
+ rating: router({
+ history: protectedProcedure.query(async ({ ctx }) => {
+ return db.getUserRatingHistory(ctx.user.id);
+ }),
+ current: protectedProcedure.query(async ({ ctx }) => {
+ const user = await db.getUserByOpenId(ctx.user.openId);
+ return { rating: user?.ntrpRating || 1.5 };
+ }),
+ }),
});
+// NTRP Rating calculation function
+async function recalculateNTRPRating(userId: number, latestAnalysisId: number) {
+ const analyses = await db.getUserAnalyses(userId);
+ if (analyses.length === 0) return;
+
+ // Weight recent analyses more heavily
+ const weightedScores = analyses.slice(0, 20).map((a, i) => {
+ const weight = Math.max(0.3, 1 - i * 0.05); // Recent = higher weight
+ return {
+ overallScore: (a.overallScore || 0) * weight,
+ strokeConsistency: (a.strokeConsistency || 0) * weight,
+ footworkScore: (a.footworkScore || 0) * weight,
+ fluidityScore: (a.fluidityScore || 0) * weight,
+ shotCount: (a.shotCount || 0) * weight,
+ avgSwingSpeed: (a.avgSwingSpeed || 0) * weight,
+ weight,
+ };
+ });
+
+ const totalWeight = weightedScores.reduce((sum, s) => sum + s.weight, 0);
+
+ const dimensions = {
+ poseAccuracy: weightedScores.reduce((sum, s) => sum + s.overallScore, 0) / totalWeight,
+ strokeConsistency: weightedScores.reduce((sum, s) => sum + s.strokeConsistency, 0) / totalWeight,
+ footwork: weightedScores.reduce((sum, s) => sum + s.footworkScore, 0) / totalWeight,
+ fluidity: weightedScores.reduce((sum, s) => sum + s.fluidityScore, 0) / totalWeight,
+ power: Math.min(100, weightedScores.reduce((sum, s) => sum + s.avgSwingSpeed, 0) / totalWeight * 5),
+ };
+
+ // Convert 0-100 scores to NTRP 1.0-5.0
+ // NTRP mapping: 0-20 → 1.0-1.5, 20-40 → 1.5-2.5, 40-60 → 2.5-3.5, 60-80 → 3.5-4.5, 80-100 → 4.5-5.0
+ const avgDimension = (
+ dimensions.poseAccuracy * 0.30 +
+ dimensions.strokeConsistency * 0.25 +
+ dimensions.footwork * 0.20 +
+ dimensions.fluidity * 0.15 +
+ dimensions.power * 0.10
+ );
+
+ let ntrpRating: number;
+ if (avgDimension <= 20) ntrpRating = 1.0 + (avgDimension / 20) * 0.5;
+ else if (avgDimension <= 40) ntrpRating = 1.5 + ((avgDimension - 20) / 20) * 1.0;
+ else if (avgDimension <= 60) ntrpRating = 2.5 + ((avgDimension - 40) / 20) * 1.0;
+ else if (avgDimension <= 80) ntrpRating = 3.5 + ((avgDimension - 60) / 20) * 1.0;
+ else ntrpRating = 4.5 + ((avgDimension - 80) / 20) * 0.5;
+
+ ntrpRating = Math.round(ntrpRating * 10) / 10;
+ ntrpRating = Math.max(1.0, Math.min(5.0, ntrpRating));
+
+ // Save rating history
+ await db.createRatingEntry({
+ userId,
+ rating: ntrpRating,
+ reason: `基于${analyses.length}次视频分析自动评分`,
+ dimensionScores: dimensions,
+ analysisId: latestAnalysisId,
+ });
+
+ // Update user's current rating
+ await db.updateUserProfile(userId, { ntrpRating });
+}
+
export type AppRouter = typeof appRouter;
diff --git a/todo.md b/todo.md
new file mode 100644
index 0000000..4e50295
--- /dev/null
+++ b/todo.md
@@ -0,0 +1,28 @@
+# Project TODO
+
+- [x] 用户名简单登录系统(只需输入用户名即可登录)
+- [x] 数据库schema设计(用户表、训练计划表、视频表、分析结果表、训练记录表)
+- [x] 训练计划生成系统(根据初级/中级/高级水平生成计划)
+- [x] 训练计划内容(影子挥拍、墙壁练习、脚步移动等只需球拍的训练)
+- [x] 视频上传功能(支持webm/mp4格式)
+- [x] 视频存储到S3
+- [x] MediaPipe Pose浏览器端姿势识别集成
+- [x] 视频播放与关键点可视化叠加显示
+- [x] 姿势矫正建议系统(基于AI识别结果生成矫正建议)
+- [x] 训练计划自动调整(根据姿势识别结果和进度调整计划)
+- [x] 训练进度追踪(可视化展示训练历史和姿势改进趋势)
+- [x] 训练视频库管理(保存和管理所有训练视频及分析结果)
+- [x] 全局UI设计和主题配置
+- [x] 响应式布局适配
+- [x] 编写vitest测试
+- [x] 击球次数统计(参考tennis_analysis)
+- [x] 挥拍速度估算(手腕/手臂关键点帧间位移)
+- [x] 运动轨迹可视化(身体中心点移动轨迹)
+- [ ] 迷你球场可视化叠加
+- [x] 球员统计面板(综合展示分析数据)
+- [x] 帧级别关键时刻标注
+- [x] NTRP自动评分系统(1.0-5.0)
+- [x] 基于所有历史记录自动更新用户评分
+- [x] 多维度评分(姿势正确性、动作流畅性、击球一致性、脚步移动、挥拍速度)
+- [x] 评分趋势图表展示
+- [ ] 推送代码到Gitea仓库