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,17 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
||||
<title>Tennis Training Hub - AI网球训练助手</title>
|
||||
<!-- THIS IS THE START OF A COMMENT BLOCK, BLOCK TO BE DELETED: Google Fonts here, example:
|
||||
<title>Tennis Training Hub - AI网球训练助手</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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" />
|
||||
THIS IS THE END OF A COMMENT BLOCK, BLOCK TO BE DELETED -->
|
||||
<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" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -4,32 +4,57 @@ import NotFound from "@/pages/NotFound";
|
||||
import { Route, Switch } from "wouter";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import DashboardLayout from "./components/DashboardLayout";
|
||||
import Home from "./pages/Home";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Training from "./pages/Training";
|
||||
import Analysis from "./pages/Analysis";
|
||||
import Videos from "./pages/Videos";
|
||||
import Progress from "./pages/Progress";
|
||||
import Rating from "./pages/Rating";
|
||||
|
||||
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Component />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Router() {
|
||||
// make sure to consider if you need authentication for certain routes
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={"/"} component={Home} />
|
||||
<Route path={"/404"} component={NotFound} />
|
||||
{/* Final fallback route */}
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/login" component={Login} />
|
||||
<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} />
|
||||
</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() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider
|
||||
defaultTheme="light"
|
||||
// switchable
|
||||
>
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
|
||||
@@ -19,23 +19,28 @@ import {
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
|
||||
import {
|
||||
LayoutDashboard, LogOut, PanelLeft, Target, Video,
|
||||
Award, Activity, FileVideo
|
||||
} from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLocation, Redirect } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
|
||||
{ icon: Users, label: "Page 2", path: "/some-path" },
|
||||
{ icon: LayoutDashboard, label: "仪表盘", path: "/dashboard" },
|
||||
{ icon: Target, label: "训练计划", path: "/training" },
|
||||
{ icon: Video, label: "视频分析", path: "/analysis" },
|
||||
{ icon: FileVideo, label: "视频库", path: "/videos" },
|
||||
{ icon: Activity, label: "训练进度", path: "/progress" },
|
||||
{ icon: Award, label: "NTRP评分", path: "/rating" },
|
||||
];
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||
const DEFAULT_WIDTH = 280;
|
||||
const DEFAULT_WIDTH = 260;
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 480;
|
||||
const MAX_WIDTH = 400;
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -57,29 +62,7 @@ export default function DashboardLayout({
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<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 <Redirect to="/login" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -124,7 +107,6 @@ function DashboardLayoutContent({
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
||||
const newWidth = e.clientX - sidebarLeft;
|
||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||
@@ -170,8 +152,9 @@ function DashboardLayoutContent({
|
||||
</button>
|
||||
{!isCollapsed ? (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-semibold tracking-tight truncate">
|
||||
Navigation
|
||||
<Target className="h-5 w-5 text-primary shrink-0" />
|
||||
<span className="font-semibold tracking-tight truncate text-sm">
|
||||
Tennis Hub
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -206,16 +189,16 @@ function DashboardLayoutContent({
|
||||
<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">
|
||||
<Avatar className="h-9 w-9 border shrink-0">
|
||||
<AvatarFallback className="text-xs font-medium">
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
<AvatarFallback className="text-xs font-medium bg-primary/10 text-primary">
|
||||
{user?.name?.charAt(0).toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
||||
<p className="text-sm font-medium truncate leading-none">
|
||||
{user?.name || "-"}
|
||||
{user?.name || "用户"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
||||
{user?.email || "-"}
|
||||
{user?.email || "网球训练中"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -226,7 +209,7 @@ function DashboardLayoutContent({
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
<span>退出登录</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -249,15 +232,15 @@ function DashboardLayoutContent({
|
||||
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="tracking-tight text-foreground">
|
||||
{activeMenuItem?.label ?? "Menu"}
|
||||
<span className="tracking-tight text-foreground text-sm">
|
||||
{activeMenuItem?.label ?? "Tennis Hub"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<main className="flex-1 p-4">{children}</main>
|
||||
<main className="flex-1 p-4 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,77 +40,79 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-sans: 'Inter', 'Noto Sans SC', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: var(--color-blue-700);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
--sidebar-primary: var(--color-blue-600);
|
||||
--sidebar-primary-foreground: var(--color-blue-50);
|
||||
--chart-1: var(--color-blue-300);
|
||||
--chart-2: var(--color-blue-500);
|
||||
--chart-3: var(--color-blue-600);
|
||||
--chart-4: var(--color-blue-700);
|
||||
--chart-5: var(--color-blue-800);
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.235 0.015 65);
|
||||
/* Tennis green theme */
|
||||
--primary: oklch(0.55 0.16 145);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-primary: oklch(0.50 0.15 145);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--chart-1: oklch(0.65 0.18 145);
|
||||
--chart-2: oklch(0.55 0.16 145);
|
||||
--chart-3: oklch(0.72 0.12 80);
|
||||
--chart-4: oklch(0.60 0.14 200);
|
||||
--chart-5: oklch(0.50 0.10 280);
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.985 0.002 100);
|
||||
--foreground: oklch(0.18 0.02 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.235 0.015 65);
|
||||
--card-foreground: oklch(0.18 0.02 260);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.235 0.015 65);
|
||||
--secondary: oklch(0.98 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.4 0.015 65);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover-foreground: oklch(0.18 0.02 260);
|
||||
--secondary: oklch(0.96 0.01 145);
|
||||
--secondary-foreground: oklch(0.35 0.08 145);
|
||||
--muted: oklch(0.96 0.005 260);
|
||||
--muted-foreground: oklch(0.50 0.02 260);
|
||||
--accent: oklch(0.95 0.02 145);
|
||||
--accent-foreground: oklch(0.25 0.06 145);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.623 0.214 259.815);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.235 0.015 65);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||
--border: oklch(0.91 0.005 145);
|
||||
--input: oklch(0.91 0.005 145);
|
||||
--ring: oklch(0.55 0.16 145);
|
||||
--sidebar: oklch(0.98 0.005 145);
|
||||
--sidebar-foreground: oklch(0.18 0.02 260);
|
||||
--sidebar-accent: oklch(0.94 0.02 145);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.06 145);
|
||||
--sidebar-border: oklch(0.91 0.005 145);
|
||||
--sidebar-ring: oklch(0.55 0.16 145);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--primary: var(--color-blue-700);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
--sidebar-primary: var(--color-blue-500);
|
||||
--sidebar-primary-foreground: var(--color-blue-50);
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.85 0.005 65);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.85 0.005 65);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.85 0.005 65);
|
||||
--secondary: oklch(0.24 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.7 0.005 65);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.92 0.005 65);
|
||||
--primary: oklch(0.65 0.18 145);
|
||||
--primary-foreground: oklch(0.12 0.02 145);
|
||||
--sidebar-primary: oklch(0.60 0.16 145);
|
||||
--sidebar-primary-foreground: oklch(0.12 0.02 145);
|
||||
--background: oklch(0.14 0.01 260);
|
||||
--foreground: oklch(0.90 0.005 100);
|
||||
--card: oklch(0.19 0.01 260);
|
||||
--card-foreground: oklch(0.90 0.005 100);
|
||||
--popover: oklch(0.19 0.01 260);
|
||||
--popover-foreground: oklch(0.90 0.005 100);
|
||||
--secondary: oklch(0.22 0.015 145);
|
||||
--secondary-foreground: oklch(0.75 0.08 145);
|
||||
--muted: oklch(0.25 0.01 260);
|
||||
--muted-foreground: oklch(0.65 0.015 260);
|
||||
--accent: oklch(0.25 0.02 145);
|
||||
--accent-foreground: oklch(0.85 0.06 145);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: var(--color-blue-300);
|
||||
--chart-2: var(--color-blue-500);
|
||||
--chart-3: var(--color-blue-600);
|
||||
--chart-4: var(--color-blue-700);
|
||||
--chart-5: var(--color-blue-800);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.85 0.005 65);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.65 0.18 145);
|
||||
--chart-1: oklch(0.65 0.18 145);
|
||||
--chart-2: oklch(0.55 0.16 145);
|
||||
--chart-3: oklch(0.72 0.12 80);
|
||||
--chart-4: oklch(0.60 0.14 200);
|
||||
--chart-5: oklch(0.50 0.10 280);
|
||||
--sidebar: oklch(0.19 0.01 260);
|
||||
--sidebar-foreground: oklch(0.90 0.005 100);
|
||||
--sidebar-accent: oklch(0.25 0.02 145);
|
||||
--sidebar-accent-foreground: oklch(0.85 0.06 145);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
--sidebar-ring: oklch(0.65 0.18 145);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -134,24 +136,11 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/**
|
||||
* Custom container utility that centers content and adds responsive padding.
|
||||
*
|
||||
* This overrides Tailwind's default container behavior to:
|
||||
* - Auto-center content (mx-auto)
|
||||
* - Add responsive horizontal padding
|
||||
* - Set max-width for large screens
|
||||
*
|
||||
* Usage: <div className="container">...</div>
|
||||
*
|
||||
* For custom widths, use max-w-* utilities directly:
|
||||
* <div className="max-w-6xl mx-auto px-4">...</div>
|
||||
*/
|
||||
.container {
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1rem; /* 16px - mobile padding */
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@@ -162,16 +151,16 @@
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
padding-left: 1.5rem; /* 24px - tablet padding */
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
padding-left: 2rem; /* 32px - desktop padding */
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
max-width: 1280px; /* Standard content width */
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { getLoginUrl } from "@/const";
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { useLocation, Redirect } from "wouter";
|
||||
import {
|
||||
Target, Video, Award, TrendingUp, Zap, Activity,
|
||||
ChevronRight, Footprints, BarChart3
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* All content in this page are only for example, replace with your own feature implementation
|
||||
* When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls
|
||||
*/
|
||||
export default function Home() {
|
||||
// The userAuth hooks provides authentication state
|
||||
// To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()
|
||||
let { user, loading, error, isAuthenticated, logout } = useAuth();
|
||||
const { user, loading, isAuthenticated } = useAuth();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// If theme is switchable in App.tsx, we can implement theme toggling like this:
|
||||
// const { theme, toggleTheme } = useTheme();
|
||||
if (loading) return null;
|
||||
if (isAuthenticated) return <Redirect to="/dashboard" />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<main>
|
||||
{/* Example: lucide-react for icons */}
|
||||
<Loader2 className="animate-spin" />
|
||||
Example Page
|
||||
{/* Example: Streamdown for markdown rendering */}
|
||||
<Streamdown>Any **markdown** content</Streamdown>
|
||||
<Button variant="default">Example Button</Button>
|
||||
</main>
|
||||
<div className="min-h-screen bg-gradient-to-b from-green-50 via-background to-emerald-50/30">
|
||||
{/* Hero */}
|
||||
<header className="container py-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-6 w-6 text-primary" />
|
||||
<span className="font-bold text-lg tracking-tight">Tennis Training Hub</span>
|
||||
</div>
|
||||
<Button onClick={() => setLocation("/login")} variant="default" size="sm">
|
||||
开始使用
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
"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.
|
||||
* Extend this file with additional tables as your product grows.
|
||||
* Columns use camelCase to match both database fields and generated types.
|
||||
* Core user table - supports both OAuth and simple username login
|
||||
*/
|
||||
export const users = mysqlTable("users", {
|
||||
/**
|
||||
* Surrogate primary key. Auto-incremented numeric value managed by the database.
|
||||
* Use this for relations between tables.
|
||||
*/
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
/** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
|
||||
openId: varchar("openId", { length: 64 }).notNull().unique(),
|
||||
name: text("name"),
|
||||
email: varchar("email", { length: 320 }),
|
||||
loginMethod: varchar("loginMethod", { length: 64 }),
|
||||
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
|
||||
/** Tennis skill level */
|
||||
skillLevel: mysqlEnum("skillLevel", ["beginner", "intermediate", "advanced"]).default("beginner"),
|
||||
/** User's training goals */
|
||||
trainingGoals: text("trainingGoals"),
|
||||
/** NTRP rating (1.0 - 5.0) */
|
||||
ntrpRating: float("ntrpRating").default(1.5),
|
||||
/** Total training sessions completed */
|
||||
totalSessions: int("totalSessions").default(0),
|
||||
/** Total training minutes */
|
||||
totalMinutes: int("totalMinutes").default(0),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
||||
@@ -25,4 +28,156 @@ export const users = mysqlTable("users", {
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type InsertUser = typeof users.$inferInsert;
|
||||
|
||||
// TODO: Add your tables here
|
||||
/**
|
||||
* 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/s3-request-presigner": "^3.693.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mediapipe/drawing_utils": "^0.3.1675466124",
|
||||
"@mediapipe/pose": "^0.5.1675469404",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
|
||||
16
pnpm-lock.yaml
自动生成的
16
pnpm-lock.yaml
自动生成的
@@ -25,6 +25,12 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
|
||||
'@mediapipe/drawing_utils':
|
||||
specifier: ^0.3.1675466124
|
||||
version: 0.3.1675466124
|
||||
'@mediapipe/pose':
|
||||
specifier: ^0.5.1675469404
|
||||
version: 0.5.1675469404
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.12
|
||||
version: 1.2.12(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
@@ -1049,6 +1055,12 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@mediapipe/drawing_utils@0.3.1675466124':
|
||||
resolution: {integrity: sha512-/IWIB/iYRMtiUKe3k7yGqvwseWHCOqzVpRDfMgZ6gv9z7EEimg6iZbRluoPbcNKHbYSxN5yOvYTzUYb8KVf22Q==}
|
||||
|
||||
'@mediapipe/pose@0.5.1675469404':
|
||||
resolution: {integrity: sha512-DFZsNWTsSphRIZppnUCuunzBiHP2FdJXR9ehc7mMi4KG+oPaOH0Em3d6kr7Py+TSyTXC1doH88KcF28k2sBxsQ==}
|
||||
|
||||
'@medv/finder@4.0.2':
|
||||
resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==}
|
||||
|
||||
@@ -5247,6 +5259,10 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@mediapipe/drawing_utils@0.3.1675466124': {}
|
||||
|
||||
'@mediapipe/pose@0.5.1675469404': {}
|
||||
|
||||
'@medv/finder@4.0.2': {}
|
||||
|
||||
'@mermaid-js/parser@0.6.3':
|
||||
|
||||
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 { InsertUser, users } from "../drizzle/schema";
|
||||
import {
|
||||
InsertUser, users,
|
||||
usernameAccounts,
|
||||
trainingPlans, InsertTrainingPlan,
|
||||
trainingVideos, InsertTrainingVideo,
|
||||
poseAnalyses, InsertPoseAnalysis,
|
||||
trainingRecords, InsertTrainingRecord,
|
||||
ratingHistory, InsertRatingHistory,
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
// Lazily create the drizzle instance so local tooling can run without a DB.
|
||||
export async function getDb() {
|
||||
if (!_db && process.env.DATABASE_URL) {
|
||||
try {
|
||||
@@ -18,26 +25,19 @@ export async function getDb() {
|
||||
return _db;
|
||||
}
|
||||
|
||||
export async function upsertUser(user: InsertUser): Promise<void> {
|
||||
if (!user.openId) {
|
||||
throw new Error("User openId is required for upsert");
|
||||
}
|
||||
// ===== USER OPERATIONS =====
|
||||
|
||||
export async function upsertUser(user: InsertUser): Promise<void> {
|
||||
if (!user.openId) throw new Error("User openId is required for upsert");
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[Database] Cannot upsert user: database not available");
|
||||
return;
|
||||
}
|
||||
if (!db) { console.warn("[Database] Cannot upsert user: database not available"); return; }
|
||||
|
||||
try {
|
||||
const values: InsertUser = {
|
||||
openId: user.openId,
|
||||
};
|
||||
const values: InsertUser = { openId: user.openId };
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
|
||||
const textFields = ["name", "email", "loginMethod"] as const;
|
||||
type TextField = (typeof textFields)[number];
|
||||
|
||||
const assignNullable = (field: TextField) => {
|
||||
const value = user[field];
|
||||
if (value === undefined) return;
|
||||
@@ -45,48 +45,222 @@ export async function upsertUser(user: InsertUser): Promise<void> {
|
||||
values[field] = normalized;
|
||||
updateSet[field] = normalized;
|
||||
};
|
||||
|
||||
textFields.forEach(assignNullable);
|
||||
|
||||
if (user.lastSignedIn !== undefined) {
|
||||
values.lastSignedIn = user.lastSignedIn;
|
||||
updateSet.lastSignedIn = user.lastSignedIn;
|
||||
}
|
||||
if (user.role !== undefined) {
|
||||
values.role = user.role;
|
||||
updateSet.role = user.role;
|
||||
} else if (user.openId === ENV.ownerOpenId) {
|
||||
values.role = 'admin';
|
||||
updateSet.role = 'admin';
|
||||
}
|
||||
if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; }
|
||||
if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; }
|
||||
else if (user.openId === ENV.ownerOpenId) { values.role = 'admin'; updateSet.role = 'admin'; }
|
||||
if (!values.lastSignedIn) values.lastSignedIn = new Date();
|
||||
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
|
||||
|
||||
if (!values.lastSignedIn) {
|
||||
values.lastSignedIn = new Date();
|
||||
}
|
||||
|
||||
if (Object.keys(updateSet).length === 0) {
|
||||
updateSet.lastSignedIn = new Date();
|
||||
}
|
||||
|
||||
await db.insert(users).values(values).onDuplicateKeyUpdate({
|
||||
set: updateSet,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Database] Failed to upsert user:", error);
|
||||
throw error;
|
||||
}
|
||||
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
|
||||
} catch (error) { console.error("[Database] Failed to upsert user:", error); throw error; }
|
||||
}
|
||||
|
||||
export async function getUserByOpenId(openId: string) {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[Database] Cannot get user: database not available");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
||||
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
// TODO: add feature queries here as your schema grows.
|
||||
export async function getUserByUsername(username: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(usernameAccounts).where(eq(usernameAccounts.username, username)).limit(1);
|
||||
if (result.length === 0) return undefined;
|
||||
const userResult = await db.select().from(users).where(eq(users.id, result[0].userId)).limit(1);
|
||||
return userResult.length > 0 ? userResult[0] : undefined;
|
||||
}
|
||||
|
||||
export async function createUsernameAccount(username: string): Promise<{ user: typeof users.$inferSelect; isNew: boolean }> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
// Check if username already exists
|
||||
const existing = await db.select().from(usernameAccounts).where(eq(usernameAccounts.username, username)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
const user = await db.select().from(users).where(eq(users.id, existing[0].userId)).limit(1);
|
||||
if (user.length > 0) {
|
||||
await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, user[0].id));
|
||||
return { user: user[0], isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Create new user with username as openId
|
||||
const openId = `username_${username}_${Date.now()}`;
|
||||
await db.insert(users).values({
|
||||
openId,
|
||||
name: username,
|
||||
loginMethod: "username",
|
||||
lastSignedIn: new Date(),
|
||||
ntrpRating: 1.5,
|
||||
totalSessions: 0,
|
||||
totalMinutes: 0,
|
||||
});
|
||||
|
||||
const newUser = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
||||
if (newUser.length === 0) throw new Error("Failed to create user");
|
||||
|
||||
await db.insert(usernameAccounts).values({ username, userId: newUser[0].id });
|
||||
return { user: newUser[0], isNew: true };
|
||||
}
|
||||
|
||||
export async function updateUserProfile(userId: number, data: {
|
||||
skillLevel?: "beginner" | "intermediate" | "advanced";
|
||||
trainingGoals?: string;
|
||||
ntrpRating?: number;
|
||||
totalSessions?: number;
|
||||
totalMinutes?: number;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(users).set(data).where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
// ===== TRAINING PLAN OPERATIONS =====
|
||||
|
||||
export async function createTrainingPlan(plan: InsertTrainingPlan) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
// Deactivate existing active plans
|
||||
await db.update(trainingPlans).set({ isActive: 0 }).where(and(eq(trainingPlans.userId, plan.userId), eq(trainingPlans.isActive, 1)));
|
||||
const result = await db.insert(trainingPlans).values(plan);
|
||||
return result[0].insertId;
|
||||
}
|
||||
|
||||
export async function getUserTrainingPlans(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(trainingPlans).where(eq(trainingPlans.userId, userId)).orderBy(desc(trainingPlans.createdAt));
|
||||
}
|
||||
|
||||
export async function getActivePlan(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(trainingPlans).where(and(eq(trainingPlans.userId, userId), eq(trainingPlans.isActive, 1))).limit(1);
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function updateTrainingPlan(planId: number, data: Partial<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 { systemRouter } from "./_core/systemRouter";
|
||||
import { publicProcedure, router } from "./_core/trpc";
|
||||
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
||||
import { z } from "zod";
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { invokeLLM } from "./_core/llm";
|
||||
import { storagePut } from "./storage";
|
||||
import * as db from "./db";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const appRouter = router({
|
||||
// if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly
|
||||
system: systemRouter,
|
||||
|
||||
auth: router({
|
||||
me: publicProcedure.query(opts => opts.ctx.user),
|
||||
logout: publicProcedure.mutation(({ ctx }) => {
|
||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
||||
return {
|
||||
success: true,
|
||||
} as const;
|
||||
return { success: true } as const;
|
||||
}),
|
||||
|
||||
// Username-based login
|
||||
loginWithUsername: publicProcedure
|
||||
.input(z.object({ username: z.string().min(1).max(64) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, isNew } = await db.createUsernameAccount(input.username);
|
||||
const sessionToken = await sdk.createSessionToken(user.openId, {
|
||||
name: user.name || input.username,
|
||||
expiresInMs: ONE_YEAR_MS,
|
||||
});
|
||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||
ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
|
||||
return { user, isNew };
|
||||
}),
|
||||
}),
|
||||
|
||||
// User profile management
|
||||
profile: router({
|
||||
update: protectedProcedure
|
||||
.input(z.object({
|
||||
skillLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
trainingGoals: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db.updateUserProfile(ctx.user.id, input);
|
||||
return { success: true };
|
||||
}),
|
||||
stats: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.getUserStats(ctx.user.id);
|
||||
}),
|
||||
}),
|
||||
|
||||
// TODO: add feature routers here, e.g.
|
||||
// todo: router({
|
||||
// list: protectedProcedure.query(({ ctx }) =>
|
||||
// db.getUserTodos(ctx.user.id)
|
||||
// ),
|
||||
// }),
|
||||
// Training plan management
|
||||
plan: router({
|
||||
generate: protectedProcedure
|
||||
.input(z.object({
|
||||
skillLevel: z.enum(["beginner", "intermediate", "advanced"]),
|
||||
durationDays: z.number().min(1).max(30).default(7),
|
||||
focusAreas: z.array(z.string()).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
// Get user's recent analyses for personalization
|
||||
const analyses = await db.getUserAnalyses(user.id);
|
||||
const recentScores = analyses.slice(0, 5).map(a => ({
|
||||
score: a.overallScore,
|
||||
issues: a.detectedIssues,
|
||||
exerciseType: a.exerciseType,
|
||||
shotCount: a.shotCount,
|
||||
strokeConsistency: a.strokeConsistency,
|
||||
footworkScore: a.footworkScore,
|
||||
}));
|
||||
|
||||
const prompt = `你是一位专业网球教练。请为一位${
|
||||
input.skillLevel === "beginner" ? "初级" : input.skillLevel === "intermediate" ? "中级" : "高级"
|
||||
}水平的网球学员生成一个${input.durationDays}天的在家训练计划。
|
||||
|
||||
要求:
|
||||
- 只需要球拍,不需要球场和球网
|
||||
- 包含影子挥拍、墙壁练习、脚步移动、体能训练等
|
||||
- 每天训练30-60分钟
|
||||
${input.focusAreas?.length ? `- 重点关注: ${input.focusAreas.join(", ")}` : ""}
|
||||
${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(recentScores)}` : ""}
|
||||
|
||||
请返回JSON格式,包含每天的训练内容。`;
|
||||
|
||||
const response = await invokeLLM({
|
||||
messages: [
|
||||
{ role: "system", content: "你是专业网球教练AI助手。返回严格的JSON格式。" },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "training_plan",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "训练计划标题" },
|
||||
exercises: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
day: { type: "number" },
|
||||
name: { type: "string" },
|
||||
category: { type: "string" },
|
||||
duration: { type: "number", description: "分钟" },
|
||||
description: { type: "string" },
|
||||
tips: { type: "string" },
|
||||
sets: { type: "number" },
|
||||
reps: { type: "number" },
|
||||
},
|
||||
required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "exercises"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
const parsed = typeof content === "string" ? JSON.parse(content) : null;
|
||||
if (!parsed) throw new Error("Failed to generate training plan");
|
||||
|
||||
const planId = await db.createTrainingPlan({
|
||||
userId: user.id,
|
||||
title: parsed.title,
|
||||
skillLevel: input.skillLevel,
|
||||
durationDays: input.durationDays,
|
||||
exercises: parsed.exercises,
|
||||
isActive: 1,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
return { planId, plan: parsed };
|
||||
}),
|
||||
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.getUserTrainingPlans(ctx.user.id);
|
||||
}),
|
||||
|
||||
active: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.getActivePlan(ctx.user.id);
|
||||
}),
|
||||
|
||||
adjust: protectedProcedure
|
||||
.input(z.object({ planId: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const analyses = await db.getUserAnalyses(ctx.user.id);
|
||||
const recentAnalyses = analyses.slice(0, 5);
|
||||
const currentPlan = (await db.getUserTrainingPlans(ctx.user.id)).find(p => p.id === input.planId);
|
||||
if (!currentPlan) throw new Error("Plan not found");
|
||||
|
||||
const prompt = `基于以下用户的姿势分析结果,调整训练计划:
|
||||
|
||||
当前计划: ${JSON.stringify(currentPlan.exercises)}
|
||||
最近分析结果: ${JSON.stringify(recentAnalyses.map(a => ({
|
||||
score: a.overallScore,
|
||||
issues: a.detectedIssues,
|
||||
corrections: a.corrections,
|
||||
shotCount: a.shotCount,
|
||||
strokeConsistency: a.strokeConsistency,
|
||||
footworkScore: a.footworkScore,
|
||||
fluidityScore: a.fluidityScore,
|
||||
})))}
|
||||
|
||||
请根据分析结果调整训练计划,增加针对薄弱环节的训练,返回与原计划相同格式的JSON。`;
|
||||
|
||||
const response = await invokeLLM({
|
||||
messages: [
|
||||
{ role: "system", content: "你是专业网球教练AI助手。返回严格的JSON格式。" },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "adjusted_plan",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
adjustmentNotes: { type: "string", description: "调整说明" },
|
||||
exercises: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
day: { type: "number" },
|
||||
name: { type: "string" },
|
||||
category: { type: "string" },
|
||||
duration: { type: "number" },
|
||||
description: { type: "string" },
|
||||
tips: { type: "string" },
|
||||
sets: { type: "number" },
|
||||
reps: { type: "number" },
|
||||
},
|
||||
required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "adjustmentNotes", "exercises"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
const parsed = typeof content === "string" ? JSON.parse(content) : null;
|
||||
if (!parsed) throw new Error("Failed to adjust plan");
|
||||
|
||||
await db.updateTrainingPlan(input.planId, {
|
||||
exercises: parsed.exercises,
|
||||
adjustmentNotes: parsed.adjustmentNotes,
|
||||
version: (currentPlan.version || 1) + 1,
|
||||
});
|
||||
|
||||
return { success: true, adjustmentNotes: parsed.adjustmentNotes };
|
||||
}),
|
||||
}),
|
||||
|
||||
// Video management
|
||||
video: router({
|
||||
upload: protectedProcedure
|
||||
.input(z.object({
|
||||
title: z.string(),
|
||||
format: z.string(),
|
||||
fileSize: z.number(),
|
||||
exerciseType: z.string().optional(),
|
||||
fileBase64: z.string(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const fileBuffer = Buffer.from(input.fileBase64, "base64");
|
||||
const fileKey = `videos/${ctx.user.id}/${nanoid()}.${input.format}`;
|
||||
const contentType = input.format === "webm" ? "video/webm" : "video/mp4";
|
||||
const { url } = await storagePut(fileKey, fileBuffer, contentType);
|
||||
|
||||
const videoId = await db.createVideo({
|
||||
userId: ctx.user.id,
|
||||
title: input.title,
|
||||
fileKey,
|
||||
url,
|
||||
format: input.format,
|
||||
fileSize: input.fileSize,
|
||||
exerciseType: input.exerciseType || null,
|
||||
analysisStatus: "pending",
|
||||
});
|
||||
|
||||
return { videoId, url };
|
||||
}),
|
||||
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.getUserVideos(ctx.user.id);
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(z.object({ videoId: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
return db.getVideoById(input.videoId);
|
||||
}),
|
||||
|
||||
updateStatus: protectedProcedure
|
||||
.input(z.object({
|
||||
videoId: z.number(),
|
||||
status: z.enum(["pending", "analyzing", "completed", "failed"]),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
await db.updateVideoStatus(input.videoId, input.status);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// Pose analysis
|
||||
analysis: router({
|
||||
save: protectedProcedure
|
||||
.input(z.object({
|
||||
videoId: z.number(),
|
||||
overallScore: z.number().optional(),
|
||||
poseMetrics: z.any().optional(),
|
||||
detectedIssues: z.any().optional(),
|
||||
corrections: z.any().optional(),
|
||||
exerciseType: z.string().optional(),
|
||||
framesAnalyzed: z.number().optional(),
|
||||
shotCount: z.number().optional(),
|
||||
avgSwingSpeed: z.number().optional(),
|
||||
maxSwingSpeed: z.number().optional(),
|
||||
totalMovementDistance: z.number().optional(),
|
||||
strokeConsistency: z.number().optional(),
|
||||
footworkScore: z.number().optional(),
|
||||
fluidityScore: z.number().optional(),
|
||||
keyMoments: z.any().optional(),
|
||||
movementTrajectory: z.any().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const analysisId = await db.createPoseAnalysis({
|
||||
...input,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
await db.updateVideoStatus(input.videoId, "completed");
|
||||
|
||||
// Auto-update NTRP rating after analysis
|
||||
await recalculateNTRPRating(ctx.user.id, analysisId);
|
||||
|
||||
return { analysisId };
|
||||
}),
|
||||
|
||||
getByVideo: protectedProcedure
|
||||
.input(z.object({ videoId: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
return db.getAnalysisByVideoId(input.videoId);
|
||||
}),
|
||||
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.getUserAnalyses(ctx.user.id);
|
||||
}),
|
||||
|
||||
// Generate AI correction suggestions
|
||||
getCorrections: protectedProcedure
|
||||
.input(z.object({
|
||||
poseMetrics: z.any(),
|
||||
exerciseType: z.string(),
|
||||
detectedIssues: z.any(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const response = await invokeLLM({
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "你是一位专业网球教练。根据MediaPipe姿势分析数据,给出具体的姿势矫正建议。用中文回答。",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `分析以下网球动作数据并给出矫正建议:
|
||||
动作类型: ${input.exerciseType}
|
||||
姿势指标: ${JSON.stringify(input.poseMetrics)}
|
||||
检测到的问题: ${JSON.stringify(input.detectedIssues)}
|
||||
|
||||
请给出:
|
||||
1. 每个问题的具体矫正方法
|
||||
2. 推荐的练习动作
|
||||
3. 需要注意的关键点`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
corrections: response.choices[0]?.message?.content || "暂无建议",
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
// Training records
|
||||
record: router({
|
||||
create: protectedProcedure
|
||||
.input(z.object({
|
||||
planId: z.number().optional(),
|
||||
exerciseName: z.string(),
|
||||
durationMinutes: z.number().optional(),
|
||||
notes: z.string().optional(),
|
||||
poseScore: z.number().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const recordId = await db.createTrainingRecord({
|
||||
userId: ctx.user.id,
|
||||
...input,
|
||||
completed: 0,
|
||||
});
|
||||
return { recordId };
|
||||
}),
|
||||
|
||||
complete: protectedProcedure
|
||||
.input(z.object({
|
||||
recordId: z.number(),
|
||||
poseScore: z.number().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db.markRecordCompleted(input.recordId, input.poseScore);
|
||||
// Update user stats
|
||||
const records = await db.getUserTrainingRecords(ctx.user.id, 1000);
|
||||
const completed = records.filter(r => r.completed === 1);
|
||||
const totalMinutes = records.reduce((sum, r) => sum + (r.durationMinutes || 0), 0);
|
||||
await db.updateUserProfile(ctx.user.id, {
|
||||
totalSessions: completed.length,
|
||||
totalMinutes,
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
list: protectedProcedure
|
||||
.input(z.object({ limit: z.number().default(50) }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
return db.getUserTrainingRecords(ctx.user.id, input?.limit || 50);
|
||||
}),
|
||||
}),
|
||||
|
||||
// Rating system
|
||||
rating: router({
|
||||
history: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.getUserRatingHistory(ctx.user.id);
|
||||
}),
|
||||
current: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await db.getUserByOpenId(ctx.user.openId);
|
||||
return { rating: user?.ntrpRating || 1.5 };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// NTRP Rating calculation function
|
||||
async function recalculateNTRPRating(userId: number, latestAnalysisId: number) {
|
||||
const analyses = await db.getUserAnalyses(userId);
|
||||
if (analyses.length === 0) return;
|
||||
|
||||
// Weight recent analyses more heavily
|
||||
const weightedScores = analyses.slice(0, 20).map((a, i) => {
|
||||
const weight = Math.max(0.3, 1 - i * 0.05); // Recent = higher weight
|
||||
return {
|
||||
overallScore: (a.overallScore || 0) * weight,
|
||||
strokeConsistency: (a.strokeConsistency || 0) * weight,
|
||||
footworkScore: (a.footworkScore || 0) * weight,
|
||||
fluidityScore: (a.fluidityScore || 0) * weight,
|
||||
shotCount: (a.shotCount || 0) * weight,
|
||||
avgSwingSpeed: (a.avgSwingSpeed || 0) * weight,
|
||||
weight,
|
||||
};
|
||||
});
|
||||
|
||||
const totalWeight = weightedScores.reduce((sum, s) => sum + s.weight, 0);
|
||||
|
||||
const dimensions = {
|
||||
poseAccuracy: weightedScores.reduce((sum, s) => sum + s.overallScore, 0) / totalWeight,
|
||||
strokeConsistency: weightedScores.reduce((sum, s) => sum + s.strokeConsistency, 0) / totalWeight,
|
||||
footwork: weightedScores.reduce((sum, s) => sum + s.footworkScore, 0) / totalWeight,
|
||||
fluidity: weightedScores.reduce((sum, s) => sum + s.fluidityScore, 0) / totalWeight,
|
||||
power: Math.min(100, weightedScores.reduce((sum, s) => sum + s.avgSwingSpeed, 0) / totalWeight * 5),
|
||||
};
|
||||
|
||||
// Convert 0-100 scores to NTRP 1.0-5.0
|
||||
// NTRP mapping: 0-20 → 1.0-1.5, 20-40 → 1.5-2.5, 40-60 → 2.5-3.5, 60-80 → 3.5-4.5, 80-100 → 4.5-5.0
|
||||
const avgDimension = (
|
||||
dimensions.poseAccuracy * 0.30 +
|
||||
dimensions.strokeConsistency * 0.25 +
|
||||
dimensions.footwork * 0.20 +
|
||||
dimensions.fluidity * 0.15 +
|
||||
dimensions.power * 0.10
|
||||
);
|
||||
|
||||
let ntrpRating: number;
|
||||
if (avgDimension <= 20) ntrpRating = 1.0 + (avgDimension / 20) * 0.5;
|
||||
else if (avgDimension <= 40) ntrpRating = 1.5 + ((avgDimension - 20) / 20) * 1.0;
|
||||
else if (avgDimension <= 60) ntrpRating = 2.5 + ((avgDimension - 40) / 20) * 1.0;
|
||||
else if (avgDimension <= 80) ntrpRating = 3.5 + ((avgDimension - 60) / 20) * 1.0;
|
||||
else ntrpRating = 4.5 + ((avgDimension - 80) / 20) * 0.5;
|
||||
|
||||
ntrpRating = Math.round(ntrpRating * 10) / 10;
|
||||
ntrpRating = Math.max(1.0, Math.min(5.0, ntrpRating));
|
||||
|
||||
// Save rating history
|
||||
await db.createRatingEntry({
|
||||
userId,
|
||||
rating: ntrpRating,
|
||||
reason: `基于${analyses.length}次视频分析自动评分`,
|
||||
dimensionScores: dimensions,
|
||||
analysisId: latestAnalysisId,
|
||||
});
|
||||
|
||||
// Update user's current rating
|
||||
await db.updateUserProfile(userId, { ntrpRating });
|
||||
}
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
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仓库
|
||||
在新工单中引用
屏蔽一个用户