Checkpoint: Tennis Training Hub v1.0 - 完整功能版本:用户名登录、AI训练计划生成、MediaPipe视频姿势识别、击球统计、挥拍速度分析、NTRP自动评分系统、训练进度追踪、视频库管理、AI矫正建议

这个提交包含在:
Manus
2026-03-14 07:41:43 -04:00
父节点 00d6319ffb
当前提交 36907d1110
修改 29 个文件,包含 4870 行新增228 行删除

0
.gitkeep 普通文件
查看文件

查看文件

@@ -0,0 +1,9 @@
{
"query": "CREATE TABLE `pose_analyses` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`videoId` int NOT NULL,\n\t`userId` int NOT NULL,\n\t`overallScore` float,\n\t`poseMetrics` json,\n\t`detectedIssues` json,\n\t`corrections` json,\n\t`exerciseType` varchar(64),\n\t`framesAnalyzed` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `pose_analyses_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_plans` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') NOT NULL,\n\t`durationDays` int NOT NULL DEFAULT 7,\n\t`exercises` json NOT NULL,\n\t`isActive` int NOT NULL DEFAULT 1,\n\t`adjustmentNotes` text,\n\t`version` int NOT NULL DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_plans_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_records` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`planId` int,\n\t`exerciseName` varchar(128) NOT NULL,\n\t`durationMinutes` int,\n\t`completed` int NOT NULL DEFAULT 0,\n\t`notes` text,\n\t`poseScore` float,\n\t`trainingDate` timestamp NOT NULL DEFAULT (now()),\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `training_records_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`fileKey` varchar(512) NOT NULL,\n\t`url` text NOT NULL,\n\t`format` varchar(16) NOT NULL,\n\t`fileSize` int,\n\t`duration` float,\n\t`exerciseType` varchar(64),\n\t`analysisStatus` enum('pending','analyzing','completed','failed') DEFAULT 'pending',\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_videos_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `username_accounts` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`username` varchar(64) NOT NULL,\n\t`userId` int NOT NULL,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `username_accounts_id` PRIMARY KEY(`id`),\n\tCONSTRAINT `username_accounts_username_unique` UNIQUE(`username`)\n);\n\nALTER TABLE `users` ADD `skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner';\nALTER TABLE `users` ADD `trainingGoals` text;",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway04.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2DECURBBieadmmU.root --database auVVpV3E7dpuxwRrSUT9kL --execute CREATE TABLE `pose_analyses` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`videoId` int NOT NULL,\n\t`userId` int NOT NULL,\n\t`overallScore` float,\n\t`poseMetrics` json,\n\t`detectedIssues` json,\n\t`corrections` json,\n\t`exerciseType` varchar(64),\n\t`framesAnalyzed` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `pose_analyses_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_plans` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') NOT NULL,\n\t`durationDays` int NOT NULL DEFAULT 7,\n\t`exercises` json NOT NULL,\n\t`isActive` int NOT NULL DEFAULT 1,\n\t`adjustmentNotes` text,\n\t`version` int NOT NULL DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_plans_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_records` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`planId` int,\n\t`exerciseName` varchar(128) NOT NULL,\n\t`durationMinutes` int,\n\t`completed` int NOT NULL DEFAULT 0,\n\t`notes` text,\n\t`poseScore` float,\n\t`trainingDate` timestamp NOT NULL DEFAULT (now()),\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `training_records_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`fileKey` varchar(512) NOT NULL,\n\t`url` text NOT NULL,\n\t`format` varchar(16) NOT NULL,\n\t`fileSize` int,\n\t`duration` float,\n\t`exerciseType` varchar(64),\n\t`analysisStatus` enum('pending','analyzing','completed','failed') DEFAULT 'pending',\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_videos_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `username_accounts` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`username` varchar(64) NOT NULL,\n\t`userId` int NOT NULL,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `username_accounts_id` PRIMARY KEY(`id`),\n\tCONSTRAINT `username_accounts_username_unique` UNIQUE(`username`)\n);\n\nALTER TABLE `users` ADD `skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner';\nALTER TABLE `users` ADD `trainingGoals` text;",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 5704
}

查看文件

@@ -0,0 +1,9 @@
{
"query": "CREATE TABLE `rating_history` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`rating` float NOT NULL,\n\t`reason` varchar(256),\n\t`dimensionScores` json,\n\t`analysisId` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `rating_history_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `pose_analyses` ADD `shotCount` int DEFAULT 0;\nALTER TABLE `pose_analyses` ADD `avgSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `maxSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `totalMovementDistance` float;\nALTER TABLE `pose_analyses` ADD `strokeConsistency` float;\nALTER TABLE `pose_analyses` ADD `footworkScore` float;\nALTER TABLE `pose_analyses` ADD `fluidityScore` float;\nALTER TABLE `pose_analyses` ADD `keyMoments` json;\nALTER TABLE `pose_analyses` ADD `movementTrajectory` json;\nALTER TABLE `users` ADD `ntrpRating` float DEFAULT 1.5;\nALTER TABLE `users` ADD `totalSessions` int DEFAULT 0;\nALTER TABLE `users` ADD `totalMinutes` int DEFAULT 0;",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway04.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2DECURBBieadmmU.root --database auVVpV3E7dpuxwRrSUT9kL --execute CREATE TABLE `rating_history` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`rating` float NOT NULL,\n\t`reason` varchar(256),\n\t`dimensionScores` json,\n\t`analysisId` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `rating_history_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `pose_analyses` ADD `shotCount` int DEFAULT 0;\nALTER TABLE `pose_analyses` ADD `avgSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `maxSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `totalMovementDistance` float;\nALTER TABLE `pose_analyses` ADD `strokeConsistency` float;\nALTER TABLE `pose_analyses` ADD `footworkScore` float;\nALTER TABLE `pose_analyses` ADD `fluidityScore` float;\nALTER TABLE `pose_analyses` ADD `keyMoments` json;\nALTER TABLE `pose_analyses` ADD `movementTrajectory` json;\nALTER TABLE `users` ADD `ntrpRating` float DEFAULT 1.5;\nALTER TABLE `users` ADD `totalSessions` int DEFAULT 0;\nALTER TABLE `users` ADD `totalMinutes` int DEFAULT 0;",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 7485
}

查看文件

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
@@ -7,11 +7,9 @@
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:
<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;
}
}
}

查看文件

@@ -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;
}

查看文件

@@ -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 普通文件
查看文件

@@ -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>
);
}

查看文件

@@ -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 普通文件
查看文件

@@ -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>
);
}

查看文件

@@ -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 普通文件
查看文件

@@ -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 普通文件
查看文件

@@ -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`)
);

查看文件

@@ -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;

查看文件

@@ -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": {}
}
}

查看文件

@@ -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": {}
}
}

查看文件

@@ -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 自动生成的
查看文件

@@ -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':

查看文件

@@ -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 普通文件
查看文件

@@ -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 };
}),
}),
// TODO: add feature routers here, e.g.
// todo: router({
// list: protectedProcedure.query(({ ctx }) =>
// db.getUserTodos(ctx.user.id)
// ),
// }),
// 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);
}),
}),
// 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 普通文件
查看文件

@@ -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仓库