Checkpoint: Tennis Training Hub v1.0 - 完整功能版本:用户名登录、AI训练计划生成、MediaPipe视频姿势识别、击球统计、挥拍速度分析、NTRP自动评分系统、训练进度追踪、视频库管理、AI矫正建议
这个提交包含在:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -7,11 +7,9 @@
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
||||||
<title>Tennis Training Hub - AI网球训练助手</title>
|
<title>Tennis Training Hub - AI网球训练助手</title>
|
||||||
<!-- THIS IS THE START OF A COMMENT BLOCK, BLOCK TO BE DELETED: Google Fonts here, example:
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
THIS IS THE END OF A COMMENT BLOCK, BLOCK TO BE DELETED -->
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -4,32 +4,57 @@ import NotFound from "@/pages/NotFound";
|
|||||||
import { Route, Switch } from "wouter";
|
import { Route, Switch } from "wouter";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
|
import DashboardLayout from "./components/DashboardLayout";
|
||||||
import Home from "./pages/Home";
|
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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Component />
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
// make sure to consider if you need authentication for certain routes
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={"/"} component={Home} />
|
<Route path="/" component={Home} />
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path="/login" component={Login} />
|
||||||
{/* Final fallback route */}
|
<Route path="/dashboard">
|
||||||
|
<DashboardRoute component={Dashboard} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/training">
|
||||||
|
<DashboardRoute component={Training} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/analysis">
|
||||||
|
<DashboardRoute component={Analysis} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/videos">
|
||||||
|
<DashboardRoute component={Videos} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/progress">
|
||||||
|
<DashboardRoute component={Progress} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/rating">
|
||||||
|
<DashboardRoute component={Rating} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ThemeProvider
|
<ThemeProvider defaultTheme="light">
|
||||||
defaultTheme="light"
|
|
||||||
// switchable
|
|
||||||
>
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Router />
|
<Router />
|
||||||
|
|||||||
@@ -19,23 +19,28 @@ import {
|
|||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { getLoginUrl } from "@/const";
|
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
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 { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation, Redirect } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
|
{ icon: LayoutDashboard, label: "仪表盘", path: "/dashboard" },
|
||||||
{ icon: Users, label: "Page 2", path: "/some-path" },
|
{ 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 SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||||
const DEFAULT_WIDTH = 280;
|
const DEFAULT_WIDTH = 260;
|
||||||
const MIN_WIDTH = 200;
|
const MIN_WIDTH = 200;
|
||||||
const MAX_WIDTH = 480;
|
const MAX_WIDTH = 400;
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -57,29 +62,7 @@ export default function DashboardLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return <Redirect to="/login" />;
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
|
|
||||||
<div className="flex flex-col items-center gap-6">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-center">
|
|
||||||
Sign in to continue
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
|
||||||
Access to this dashboard requires authentication. Continue to launch the login flow.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = getLoginUrl();
|
|
||||||
}}
|
|
||||||
size="lg"
|
|
||||||
className="w-full shadow-lg hover:shadow-xl transition-all"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +107,6 @@ function DashboardLayoutContent({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
|
|
||||||
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
||||||
const newWidth = e.clientX - sidebarLeft;
|
const newWidth = e.clientX - sidebarLeft;
|
||||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||||
@@ -170,8 +152,9 @@ function DashboardLayoutContent({
|
|||||||
</button>
|
</button>
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="font-semibold tracking-tight truncate">
|
<Target className="h-5 w-5 text-primary shrink-0" />
|
||||||
Navigation
|
<span className="font-semibold tracking-tight truncate text-sm">
|
||||||
|
Tennis Hub
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -206,16 +189,16 @@ function DashboardLayoutContent({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
<Avatar className="h-9 w-9 border shrink-0">
|
<Avatar className="h-9 w-9 border shrink-0">
|
||||||
<AvatarFallback className="text-xs font-medium">
|
<AvatarFallback className="text-xs font-medium bg-primary/10 text-primary">
|
||||||
{user?.name?.charAt(0).toUpperCase()}
|
{user?.name?.charAt(0).toUpperCase() || "U"}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
||||||
<p className="text-sm font-medium truncate leading-none">
|
<p className="text-sm font-medium truncate leading-none">
|
||||||
{user?.name || "-"}
|
{user?.name || "用户"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
||||||
{user?.email || "-"}
|
{user?.email || "网球训练中"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -226,7 +209,7 @@ function DashboardLayoutContent({
|
|||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>Sign out</span>
|
<span>退出登录</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -249,15 +232,15 @@ function DashboardLayoutContent({
|
|||||||
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="tracking-tight text-foreground">
|
<span className="tracking-tight text-foreground text-sm">
|
||||||
{activeMenuItem?.label ?? "Menu"}
|
{activeMenuItem?.label ?? "Tennis Hub"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<main className="flex-1 p-4">{children}</main>
|
<main className="flex-1 p-4 md:p-6">{children}</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,77 +40,79 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--font-sans: 'Inter', 'Noto Sans SC', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary: var(--color-blue-700);
|
/* Tennis green theme */
|
||||||
--primary-foreground: var(--color-blue-50);
|
--primary: oklch(0.55 0.16 145);
|
||||||
--sidebar-primary: var(--color-blue-600);
|
--primary-foreground: oklch(0.98 0 0);
|
||||||
--sidebar-primary-foreground: var(--color-blue-50);
|
--sidebar-primary: oklch(0.50 0.15 145);
|
||||||
--chart-1: var(--color-blue-300);
|
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||||
--chart-2: var(--color-blue-500);
|
--chart-1: oklch(0.65 0.18 145);
|
||||||
--chart-3: var(--color-blue-600);
|
--chart-2: oklch(0.55 0.16 145);
|
||||||
--chart-4: var(--color-blue-700);
|
--chart-3: oklch(0.72 0.12 80);
|
||||||
--chart-5: var(--color-blue-800);
|
--chart-4: oklch(0.60 0.14 200);
|
||||||
--radius: 0.65rem;
|
--chart-5: oklch(0.50 0.10 280);
|
||||||
--background: oklch(1 0 0);
|
--radius: 0.625rem;
|
||||||
--foreground: oklch(0.235 0.015 65);
|
--background: oklch(0.985 0.002 100);
|
||||||
|
--foreground: oklch(0.18 0.02 260);
|
||||||
--card: oklch(1 0 0);
|
--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: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.235 0.015 65);
|
--popover-foreground: oklch(0.18 0.02 260);
|
||||||
--secondary: oklch(0.98 0.001 286.375);
|
--secondary: oklch(0.96 0.01 145);
|
||||||
--secondary-foreground: oklch(0.4 0.015 65);
|
--secondary-foreground: oklch(0.35 0.08 145);
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
--muted: oklch(0.96 0.005 260);
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
--muted-foreground: oklch(0.50 0.02 260);
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
--accent: oklch(0.95 0.02 145);
|
||||||
--accent-foreground: oklch(0.141 0.005 285.823);
|
--accent-foreground: oklch(0.25 0.06 145);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(0.92 0.004 286.32);
|
--border: oklch(0.91 0.005 145);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.91 0.005 145);
|
||||||
--ring: oklch(0.623 0.214 259.815);
|
--ring: oklch(0.55 0.16 145);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.98 0.005 145);
|
||||||
--sidebar-foreground: oklch(0.235 0.015 65);
|
--sidebar-foreground: oklch(0.18 0.02 260);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-accent: oklch(0.94 0.02 145);
|
||||||
--sidebar-accent-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-accent-foreground: oklch(0.25 0.06 145);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.91 0.005 145);
|
||||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
--sidebar-ring: oklch(0.55 0.16 145);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--primary: var(--color-blue-700);
|
--primary: oklch(0.65 0.18 145);
|
||||||
--primary-foreground: var(--color-blue-50);
|
--primary-foreground: oklch(0.12 0.02 145);
|
||||||
--sidebar-primary: var(--color-blue-500);
|
--sidebar-primary: oklch(0.60 0.16 145);
|
||||||
--sidebar-primary-foreground: var(--color-blue-50);
|
--sidebar-primary-foreground: oklch(0.12 0.02 145);
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.14 0.01 260);
|
||||||
--foreground: oklch(0.85 0.005 65);
|
--foreground: oklch(0.90 0.005 100);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.19 0.01 260);
|
||||||
--card-foreground: oklch(0.85 0.005 65);
|
--card-foreground: oklch(0.90 0.005 100);
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
--popover: oklch(0.19 0.01 260);
|
||||||
--popover-foreground: oklch(0.85 0.005 65);
|
--popover-foreground: oklch(0.90 0.005 100);
|
||||||
--secondary: oklch(0.24 0.006 286.033);
|
--secondary: oklch(0.22 0.015 145);
|
||||||
--secondary-foreground: oklch(0.7 0.005 65);
|
--secondary-foreground: oklch(0.75 0.08 145);
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
--muted: oklch(0.25 0.01 260);
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
--muted-foreground: oklch(0.65 0.015 260);
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
--accent: oklch(0.25 0.02 145);
|
||||||
--accent-foreground: oklch(0.92 0.005 65);
|
--accent-foreground: oklch(0.85 0.06 145);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.488 0.243 264.376);
|
--ring: oklch(0.65 0.18 145);
|
||||||
--chart-1: var(--color-blue-300);
|
--chart-1: oklch(0.65 0.18 145);
|
||||||
--chart-2: var(--color-blue-500);
|
--chart-2: oklch(0.55 0.16 145);
|
||||||
--chart-3: var(--color-blue-600);
|
--chart-3: oklch(0.72 0.12 80);
|
||||||
--chart-4: var(--color-blue-700);
|
--chart-4: oklch(0.60 0.14 200);
|
||||||
--chart-5: var(--color-blue-800);
|
--chart-5: oklch(0.50 0.10 280);
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.19 0.01 260);
|
||||||
--sidebar-foreground: oklch(0.85 0.005 65);
|
--sidebar-foreground: oklch(0.90 0.005 100);
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
--sidebar-accent: oklch(0.25 0.02 145);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.85 0.06 145);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--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 {
|
@layer base {
|
||||||
@@ -134,24 +136,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@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: <div className="container">...</div>
|
|
||||||
*
|
|
||||||
* For custom widths, use max-w-* utilities directly:
|
|
||||||
* <div className="max-w-6xl mx-auto px-4">...</div>
|
|
||||||
*/
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
padding-left: 1rem; /* 16px - mobile padding */
|
padding-left: 1rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,16 +151,16 @@
|
|||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.container {
|
.container {
|
||||||
padding-left: 1.5rem; /* 24px - tablet padding */
|
padding-left: 1.5rem;
|
||||||
padding-right: 1.5rem;
|
padding-right: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.container {
|
.container {
|
||||||
padding-left: 2rem; /* 32px - desktop padding */
|
padding-left: 2rem;
|
||||||
padding-right: 2rem;
|
padding-right: 2rem;
|
||||||
max-width: 1280px; /* Standard content width */
|
max-width: 1280px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
669
client/src/pages/Analysis.tsx
普通文件
669
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<File | null>(null);
|
||||||
|
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||||
|
const [exerciseType, setExerciseType] = useState("forehand");
|
||||||
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [analysisProgress, setAnalysisProgress] = useState(0);
|
||||||
|
const [analysisResult, setAnalysisResult] = useState<AnalysisResult | null>(null);
|
||||||
|
const [corrections, setCorrections] = useState<string>("");
|
||||||
|
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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<void>((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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">视频姿势分析</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">上传训练视频,AI自动识别姿势并给出矫正建议</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload section */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4 text-primary" />
|
||||||
|
上传训练视频
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">动作类型</label>
|
||||||
|
<Select value={exerciseType} onValueChange={setExerciseType}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="forehand">正手挥拍</SelectItem>
|
||||||
|
<SelectItem value="backhand">反手挥拍</SelectItem>
|
||||||
|
<SelectItem value="serve">发球动作</SelectItem>
|
||||||
|
<SelectItem value="volley">截击</SelectItem>
|
||||||
|
<SelectItem value="footwork">脚步移动</SelectItem>
|
||||||
|
<SelectItem value="shadow">影子挥拍</SelectItem>
|
||||||
|
<SelectItem value="wall">墙壁练习</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">选择视频</label>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="video/mp4,video/webm"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{videoUrl && (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">原始视频</label>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoUrl}
|
||||||
|
className="w-full rounded-lg border bg-black"
|
||||||
|
controls
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">骨骼分析</label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSkeleton(!showSkeleton)}
|
||||||
|
className="text-xs gap-1"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
{showSkeleton ? "隐藏骨骼" : "显示骨骼"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="w-full rounded-lg border bg-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={analyzeVideo}
|
||||||
|
disabled={isAnalyzing}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isAnalyzing ? (
|
||||||
|
<><Loader2 className="h-4 w-4 animate-spin" />分析中 {analysisProgress}%</>
|
||||||
|
) : (
|
||||||
|
<><Play className="h-4 w-4" />开始分析</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isAnalyzing && (
|
||||||
|
<Progress value={analysisProgress} className="flex-1 h-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Analysis results */}
|
||||||
|
{analysisResult && (
|
||||||
|
<>
|
||||||
|
{/* Score overview */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card className="border-0 shadow-sm bg-gradient-to-br from-green-50 to-emerald-50">
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<p className="text-xs text-muted-foreground">综合评分</p>
|
||||||
|
<p className="text-3xl font-bold text-primary">{analysisResult.overallScore}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">/100</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1"><Zap className="h-3 w-3" />击球次数</p>
|
||||||
|
<p className="text-3xl font-bold">{analysisResult.shotCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">次</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1"><Activity className="h-3 w-3" />平均挥拍速度</p>
|
||||||
|
<p className="text-3xl font-bold">{analysisResult.avgSwingSpeed}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">px/帧</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1"><TrendingUp className="h-3 w-3" />移动距离</p>
|
||||||
|
<p className="text-3xl font-bold">{analysisResult.totalMovementDistance}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">px</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dimension scores */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">多维度评分</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={item.label} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span className="font-medium">{Math.round(item.value)}/100</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${item.color}`}
|
||||||
|
style={{ width: `${Math.min(100, item.value)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detected issues */}
|
||||||
|
{analysisResult.detectedIssues.length > 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Target className="h-4 w-4 text-orange-500" />
|
||||||
|
检测到的问题
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{analysisResult.detectedIssues.map((issue, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 p-3 bg-orange-50 rounded-lg">
|
||||||
|
<span className="text-orange-500 mt-0.5 text-sm">⚠️</span>
|
||||||
|
<p className="text-sm">{issue}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key moments */}
|
||||||
|
{analysisResult.keyMoments.length > 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-primary" />
|
||||||
|
关键时刻
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{analysisResult.keyMoments.map((moment: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 p-2 border rounded-lg">
|
||||||
|
<Badge variant="secondary" className="shrink-0">帧 {moment.frame}</Badge>
|
||||||
|
<span className="text-sm">{moment.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Corrections */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-primary" />
|
||||||
|
AI矫正建议
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{correctionMutation.isPending ? (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm">AI正在生成矫正建议...</span>
|
||||||
|
</div>
|
||||||
|
) : corrections ? (
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
<Streamdown>{corrections}</Streamdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无矫正建议</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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<string, number> = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
avg[key] = Math.round(
|
||||||
|
anglesHistory.reduce((sum, a) => sum + (a[key] || 0), 0) / anglesHistory.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return avg;
|
||||||
|
}
|
||||||
279
client/src/pages/Dashboard.tsx
普通文件
279
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 (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${color}`}>
|
||||||
|
NTRP {rating.toFixed(1)} · {level}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: stats, isLoading } = trpc.profile.stats.useQuery();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map(i => <Skeleton key={i} className="h-28" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Welcome header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
欢迎回来,{user?.name || "球友"}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<NTRPBadge rating={stats?.ntrpRating || 1.5} />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
已完成 {stats?.totalSessions || 0} 次训练
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => setLocation("/training")} className="gap-2">
|
||||||
|
<Target className="h-4 w-4" />
|
||||||
|
开始训练
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setLocation("/analysis")} className="gap-2">
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
视频分析
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card className="border-0 shadow-sm bg-gradient-to-br from-green-50 to-emerald-50">
|
||||||
|
<CardContent className="pt-5 pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">NTRP评分</p>
|
||||||
|
<p className="text-2xl font-bold text-primary mt-1">
|
||||||
|
{(stats?.ntrpRating || 1.5).toFixed(1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<Award className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-5 pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">训练次数</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{stats?.totalSessions || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-blue-50 flex items-center justify-center">
|
||||||
|
<Activity className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-5 pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">训练时长</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{stats?.totalMinutes || 0}<span className="text-sm font-normal text-muted-foreground ml-1">分钟</span></p>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-orange-50 flex items-center justify-center">
|
||||||
|
<Clock className="h-5 w-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-5 pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">总击球数</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{stats?.totalShots || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-purple-50 flex items-center justify-center">
|
||||||
|
<Zap className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating trend chart */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
|
NTRP评分趋势
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setLocation("/rating")} className="text-xs gap-1">
|
||||||
|
查看详情 <ChevronRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{ratingData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<AreaChart data={ratingData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ratingGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis domain={[1, 5]} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Area type="monotone" dataKey="rating" stroke="oklch(0.55 0.16 145)" fill="url(#ratingGradient)" strokeWidth={2} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<BarChart3 className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>完成视频分析后将显示评分趋势</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent analyses */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Video className="h-4 w-4 text-primary" />
|
||||||
|
最近分析
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setLocation("/videos")} className="text-xs gap-1">
|
||||||
|
查看全部 <ChevronRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(stats?.recentAnalyses?.length || 0) > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats!.recentAnalyses.slice(0, 4).map((a: any) => (
|
||||||
|
<div key={a.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-primary/5 flex items-center justify-center text-xs font-bold text-primary">
|
||||||
|
{Math.round(a.overallScore || 0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{a.exerciseType || "综合分析"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(a.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
{a.shotCount ? ` · ${a.shotCount}次击球` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={a.overallScore || 0} className="w-16 h-1.5" />
|
||||||
|
<span className="text-xs text-muted-foreground">{Math.round(a.overallScore || 0)}分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<Video className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>上传训练视频开始AI分析</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base font-semibold">快速开始</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setLocation("/training")}
|
||||||
|
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-green-100 flex items-center justify-center shrink-0">
|
||||||
|
<Target className="h-5 w-5 text-green-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">生成训练计划</p>
|
||||||
|
<p className="text-xs text-muted-foreground">AI定制个人训练方案</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLocation("/analysis")}
|
||||||
|
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-blue-100 flex items-center justify-center shrink-0">
|
||||||
|
<Video className="h-5 w-5 text-blue-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">上传视频分析</p>
|
||||||
|
<p className="text-xs text-muted-foreground">MediaPipe AI姿势识别</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLocation("/rating")}
|
||||||
|
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-purple-100 flex items-center justify-center shrink-0">
|
||||||
|
<Award className="h-5 w-5 text-purple-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">查看NTRP评分</p>
|
||||||
|
<p className="text-xs text-muted-foreground">多维度能力评估</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,150 @@
|
|||||||
import { useAuth } from "@/_core/hooks/useAuth";
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2 } from "lucide-react";
|
import { useLocation, Redirect } from "wouter";
|
||||||
import { getLoginUrl } from "@/const";
|
import {
|
||||||
import { Streamdown } from 'streamdown';
|
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() {
|
export default function Home() {
|
||||||
// The userAuth hooks provides authentication state
|
const { user, loading, isAuthenticated } = useAuth();
|
||||||
// To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()
|
const [, setLocation] = useLocation();
|
||||||
let { user, loading, error, isAuthenticated, logout } = useAuth();
|
|
||||||
|
|
||||||
// If theme is switchable in App.tsx, we can implement theme toggling like this:
|
if (loading) return null;
|
||||||
// const { theme, toggleTheme } = useTheme();
|
if (isAuthenticated) return <Redirect to="/dashboard" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen bg-gradient-to-b from-green-50 via-background to-emerald-50/30">
|
||||||
<main>
|
{/* Hero */}
|
||||||
{/* Example: lucide-react for icons */}
|
<header className="container py-6 flex items-center justify-between">
|
||||||
<Loader2 className="animate-spin" />
|
<div className="flex items-center gap-2">
|
||||||
Example Page
|
<Target className="h-6 w-6 text-primary" />
|
||||||
{/* Example: Streamdown for markdown rendering */}
|
<span className="font-bold text-lg tracking-tight">Tennis Training Hub</span>
|
||||||
<Streamdown>Any **markdown** content</Streamdown>
|
</div>
|
||||||
<Button variant="default">Example Button</Button>
|
<Button onClick={() => setLocation("/login")} variant="default" size="sm">
|
||||||
</main>
|
开始使用
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="container py-16 md:py-24">
|
||||||
|
<div className="max-w-3xl mx-auto text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium mb-6">
|
||||||
|
<Zap className="h-3.5 w-3.5" />
|
||||||
|
AI驱动的网球训练助手
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
|
||||||
|
在家也能提升
|
||||||
|
<span className="text-primary block mt-1">网球技术水平</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-muted-foreground mt-6 max-w-xl mx-auto leading-relaxed">
|
||||||
|
只需一支球拍,通过AI姿势识别和智能训练计划,在家高效训练。
|
||||||
|
实时分析挥拍动作,自动评分,持续进步。
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-3 mt-8">
|
||||||
|
<Button onClick={() => setLocation("/login")} size="lg" className="gap-2 h-12 px-6">
|
||||||
|
免费开始训练
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section className="container py-16">
|
||||||
|
<h2 className="text-2xl font-bold text-center mb-12">核心功能</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<div key={feature.title} className="p-6 rounded-2xl border bg-card hover:shadow-md transition-shadow">
|
||||||
|
<div className={`h-12 w-12 rounded-xl ${feature.color} flex items-center justify-center mb-4`}>
|
||||||
|
<feature.icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-base mb-2">{feature.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">{feature.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* How it works */}
|
||||||
|
<section className="container py-16">
|
||||||
|
<h2 className="text-2xl font-bold text-center mb-12">使用流程</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 max-w-4xl mx-auto">
|
||||||
|
{[
|
||||||
|
{ step: "1", title: "输入用户名", desc: "无需注册,输入用户名即可开始" },
|
||||||
|
{ step: "2", title: "生成训练计划", desc: "选择水平,AI生成个性化方案" },
|
||||||
|
{ step: "3", title: "上传训练视频", desc: "录制挥拍视频并上传分析" },
|
||||||
|
{ step: "4", title: "获取评分建议", desc: "查看分析结果和矫正建议" },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.step} className="text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-lg font-bold mx-auto mb-3">
|
||||||
|
{item.step}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-sm mb-1">{item.title}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="container py-16">
|
||||||
|
<div className="max-w-2xl mx-auto text-center p-8 rounded-2xl bg-primary/5">
|
||||||
|
<h2 className="text-2xl font-bold mb-3">准备好提升网球技术了吗?</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">完全免费,无需注册,输入用户名即可开始</p>
|
||||||
|
<Button onClick={() => setLocation("/login")} size="lg" className="gap-2">
|
||||||
|
立即开始
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="container py-8 border-t">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target className="h-4 w-4" />
|
||||||
|
<span>Tennis Training Hub</span>
|
||||||
|
</div>
|
||||||
|
<span>AI驱动的在家网球训练助手</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
102
client/src/pages/Login.tsx
普通文件
102
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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 via-background to-emerald-50 p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
|
||||||
|
<Target className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Tennis Training Hub</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">AI驱动的在家网球训练助手</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-0 shadow-xl">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<CardTitle className="text-xl">开始训练</CardTitle>
|
||||||
|
<CardDescription>输入用户名即可开始使用</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入您的用户名"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="h-12 text-base"
|
||||||
|
autoFocus
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full h-12 text-base font-medium"
|
||||||
|
disabled={loginMutation.isPending || !username.trim()}
|
||||||
|
>
|
||||||
|
{loginMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
登录中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"进入训练"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t">
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center text-xs text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary/5 flex items-center justify-center text-primary font-bold text-sm">AI</div>
|
||||||
|
<span>姿势识别</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary/5 flex items-center justify-center text-primary font-bold text-sm">📊</div>
|
||||||
|
<span>训练计划</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary/5 flex items-center justify-center text-primary font-bold text-sm">🎯</div>
|
||||||
|
<span>NTRP评分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-6">
|
||||||
|
无需注册,输入用户名即可使用全部功能
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
client/src/pages/Progress.tsx
普通文件
215
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate data by date for charts
|
||||||
|
const dateMap = new Map<string, { date: string; sessions: number; minutes: number; avgScore: number; scores: number[] }>();
|
||||||
|
(records || []).forEach((r: any) => {
|
||||||
|
const date = new Date(r.trainingDate || r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
||||||
|
const 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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">训练进度</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">追踪您的训练历史和能力提升趋势</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||||
|
<Activity className="h-3 w-3" />总训练次数
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{stats?.totalSessions || 0}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||||
|
<Clock className="h-3 w-3" />总训练时长
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{totalMinutes}<span className="text-sm font-normal ml-1">分钟</span></p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />已完成
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{completedRecords.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||||
|
<Target className="h-3 w-3" />视频分析
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{analyses?.length || 0}<span className="text-sm font-normal ml-1">次</span></p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Training frequency chart */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-primary" />
|
||||||
|
训练频率
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="sessions" fill="oklch(0.55 0.16 145)" radius={[4, 4, 0, 0]} name="训练次数" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[220px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<Calendar className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>开始训练后将显示频率统计</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Score improvement trend */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
|
能力提升趋势
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{scoreTrend.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={scoreTrend}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="overall" stroke="oklch(0.55 0.16 145)" strokeWidth={2} name="综合" dot={{ r: 3 }} />
|
||||||
|
<Line type="monotone" dataKey="consistency" stroke="#3b82f6" strokeWidth={1.5} name="一致性" dot={{ r: 2 }} />
|
||||||
|
<Line type="monotone" dataKey="footwork" stroke="#f59e0b" strokeWidth={1.5} name="脚步" dot={{ r: 2 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[220px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUp className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>完成视频分析后将显示能力趋势</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent records */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">最近训练记录</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(records?.length || 0) > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(records || []).slice(0, 20).map((record: any) => (
|
||||||
|
<div key={record.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`h-8 w-8 rounded-lg flex items-center justify-center ${
|
||||||
|
record.completed ? "bg-green-50 text-green-600" : "bg-muted text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{record.completed ? <CheckCircle2 className="h-4 w-4" /> : <Activity className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{record.exerciseName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(record.trainingDate || record.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
{record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{record.poseScore && (
|
||||||
|
<Badge variant="secondary" className="text-xs">{Math.round(record.poseScore)}分</Badge>
|
||||||
|
)}
|
||||||
|
{record.completed ? (
|
||||||
|
<Badge className="bg-green-100 text-green-700 text-xs">已完成</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">进行中</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
<Activity className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>还没有训练记录</p>
|
||||||
|
<Button variant="link" size="sm" onClick={() => setLocation("/training")} className="mt-2">
|
||||||
|
开始第一次训练
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
client/src/pages/Rating.tsx
普通文件
230
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
<Skeleton className="h-60 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">NTRP评分系统</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">基于所有历史训练记录自动计算的综合评分</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current rating card */}
|
||||||
|
<Card className="border-0 shadow-sm overflow-hidden">
|
||||||
|
<div className="bg-gradient-to-r from-primary/10 via-primary/5 to-transparent p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-20 w-20 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<span className="text-3xl font-bold text-primary">{currentRating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">{level.label}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 max-w-md">{level.desc}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Award className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium">NTRP {currentRating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Radar chart */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Target className="h-4 w-4 text-primary" />
|
||||||
|
能力雷达图
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>五维度综合能力评估</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{Object.keys(dimensions).length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<RadarChart data={radarData}>
|
||||||
|
<PolarGrid stroke="#e5e7eb" />
|
||||||
|
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 12 }} />
|
||||||
|
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 10 }} />
|
||||||
|
<Radar
|
||||||
|
name="能力值"
|
||||||
|
dataKey="value"
|
||||||
|
stroke="oklch(0.55 0.16 145)"
|
||||||
|
fill="oklch(0.55 0.16 145)"
|
||||||
|
fillOpacity={0.3}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</RadarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<Target className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>完成视频分析后将显示能力雷达图</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Rating trend */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
|
评分变化趋势
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>NTRP评分随时间的变化</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{trendData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<AreaChart data={trendData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ratingGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis domain={[1, 5]} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Area type="monotone" dataKey="rating" stroke="oklch(0.55 0.16 145)" fill="url(#ratingGrad)" strokeWidth={2} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUp className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>完成视频分析后将显示评分趋势</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dimension details */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">评分维度说明</CardTitle>
|
||||||
|
<CardDescription>NTRP评分由以下五个维度加权计算</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={item.label} className="p-4 rounded-xl border bg-card">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<item.icon className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{item.value ? Math.round(item.value) : "--"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">权重 {item.weight}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* NTRP level reference */}
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">NTRP等级参考</CardTitle>
|
||||||
|
<CardDescription>美国网球协会(USTA)标准评级体系</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{NTRP_LEVELS.map(l => (
|
||||||
|
<div
|
||||||
|
key={l.label}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
|
||||||
|
currentRating >= l.min && currentRating < l.max
|
||||||
|
? "bg-primary/5 border border-primary/20"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Badge className={`${l.color} border shrink-0`}>
|
||||||
|
{l.min.toFixed(1)}-{l.max.toFixed(1)}
|
||||||
|
</Badge>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{l.label}</span>
|
||||||
|
<p className="text-xs text-muted-foreground">{l.desc}</p>
|
||||||
|
</div>
|
||||||
|
{currentRating >= l.min && currentRating < l.max && (
|
||||||
|
<Badge variant="default" className="ml-auto shrink-0">当前等级</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
client/src/pages/Training.tsx
普通文件
287
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<string, React.ReactNode> = {
|
||||||
|
"影子挥拍": <Hand className="h-4 w-4" />,
|
||||||
|
"脚步移动": <Footprints className="h-4 w-4" />,
|
||||||
|
"体能训练": <Dumbbell className="h-4 w-4" />,
|
||||||
|
"墙壁练习": <Target className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
"影子挥拍": "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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
<Skeleton className="h-60 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">训练计划</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">AI为您定制的在家网球训练方案</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!activePlan ? (
|
||||||
|
/* Generate new plan */
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
生成训练计划
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
根据您的水平和目标,AI将生成个性化的在家训练方案(只需球拍)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">技能水平</label>
|
||||||
|
<Select value={skillLevel} onValueChange={(v: any) => setSkillLevel(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="beginner">初级 - 刚开始学习网球</SelectItem>
|
||||||
|
<SelectItem value="intermediate">中级 - 有一定基础</SelectItem>
|
||||||
|
<SelectItem value="advanced">高级 - 有丰富经验</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">训练周期</label>
|
||||||
|
<Select value={String(durationDays)} onValueChange={(v) => setDurationDays(Number(v))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="3">3天计划</SelectItem>
|
||||||
|
<SelectItem value="7">7天计划</SelectItem>
|
||||||
|
<SelectItem value="14">14天计划</SelectItem>
|
||||||
|
<SelectItem value="30">30天计划</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => generateMutation.mutate({ skillLevel, durationDays })}
|
||||||
|
disabled={generateMutation.isPending}
|
||||||
|
className="w-full sm:w-auto gap-2"
|
||||||
|
>
|
||||||
|
{generateMutation.isPending ? (
|
||||||
|
<><Loader2 className="h-4 w-4 animate-spin" />AI生成中...</>
|
||||||
|
) : (
|
||||||
|
<><Sparkles className="h-4 w-4" />生成训练计划</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
/* Active plan display */
|
||||||
|
<>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{activePlan.title}</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{activePlan.skillLevel === "beginner" ? "初级" : activePlan.skillLevel === "intermediate" ? "中级" : "高级"}
|
||||||
|
</Badge>
|
||||||
|
<span>{activePlan.durationDays}天计划</span>
|
||||||
|
{activePlan.version > 1 && (
|
||||||
|
<Badge variant="outline" className="text-xs">v{activePlan.version} 已调整</Badge>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => adjustMutation.mutate({ planId: activePlan.id })}
|
||||||
|
disabled={adjustMutation.isPending}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{adjustMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
智能调整
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{activePlan.adjustmentNotes && (
|
||||||
|
<div className="mt-3 p-3 bg-primary/5 rounded-lg text-sm text-primary">
|
||||||
|
<strong>调整说明:</strong>{activePlan.adjustmentNotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Day selector */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 mb-4">
|
||||||
|
{Array.from({ length: totalDays }, (_, i) => i + 1).map(day => (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
onClick={() => 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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-semibold mb-3">第 {selectedDay} 天训练</h3>
|
||||||
|
|
||||||
|
{exercises.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{exercises.map((ex, idx) => (
|
||||||
|
<div key={idx} className="border rounded-xl p-4 hover:shadow-sm transition-shadow">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`h-10 w-10 rounded-xl flex items-center justify-center shrink-0 ${
|
||||||
|
categoryColors[ex.category] || "bg-gray-50 text-gray-700"
|
||||||
|
}`}>
|
||||||
|
{categoryIcons[ex.category] || <Target className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm">{ex.name}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{ex.description}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />{ex.duration}分钟
|
||||||
|
</span>
|
||||||
|
<span>{ex.sets}组 × {ex.reps}次</span>
|
||||||
|
</div>
|
||||||
|
{ex.tips && (
|
||||||
|
<p className="text-xs text-primary mt-2 bg-primary/5 rounded-md px-2 py-1">
|
||||||
|
💡 {ex.tips}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
recordMutation.mutate({
|
||||||
|
planId: activePlan.id,
|
||||||
|
exerciseName: ex.name,
|
||||||
|
durationMinutes: ex.duration,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
<p>该天暂无训练安排</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
generateMutation.mutate({ skillLevel, durationDays });
|
||||||
|
}}
|
||||||
|
disabled={generateMutation.isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
重新生成计划
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
client/src/pages/Videos.tsx
普通文件
150
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<string, { label: string; color: string }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">训练视频库</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
|
管理您的所有训练视频及分析结果 · 共 {videos?.length || 0} 个视频
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setLocation("/analysis")} className="gap-2">
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
上传新视频
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(!videos || videos.length === 0) ? (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="py-16 text-center">
|
||||||
|
<FileVideo className="h-12 w-12 mx-auto mb-4 text-muted-foreground/30" />
|
||||||
|
<h3 className="font-semibold text-lg mb-2">还没有训练视频</h3>
|
||||||
|
<p className="text-muted-foreground text-sm mb-4">上传您的训练视频,AI将自动分析姿势并给出建议</p>
|
||||||
|
<Button onClick={() => setLocation("/analysis")} className="gap-2">
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
上传第一个视频
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{videos.map((video: any) => {
|
||||||
|
const analysis = getAnalysis(video.id);
|
||||||
|
const status = statusMap[video.analysisStatus] || statusMap.pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={video.id} className="border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Thumbnail / icon */}
|
||||||
|
<div className="h-20 w-28 rounded-lg bg-black/5 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
{video.url ? (
|
||||||
|
<video src={video.url} className="h-full w-full object-cover" muted preload="metadata" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-6 w-6 text-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-sm truncate">{video.title}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
<Badge className={`${status.color} border text-xs`}>{status.label}</Badge>
|
||||||
|
{video.exerciseType && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{exerciseTypeMap[video.exerciseType] || video.exerciseType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(video.createdAt).toLocaleDateString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{(video.fileSize / 1024 / 1024).toFixed(1)}MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis summary */}
|
||||||
|
{analysis && (
|
||||||
|
<div className="flex items-center gap-4 mt-3 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<BarChart3 className="h-3 w-3 text-primary" />
|
||||||
|
<span className="font-medium">{Math.round(analysis.overallScore || 0)}分</span>
|
||||||
|
</div>
|
||||||
|
{(analysis.shotCount ?? 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Zap className="h-3 w-3 text-orange-500" />
|
||||||
|
<span>{analysis.shotCount}次击球</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(analysis.avgSwingSpeed ?? 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
速度 {(analysis.avgSwingSpeed ?? 0).toFixed(1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(analysis.strokeConsistency ?? 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
一致性 {Math.round(analysis.strokeConsistency ?? 0)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
drizzle/0000_absurd_ink.sql
普通文件
13
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`)
|
||||||
|
);
|
||||||
70
drizzle/0001_public_prowler.sql
普通文件
70
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;
|
||||||
@@ -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;
|
||||||
110
drizzle/meta/0000_snapshot.json
普通文件
110
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
561
drizzle/meta/0001_snapshot.json
普通文件
561
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
716
drizzle/meta/0002_snapshot.json
普通文件
716
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,27 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "mysql",
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -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.
|
* Core user table - supports both OAuth and simple username login
|
||||||
* Extend this file with additional tables as your product grows.
|
|
||||||
* Columns use camelCase to match both database fields and generated types.
|
|
||||||
*/
|
*/
|
||||||
export const users = mysqlTable("users", {
|
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(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
/** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
|
|
||||||
openId: varchar("openId", { length: 64 }).notNull().unique(),
|
openId: varchar("openId", { length: 64 }).notNull().unique(),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
email: varchar("email", { length: 320 }),
|
email: varchar("email", { length: 320 }),
|
||||||
loginMethod: varchar("loginMethod", { length: 64 }),
|
loginMethod: varchar("loginMethod", { length: 64 }),
|
||||||
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
|
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(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
||||||
@@ -25,4 +28,156 @@ export const users = mysqlTable("users", {
|
|||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type InsertUser = typeof users.$inferInsert;
|
export type InsertUser = typeof users.$inferInsert;
|
||||||
|
|
||||||
// TODO: Add your tables here
|
/**
|
||||||
|
* 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;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"@aws-sdk/client-s3": "^3.693.0",
|
"@aws-sdk/client-s3": "^3.693.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.693.0",
|
"@aws-sdk/s3-request-presigner": "^3.693.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@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-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
|||||||
16
pnpm-lock.yaml
自动生成的
16
pnpm-lock.yaml
自动生成的
@@ -25,6 +25,12 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
|
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':
|
'@radix-ui/react-accordion':
|
||||||
specifier: ^1.2.12
|
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)
|
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':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
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':
|
'@medv/finder@4.0.2':
|
||||||
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
||||||
|
|
||||||
@@ -5247,6 +5259,10 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@mediapipe/drawing_utils@0.3.1675466124': {}
|
||||||
|
|
||||||
|
'@mediapipe/pose@0.5.1675469404': {}
|
||||||
|
|
||||||
'@medv/finder@4.0.2': {}
|
'@medv/finder@4.0.2': {}
|
||||||
|
|
||||||
'@mermaid-js/parser@0.6.3':
|
'@mermaid-js/parser@0.6.3':
|
||||||
|
|||||||
272
server/db.ts
272
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 { 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';
|
import { ENV } from './_core/env';
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
// Lazily create the drizzle instance so local tooling can run without a DB.
|
|
||||||
export async function getDb() {
|
export async function getDb() {
|
||||||
if (!_db && process.env.DATABASE_URL) {
|
if (!_db && process.env.DATABASE_URL) {
|
||||||
try {
|
try {
|
||||||
@@ -18,26 +25,19 @@ export async function getDb() {
|
|||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertUser(user: InsertUser): Promise<void> {
|
// ===== USER OPERATIONS =====
|
||||||
if (!user.openId) {
|
|
||||||
throw new Error("User openId is required for upsert");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export async function upsertUser(user: InsertUser): Promise<void> {
|
||||||
|
if (!user.openId) throw new Error("User openId is required for upsert");
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) {
|
if (!db) { console.warn("[Database] Cannot upsert user: database not available"); return; }
|
||||||
console.warn("[Database] Cannot upsert user: database not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const values: InsertUser = {
|
const values: InsertUser = { openId: user.openId };
|
||||||
openId: user.openId,
|
|
||||||
};
|
|
||||||
const updateSet: Record<string, unknown> = {};
|
const updateSet: Record<string, unknown> = {};
|
||||||
|
|
||||||
const textFields = ["name", "email", "loginMethod"] as const;
|
const textFields = ["name", "email", "loginMethod"] as const;
|
||||||
type TextField = (typeof textFields)[number];
|
type TextField = (typeof textFields)[number];
|
||||||
|
|
||||||
const assignNullable = (field: TextField) => {
|
const assignNullable = (field: TextField) => {
|
||||||
const value = user[field];
|
const value = user[field];
|
||||||
if (value === undefined) return;
|
if (value === undefined) return;
|
||||||
@@ -45,48 +45,222 @@ export async function upsertUser(user: InsertUser): Promise<void> {
|
|||||||
values[field] = normalized;
|
values[field] = normalized;
|
||||||
updateSet[field] = normalized;
|
updateSet[field] = normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
textFields.forEach(assignNullable);
|
textFields.forEach(assignNullable);
|
||||||
|
|
||||||
if (user.lastSignedIn !== undefined) {
|
if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; }
|
||||||
values.lastSignedIn = user.lastSignedIn;
|
if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; }
|
||||||
updateSet.lastSignedIn = user.lastSignedIn;
|
else if (user.openId === ENV.ownerOpenId) { values.role = 'admin'; updateSet.role = 'admin'; }
|
||||||
}
|
if (!values.lastSignedIn) values.lastSignedIn = new Date();
|
||||||
if (user.role !== undefined) {
|
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
|
||||||
values.role = user.role;
|
|
||||||
updateSet.role = user.role;
|
|
||||||
} else if (user.openId === ENV.ownerOpenId) {
|
|
||||||
values.role = 'admin';
|
|
||||||
updateSet.role = 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!values.lastSignedIn) {
|
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
|
||||||
values.lastSignedIn = new Date();
|
} catch (error) { console.error("[Database] Failed to upsert user:", error); throw error; }
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserByOpenId(openId: string) {
|
export async function getUserByOpenId(openId: string) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) {
|
if (!db) return undefined;
|
||||||
console.warn("[Database] Cannot get user: database not available");
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
||||||
|
|
||||||
return result.length > 0 ? result[0] : undefined;
|
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<InsertTrainingPlan>) {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
237
server/features.test.ts
普通文件
237
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<TrpcContext["user"]>;
|
||||||
|
|
||||||
|
function createTestUser(overrides?: Partial<AuthenticatedUser>): 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<string, unknown> }[];
|
||||||
|
setCookies: { name: string; value: string; options: Record<string, unknown> }[];
|
||||||
|
} {
|
||||||
|
const clearedCookies: { name: string; options: Record<string, unknown> }[] = [];
|
||||||
|
const setCookies: { name: string; value: string; options: Record<string, unknown> }[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx: {
|
||||||
|
user,
|
||||||
|
req: {
|
||||||
|
protocol: "https",
|
||||||
|
headers: {},
|
||||||
|
} as TrpcContext["req"],
|
||||||
|
res: {
|
||||||
|
clearCookie: (name: string, options: Record<string, unknown>) => {
|
||||||
|
clearedCookies.push({ name, options });
|
||||||
|
},
|
||||||
|
cookie: (name: string, value: string, options: Record<string, unknown>) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { getSessionCookieOptions } from "./_core/cookies";
|
||||||
import { systemRouter } from "./_core/systemRouter";
|
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({
|
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,
|
system: systemRouter,
|
||||||
|
|
||||||
auth: router({
|
auth: router({
|
||||||
me: publicProcedure.query(opts => opts.ctx.user),
|
me: publicProcedure.query(opts => opts.ctx.user),
|
||||||
logout: publicProcedure.mutation(({ ctx }) => {
|
logout: publicProcedure.mutation(({ ctx }) => {
|
||||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||||
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
||||||
return {
|
return { success: true } as const;
|
||||||
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 };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// TODO: add feature routers here, e.g.
|
// User profile management
|
||||||
// todo: router({
|
profile: router({
|
||||||
// list: protectedProcedure.query(({ ctx }) =>
|
update: protectedProcedure
|
||||||
// db.getUserTodos(ctx.user.id)
|
.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);
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 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;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
28
todo.md
普通文件
28
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仓库
|
||||||
在新工单中引用
屏蔽一个用户