Checkpoint: v2.0完整版本:新增社区排行榜、每日打卡、24种成就徽章、实时摄像头姿势分析、在线录制(稳定压缩流/断线重连/自动剪辑)、移动端全面适配。47个测试通过。包含完整开发文档。
这个提交包含在:
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"query": "CREATE TABLE `daily_checkins` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`checkinDate` varchar(10) NOT NULL,\n\t`streakCount` int NOT NULL DEFAULT 1,\n\t`notes` text,\n\t`minutesTrained` int DEFAULT 0,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `daily_checkins_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `user_badges` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`badgeKey` varchar(64) NOT NULL,\n\t`earnedAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `user_badges_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `users` ADD `currentStreak` int DEFAULT 0;\nALTER TABLE `users` ADD `longestStreak` int DEFAULT 0;\nALTER TABLE `users` ADD `totalShots` 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 `daily_checkins` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`checkinDate` varchar(10) NOT NULL,\n\t`streakCount` int NOT NULL DEFAULT 1,\n\t`notes` text,\n\t`minutesTrained` int DEFAULT 0,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `daily_checkins_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `user_badges` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`badgeKey` varchar(64) NOT NULL,\n\t`earnedAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `user_badges_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `users` ADD `currentStreak` int DEFAULT 0;\nALTER TABLE `users` ADD `longestStreak` int DEFAULT 0;\nALTER TABLE `users` ADD `totalShots` int DEFAULT 0;",
|
||||||
|
"rows": [],
|
||||||
|
"messages": [],
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"execution_time_ms": 4522
|
||||||
|
}
|
||||||
186
README.md
普通文件
186
README.md
普通文件
@@ -0,0 +1,186 @@
|
|||||||
|
# Tennis Training Hub - AI网球训练助手
|
||||||
|
|
||||||
|
一个基于AI的在家网球训练平台,通过MediaPipe姿势识别技术帮助用户在只有球拍的条件下进行科学训练,自动分析挥拍姿势并生成个性化训练计划。
|
||||||
|
|
||||||
|
## 功能概览
|
||||||
|
|
||||||
|
| 功能模块 | 描述 | 技术实现 |
|
||||||
|
|---------|------|---------|
|
||||||
|
| 用户名登录 | 无需注册,输入用户名即可使用 | tRPC + JWT Session |
|
||||||
|
| 训练计划生成 | 根据用户水平(初/中/高级)AI生成训练计划 | LLM结构化输出 |
|
||||||
|
| 视频上传分析 | 上传训练视频进行姿势识别 | MediaPipe Pose + S3 |
|
||||||
|
| 实时摄像头分析 | 手机/电脑摄像头实时捕捉分析 | MediaPipe实时推理 |
|
||||||
|
| 在线录制 | 稳定压缩流录制、断线重连、自动剪辑 | MediaRecorder API |
|
||||||
|
| 姿势矫正建议 | AI根据分析结果生成矫正方案 | LLM + 姿势数据 |
|
||||||
|
| NTRP自动评分 | 基于USTA标准的五维度加权评分 | 自动算法 |
|
||||||
|
| 训练计划自动调整 | 根据分析结果智能调整后续计划 | LLM + 历史数据 |
|
||||||
|
| 每日打卡 | 连续打卡追踪、训练时长记录 | 日期计算 + 数据库 |
|
||||||
|
| 成就徽章 | 24种成就徽章激励系统 | 自动检测 + 授予 |
|
||||||
|
| 社区排行榜 | NTRP评分、训练时长、击球数排名 | 数据库排序查询 |
|
||||||
|
| 训练进度追踪 | 可视化展示训练历史和改进趋势 | Recharts图表 |
|
||||||
|
| 视频库管理 | 保存管理所有训练视频及分析结果 | S3 + 数据库 |
|
||||||
|
| 移动端适配 | 全面响应式设计,手机摄像头优化 | Tailwind响应式 |
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
**前端:**
|
||||||
|
- React 19 + TypeScript
|
||||||
|
- Tailwind CSS 4 + shadcn/ui
|
||||||
|
- MediaPipe Pose(浏览器端姿势识别)
|
||||||
|
- Recharts(数据可视化)
|
||||||
|
- Framer Motion(动画效果)
|
||||||
|
- wouter(路由)
|
||||||
|
|
||||||
|
**后端:**
|
||||||
|
- Express 4 + tRPC 11
|
||||||
|
- Drizzle ORM + MySQL/TiDB
|
||||||
|
- S3文件存储
|
||||||
|
- LLM集成(训练计划生成、姿势矫正建议)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
tennis-training-hub/
|
||||||
|
├── client/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── Home.tsx # 落地页
|
||||||
|
│ │ │ ├── Login.tsx # 用户名登录
|
||||||
|
│ │ │ ├── Dashboard.tsx # 仪表盘
|
||||||
|
│ │ │ ├── Training.tsx # 训练计划
|
||||||
|
│ │ │ ├── Analysis.tsx # 视频分析(MediaPipe)
|
||||||
|
│ │ │ ├── LiveCamera.tsx # 实时摄像头分析
|
||||||
|
│ │ │ ├── Recorder.tsx # 在线录制
|
||||||
|
│ │ │ ├── Videos.tsx # 视频库
|
||||||
|
│ │ │ ├── Progress.tsx # 训练进度
|
||||||
|
│ │ │ ├── Rating.tsx # NTRP评分详情
|
||||||
|
│ │ │ ├── Leaderboard.tsx # 社区排行榜
|
||||||
|
│ │ │ └── Checkin.tsx # 每日打卡+徽章
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ └── DashboardLayout.tsx # 侧边栏导航布局
|
||||||
|
│ │ ├── App.tsx # 路由配置
|
||||||
|
│ │ └── index.css # 主题样式
|
||||||
|
│ └── index.html
|
||||||
|
├── server/
|
||||||
|
│ ├── routers.ts # tRPC路由定义
|
||||||
|
│ ├── db.ts # 数据库查询助手
|
||||||
|
│ ├── storage.ts # S3存储助手
|
||||||
|
│ ├── features.test.ts # 功能测试(47个)
|
||||||
|
│ └── _core/ # 框架核心(勿修改)
|
||||||
|
├── drizzle/
|
||||||
|
│ └── schema.ts # 数据库表结构
|
||||||
|
└── shared/
|
||||||
|
└── const.ts # 共享常量
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库设计
|
||||||
|
|
||||||
|
### 核心表
|
||||||
|
|
||||||
|
| 表名 | 用途 | 关键字段 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `users` | 用户信息 | openId, name, skillLevel, ntrpRating, totalSessions, currentStreak |
|
||||||
|
| `username_accounts` | 用户名登录映射 | username, userId |
|
||||||
|
| `training_plans` | AI训练计划 | exercises(JSON), skillLevel, durationDays, version |
|
||||||
|
| `training_videos` | 训练视频 | fileKey, url, format, analysisStatus |
|
||||||
|
| `pose_analyses` | 姿势分析结果 | overallScore, shotCount, avgSwingSpeed, strokeConsistency |
|
||||||
|
| `training_records` | 训练记录 | exerciseName, durationMinutes, completed, poseScore |
|
||||||
|
| `rating_history` | NTRP评分历史 | rating, dimensionScores(JSON), analysisId |
|
||||||
|
| `daily_checkins` | 每日打卡 | checkinDate, streakCount, minutesTrained |
|
||||||
|
| `user_badges` | 成就徽章 | badgeKey, earnedAt |
|
||||||
|
|
||||||
|
## NTRP自动评分系统
|
||||||
|
|
||||||
|
评分基于USTA(美国网球协会)的NTRP标准,范围1.0-5.0,采用五维度加权计算:
|
||||||
|
|
||||||
|
| 维度 | 权重 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 姿势正确性 | 30% | 基于MediaPipe关键点角度分析 |
|
||||||
|
| 击球一致性 | 25% | 多次挥拍动作的稳定性 |
|
||||||
|
| 脚步移动 | 20% | 身体重心移动和步法评估 |
|
||||||
|
| 动作流畅性 | 15% | 挥拍动作的连贯性和自然度 |
|
||||||
|
| 力量表现 | 10% | 基于挥拍速度估算 |
|
||||||
|
|
||||||
|
**评分映射规则:**
|
||||||
|
- 0-20分 → NTRP 1.0-1.5(初学者)
|
||||||
|
- 20-40分 → NTRP 1.5-2.5(初级)
|
||||||
|
- 40-60分 → NTRP 2.5-3.5(中级)
|
||||||
|
- 60-80分 → NTRP 3.5-4.5(中高级)
|
||||||
|
- 80-100分 → NTRP 4.5-5.0(高级)
|
||||||
|
|
||||||
|
评分会根据最近20次视频分析结果自动更新,近期分析权重更高。
|
||||||
|
|
||||||
|
## 成就徽章系统
|
||||||
|
|
||||||
|
共24种成就徽章,分为6个类别:
|
||||||
|
|
||||||
|
| 类别 | 徽章数 | 示例 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 里程碑 | 1 | 初来乍到(首次登录) |
|
||||||
|
| 训练 | 6 | 初试身手、十次训练、百次训练、训练时长里程碑 |
|
||||||
|
| 连续打卡 | 4 | 三日坚持、一周达人、两周勇士、月度冠军 |
|
||||||
|
| 视频 | 3 | 影像记录、视频达人、视频大师 |
|
||||||
|
| 分析 | 4 | AI教练、优秀姿势、完美姿势、击球里程碑 |
|
||||||
|
| 评分 | 3 | NTRP 2.0/3.0/4.0 |
|
||||||
|
|
||||||
|
## 在线录制功能
|
||||||
|
|
||||||
|
在线录制模块提供专业级录制体验:
|
||||||
|
|
||||||
|
- **稳定压缩流**:使用MediaRecorder API,自适应码率(1-2.5Mbps),支持webm/mp4格式
|
||||||
|
- **断线自动重连**:摄像头意外断开时自动检测并重新连接,保存已录制片段
|
||||||
|
- **自动剪辑**:基于运动检测自动标记关键时刻,支持手动设置剪辑点
|
||||||
|
- **分段录制**:每60秒自动分段,防止数据丢失
|
||||||
|
- **手机摄像头优化**:支持前后摄像头切换,自适应分辨率
|
||||||
|
|
||||||
|
## 移动端适配
|
||||||
|
|
||||||
|
- 安全区域适配(iPhone X+刘海屏)
|
||||||
|
- 触摸友好的44px最小点击区域
|
||||||
|
- 横屏视频优化
|
||||||
|
- 防误触下拉刷新(录制/分析模式)
|
||||||
|
- 响应式侧边栏导航
|
||||||
|
- 移动端底部导航栏
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
项目包含47个vitest测试用例,覆盖所有核心后端功能:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
测试覆盖范围:
|
||||||
|
- 认证系统(登录、登出、用户名验证)
|
||||||
|
- 用户资料管理
|
||||||
|
- 训练计划生成(输入验证)
|
||||||
|
- 视频上传和管理
|
||||||
|
- 姿势分析保存和查询
|
||||||
|
- 训练记录创建和完成
|
||||||
|
- NTRP评分系统
|
||||||
|
- 每日打卡系统
|
||||||
|
- 成就徽章系统
|
||||||
|
- 社区排行榜
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# 类型检查
|
||||||
|
pnpm check
|
||||||
|
|
||||||
|
# 构建生产版本
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -13,6 +13,10 @@ import Analysis from "./pages/Analysis";
|
|||||||
import Videos from "./pages/Videos";
|
import Videos from "./pages/Videos";
|
||||||
import Progress from "./pages/Progress";
|
import Progress from "./pages/Progress";
|
||||||
import Rating from "./pages/Rating";
|
import Rating from "./pages/Rating";
|
||||||
|
import Leaderboard from "./pages/Leaderboard";
|
||||||
|
import Checkin from "./pages/Checkin";
|
||||||
|
import LiveCamera from "./pages/LiveCamera";
|
||||||
|
import Recorder from "./pages/Recorder";
|
||||||
|
|
||||||
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
|
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
|
||||||
return (
|
return (
|
||||||
@@ -45,6 +49,18 @@ function Router() {
|
|||||||
<Route path="/rating">
|
<Route path="/rating">
|
||||||
<DashboardRoute component={Rating} />
|
<DashboardRoute component={Rating} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/leaderboard">
|
||||||
|
<DashboardRoute component={Leaderboard} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/checkin">
|
||||||
|
<DashboardRoute component={Checkin} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/live-camera">
|
||||||
|
<DashboardRoute component={LiveCamera} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/recorder">
|
||||||
|
<DashboardRoute component={Recorder} />
|
||||||
|
</Route>
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -22,19 +22,23 @@ import {
|
|||||||
import { useIsMobile } from "@/hooks/useMobile";
|
import { useIsMobile } from "@/hooks/useMobile";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, LogOut, PanelLeft, Target, Video,
|
LayoutDashboard, LogOut, PanelLeft, Target, Video,
|
||||||
Award, Activity, FileVideo
|
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation, Redirect } from "wouter";
|
import { useLocation, Redirect } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: LayoutDashboard, label: "仪表盘", path: "/dashboard" },
|
{ icon: LayoutDashboard, label: "仪表盘", path: "/dashboard", group: "main" },
|
||||||
{ icon: Target, label: "训练计划", path: "/training" },
|
{ icon: Target, label: "训练计划", path: "/training", group: "main" },
|
||||||
{ icon: Video, label: "视频分析", path: "/analysis" },
|
{ icon: Flame, label: "每日打卡", path: "/checkin", group: "main" },
|
||||||
{ icon: FileVideo, label: "视频库", path: "/videos" },
|
{ icon: Camera, label: "实时分析", path: "/live-camera", group: "analysis" },
|
||||||
{ icon: Activity, label: "训练进度", path: "/progress" },
|
{ icon: CircleDot, label: "在线录制", path: "/recorder", group: "analysis" },
|
||||||
{ icon: Award, label: "NTRP评分", path: "/rating" },
|
{ icon: Video, label: "视频分析", path: "/analysis", group: "analysis" },
|
||||||
|
{ icon: FileVideo, label: "视频库", path: "/videos", group: "analysis" },
|
||||||
|
{ icon: Activity, label: "训练进度", path: "/progress", group: "stats" },
|
||||||
|
{ icon: Award, label: "NTRP评分", path: "/rating", group: "stats" },
|
||||||
|
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||||
@@ -163,7 +167,8 @@ function DashboardLayoutContent({
|
|||||||
|
|
||||||
<SidebarContent className="gap-0">
|
<SidebarContent className="gap-0">
|
||||||
<SidebarMenu className="px-2 py-1">
|
<SidebarMenu className="px-2 py-1">
|
||||||
{menuItems.map(item => {
|
{/* Main group */}
|
||||||
|
{menuItems.filter(i => i.group === "main").map(item => {
|
||||||
const isActive = location === item.path;
|
const isActive = location === item.path;
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.path}>
|
<SidebarMenuItem key={item.path}>
|
||||||
@@ -173,9 +178,49 @@ function DashboardLayoutContent({
|
|||||||
tooltip={item.label}
|
tooltip={item.label}
|
||||||
className={`h-10 transition-all font-normal`}
|
className={`h-10 transition-all font-normal`}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
||||||
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
|
<span>{item.label}</span>
|
||||||
/>
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
{!isCollapsed && <div className="my-2 mx-2 border-t border-border/50" />}
|
||||||
|
{!isCollapsed && <p className="px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">分析与录制</p>}
|
||||||
|
|
||||||
|
{menuItems.filter(i => i.group === "analysis").map(item => {
|
||||||
|
const isActive = location === item.path;
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.path}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={isActive}
|
||||||
|
onClick={() => setLocation(item.path)}
|
||||||
|
tooltip={item.label}
|
||||||
|
className={`h-10 transition-all font-normal`}
|
||||||
|
>
|
||||||
|
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
{!isCollapsed && <div className="my-2 mx-2 border-t border-border/50" />}
|
||||||
|
{!isCollapsed && <p className="px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">统计与排名</p>}
|
||||||
|
|
||||||
|
{menuItems.filter(i => i.group === "stats").map(item => {
|
||||||
|
const isActive = location === item.path;
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.path}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={isActive}
|
||||||
|
onClick={() => setLocation(item.path)}
|
||||||
|
tooltip={item.label}
|
||||||
|
className={`h-10 transition-all font-normal`}
|
||||||
|
>
|
||||||
|
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -164,3 +164,92 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile-first responsive enhancements ===== */
|
||||||
|
|
||||||
|
/* Safe area insets for notched devices (iPhone X+, etc.) */
|
||||||
|
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||||
|
.mobile-safe-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
.mobile-safe-top {
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-friendly tap targets */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
button, [role="button"], a, select, input[type="checkbox"], input[type="radio"] {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
.compact-touch button, .compact-touch [role="button"] {
|
||||||
|
min-height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text size adjustment on orientation change */
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth scrolling with momentum on mobile */
|
||||||
|
.mobile-scroll {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landscape video optimization */
|
||||||
|
@media (orientation: landscape) and (max-height: 500px) {
|
||||||
|
.landscape-compact-header {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.landscape-fullscreen-video {
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent pull-to-refresh during camera/recording */
|
||||||
|
.no-overscroll {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video container responsive */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.video-container {
|
||||||
|
aspect-ratio: auto;
|
||||||
|
min-height: 50vw;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile bottom nav spacing */
|
||||||
|
.mobile-bottom-spacing {
|
||||||
|
padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive grid for badge cards */
|
||||||
|
@media (max-width: 374px) {
|
||||||
|
.badge-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.hide-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Camera overlay text readability */
|
||||||
|
.camera-overlay-text {
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
|||||||
239
client/src/pages/Checkin.tsx
普通文件
239
client/src/pages/Checkin.tsx
普通文件
@@ -0,0 +1,239 @@
|
|||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Flame, Calendar, Award, CheckCircle2, Lock, Star, Trophy, Zap } from "lucide-react";
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, { label: string; color: string }> = {
|
||||||
|
milestone: { label: "里程碑", color: "bg-blue-100 text-blue-700" },
|
||||||
|
training: { label: "训练", color: "bg-green-100 text-green-700" },
|
||||||
|
video: { label: "视频", color: "bg-purple-100 text-purple-700" },
|
||||||
|
analysis: { label: "分析", color: "bg-orange-100 text-orange-700" },
|
||||||
|
streak: { label: "连续打卡", color: "bg-red-100 text-red-700" },
|
||||||
|
rating: { label: "评分", color: "bg-yellow-100 text-yellow-700" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Checkin() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [checkinDone, setCheckinDone] = useState(false);
|
||||||
|
|
||||||
|
const { data: todayCheckin, isLoading: loadingToday } = trpc.checkin.today.useQuery();
|
||||||
|
const { data: checkinHistory } = trpc.checkin.history.useQuery({ limit: 60 });
|
||||||
|
const { data: badges, isLoading: loadingBadges, refetch: refetchBadges } = trpc.badge.list.useQuery();
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const checkinMutation = trpc.checkin.do.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.alreadyCheckedIn) {
|
||||||
|
toast.info("今天已经打卡过了!");
|
||||||
|
} else {
|
||||||
|
toast.success(`打卡成功!连续 ${data.streak} 天 🔥`);
|
||||||
|
if (data.newBadges && data.newBadges.length > 0) {
|
||||||
|
data.newBadges.forEach((key: string) => {
|
||||||
|
toast.success(`🏆 获得新徽章!`, { duration: 5000 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCheckinDone(true);
|
||||||
|
}
|
||||||
|
utils.checkin.today.invalidate();
|
||||||
|
utils.checkin.history.invalidate();
|
||||||
|
refetchBadges();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("打卡失败,请重试"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCheckin = () => {
|
||||||
|
checkinMutation.mutate({ notes: notes || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const alreadyCheckedIn = !!todayCheckin || checkinDone;
|
||||||
|
|
||||||
|
// Build calendar heatmap for last 60 days
|
||||||
|
const heatmapData = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
(checkinHistory || []).forEach((c: any) => {
|
||||||
|
map.set(c.checkinDate, c.streakCount);
|
||||||
|
});
|
||||||
|
const days = [];
|
||||||
|
for (let i = 59; i >= 0; i--) {
|
||||||
|
const d = new Date(Date.now() - i * 86400000);
|
||||||
|
const key = d.toISOString().slice(0, 10);
|
||||||
|
days.push({ date: key, checked: map.has(key), streak: map.get(key) || 0, day: d.getDate() });
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}, [checkinHistory]);
|
||||||
|
|
||||||
|
const earnedCount = badges?.filter((b: any) => b.earned).length || 0;
|
||||||
|
const totalCount = badges?.length || 0;
|
||||||
|
|
||||||
|
// Group badges by category
|
||||||
|
const groupedBadges = useMemo(() => {
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
(badges || []).forEach((b: any) => {
|
||||||
|
if (!groups[b.category]) groups[b.category] = [];
|
||||||
|
groups[b.category].push(b);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}, [badges]);
|
||||||
|
|
||||||
|
if (loadingToday || loadingBadges) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Check-in card */}
|
||||||
|
<Card className={`border-0 shadow-sm ${alreadyCheckedIn ? "bg-green-50/50" : "bg-gradient-to-br from-primary/5 to-primary/10"}`}>
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
|
||||||
|
<div className={`h-20 w-20 rounded-full flex items-center justify-center shrink-0 ${
|
||||||
|
alreadyCheckedIn ? "bg-green-100" : "bg-primary/10"
|
||||||
|
}`}>
|
||||||
|
{alreadyCheckedIn ? (
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Flame className="h-10 w-10 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center sm:text-left">
|
||||||
|
<h2 className="text-xl font-bold">
|
||||||
|
{alreadyCheckedIn ? "今日已打卡 ✅" : "今日尚未打卡"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{alreadyCheckedIn
|
||||||
|
? `当前连续打卡 ${todayCheckin?.streakCount || (checkinHistory?.[0] as any)?.streakCount || 1} 天`
|
||||||
|
: "记录今天的训练,保持连续打卡!"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{!alreadyCheckedIn && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="今天练了什么?(可选)"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className="max-w-md text-sm resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleCheckin}
|
||||||
|
disabled={checkinMutation.isPending}
|
||||||
|
className="gap-2"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Flame className="h-4 w-4" />
|
||||||
|
{checkinMutation.isPending ? "打卡中..." : "立即打卡"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 shrink-0">
|
||||||
|
<div className="text-center px-3 py-2 rounded-lg bg-white/80">
|
||||||
|
<p className="text-2xl font-bold text-primary">{user?.currentStreak || todayCheckin?.streakCount || 0}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">连续天数</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center px-3 py-2 rounded-lg bg-white/80">
|
||||||
|
<p className="text-2xl font-bold text-orange-500">{user?.longestStreak || 0}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">最长连续</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Calendar heatmap */}
|
||||||
|
<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" />
|
||||||
|
打卡日历(近60天)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-10 sm:grid-cols-15 lg:grid-cols-20 gap-1">
|
||||||
|
{heatmapData.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
title={`${d.date}${d.checked ? ` · 连续${d.streak}天` : ""}`}
|
||||||
|
className={`aspect-square rounded-sm text-[9px] flex items-center justify-center transition-colors ${
|
||||||
|
d.checked
|
||||||
|
? d.streak >= 7 ? "bg-green-600 text-white" : d.streak >= 3 ? "bg-green-400 text-white" : "bg-green-200 text-green-800"
|
||||||
|
: "bg-muted/50 text-muted-foreground/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-muted/50" />未打卡</div>
|
||||||
|
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-200" />1-2天</div>
|
||||||
|
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-400" />3-6天</div>
|
||||||
|
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-600" />7天+</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Badges section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
<Award className="h-5 w-5 text-primary" />
|
||||||
|
成就徽章
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">已解锁 {earnedCount}/{totalCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-32 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${totalCount > 0 ? (earnedCount / totalCount) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(groupedBadges).map(([category, items]) => {
|
||||||
|
const catInfo = categoryLabels[category] || { label: category, color: "bg-gray-100 text-gray-700" };
|
||||||
|
return (
|
||||||
|
<div key={category} className="mb-4">
|
||||||
|
<Badge className={`${catInfo.color} mb-2 text-xs`}>{catInfo.label}</Badge>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
{items.map((badge: any) => (
|
||||||
|
<Card key={badge.key} className={`border-0 shadow-sm transition-all ${
|
||||||
|
badge.earned ? "bg-white" : "bg-muted/30 opacity-60"
|
||||||
|
}`}>
|
||||||
|
<CardContent className="p-3 text-center">
|
||||||
|
<div className="text-3xl mb-1">{badge.icon}</div>
|
||||||
|
<p className="text-xs font-medium truncate">{badge.name}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">{badge.description}</p>
|
||||||
|
{badge.earned ? (
|
||||||
|
<p className="text-[10px] text-green-600 mt-1">
|
||||||
|
✅ {new Date(badge.earnedAt).toLocaleDateString("zh-CN")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-1 mt-1 text-[10px] text-muted-foreground">
|
||||||
|
<Lock className="h-2.5 w-2.5" />未解锁
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
client/src/pages/Leaderboard.tsx
普通文件
166
client/src/pages/Leaderboard.tsx
普通文件
@@ -0,0 +1,166 @@
|
|||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Trophy, Clock, Zap, Target, Crown, Medal, Award } from "lucide-react";
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
type SortKey = "ntrpRating" | "totalMinutes" | "totalSessions" | "totalShots";
|
||||||
|
|
||||||
|
const tabConfig: { key: SortKey; label: string; icon: React.ReactNode; unit: string }[] = [
|
||||||
|
{ key: "ntrpRating", label: "NTRP评分", icon: <Trophy className="h-4 w-4" />, unit: "" },
|
||||||
|
{ key: "totalMinutes", label: "训练时长", icon: <Clock className="h-4 w-4" />, unit: "分钟" },
|
||||||
|
{ key: "totalSessions", label: "训练次数", icon: <Target className="h-4 w-4" />, unit: "次" },
|
||||||
|
{ key: "totalShots", label: "总击球数", icon: <Zap className="h-4 w-4" />, unit: "次" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rankIcons = [
|
||||||
|
<Crown className="h-5 w-5 text-yellow-500" />,
|
||||||
|
<Medal className="h-5 w-5 text-gray-400" />,
|
||||||
|
<Award className="h-5 w-5 text-amber-600" />,
|
||||||
|
];
|
||||||
|
|
||||||
|
const skillLevelMap: Record<string, string> = {
|
||||||
|
beginner: "初级",
|
||||||
|
intermediate: "中级",
|
||||||
|
advanced: "高级",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Leaderboard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [sortBy, setSortBy] = useState<SortKey>("ntrpRating");
|
||||||
|
const { data: leaderboard, isLoading } = trpc.leaderboard.get.useQuery({ sortBy, limit: 50 });
|
||||||
|
|
||||||
|
const myRank = useMemo(() => {
|
||||||
|
if (!leaderboard || !user) return null;
|
||||||
|
const idx = leaderboard.findIndex((u: any) => u.id === user.id);
|
||||||
|
return idx >= 0 ? idx + 1 : null;
|
||||||
|
}, [leaderboard, user]);
|
||||||
|
|
||||||
|
const getValue = (item: any, key: SortKey) => {
|
||||||
|
const v = item[key] ?? 0;
|
||||||
|
return key === "ntrpRating" ? (v as number).toFixed(1) : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
{[1, 2, 3, 4, 5].map(i => <Skeleton key={i} className="h-16 w-full" />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
与其他球友比较训练成果
|
||||||
|
{myRank && <span className="ml-2 text-primary font-medium">· 您当前排名第 {myRank} 名</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My rank card */}
|
||||||
|
{myRank && user && (
|
||||||
|
<Card className="border-primary/20 bg-primary/5 shadow-sm">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-lg">
|
||||||
|
#{myRank}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-semibold">{user.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">我的排名</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xl font-bold text-primary">{getValue(leaderboard?.find((u: any) => u.id === user.id) || {}, sortBy)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{tabConfig.find(t => t.key === sortBy)?.unit}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs value={sortBy} onValueChange={(v) => setSortBy(v as SortKey)}>
|
||||||
|
<TabsList className="grid grid-cols-2 lg:grid-cols-4 w-full">
|
||||||
|
{tabConfig.map(tab => (
|
||||||
|
<TabsTrigger key={tab.key} value={tab.key} className="gap-1.5 text-xs sm:text-sm">
|
||||||
|
{tab.icon}{tab.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{tabConfig.map(tab => (
|
||||||
|
<TabsContent key={tab.key} value={tab.key}>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{(!leaderboard || leaderboard.length === 0) ? (
|
||||||
|
<div className="py-16 text-center text-muted-foreground">
|
||||||
|
<Trophy className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>暂无排行数据</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{leaderboard.map((item: any, idx: number) => {
|
||||||
|
const isMe = user && item.id === user.id;
|
||||||
|
return (
|
||||||
|
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 transition-colors ${isMe ? "bg-primary/5" : "hover:bg-muted/50"}`}>
|
||||||
|
{/* Rank */}
|
||||||
|
<div className="w-8 text-center shrink-0">
|
||||||
|
{idx < 3 ? rankIcons[idx] : (
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{idx + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<Avatar className="h-9 w-9 shrink-0">
|
||||||
|
<AvatarFallback className={`text-xs font-medium ${idx < 3 ? "bg-primary/10 text-primary" : ""}`}>
|
||||||
|
{(item.name || "U").slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className={`text-sm font-medium truncate ${isMe ? "text-primary" : ""}`}>
|
||||||
|
{item.name || "匿名用户"}
|
||||||
|
</p>
|
||||||
|
{isMe && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">我</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>NTRP {(item.ntrpRating || 1.5).toFixed(1)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{skillLevelMap[item.skillLevel || "beginner"] || "初级"}</span>
|
||||||
|
{(item.currentStreak || 0) > 0 && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="text-orange-500">🔥{item.currentStreak}天</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className={`text-lg font-bold ${idx < 3 ? "text-primary" : ""}`}>
|
||||||
|
{getValue(item, tab.key)}
|
||||||
|
</p>
|
||||||
|
{tab.unit && <p className="text-[10px] text-muted-foreground">{tab.unit}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
572
client/src/pages/LiveCamera.tsx
普通文件
572
client/src/pages/LiveCamera.tsx
普通文件
@@ -0,0 +1,572 @@
|
|||||||
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Camera, CameraOff, RotateCcw, CheckCircle2, AlertTriangle, Smartphone, Monitor, FlipHorizontal, Zap, Activity } from "lucide-react";
|
||||||
|
import { useRef, useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
type CameraFacing = "user" | "environment";
|
||||||
|
|
||||||
|
interface PoseScore {
|
||||||
|
overall: number;
|
||||||
|
posture: number;
|
||||||
|
balance: number;
|
||||||
|
armAngle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera position guide steps
|
||||||
|
const SETUP_STEPS = [
|
||||||
|
{ title: "放置设备", desc: "将手机/平板固定在三脚架或稳定平面上", icon: <Smartphone className="h-6 w-6" /> },
|
||||||
|
{ title: "调整距离", desc: "确保摄像头能拍到全身(距离2-3米)", icon: <Monitor className="h-6 w-6" /> },
|
||||||
|
{ title: "调整高度", desc: "摄像头高度约在腰部位置,略微仰角", icon: <Camera className="h-6 w-6" /> },
|
||||||
|
{ title: "确认画面", desc: "确保光线充足,背景简洁,全身可见", icon: <CheckCircle2 className="h-6 w-6" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function LiveCamera() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const poseRef = useRef<any>(null);
|
||||||
|
const animFrameRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const [cameraActive, setCameraActive] = useState(false);
|
||||||
|
const [facing, setFacing] = useState<CameraFacing>("environment");
|
||||||
|
const [showSetupGuide, setShowSetupGuide] = useState(true);
|
||||||
|
const [setupStep, setSetupStep] = useState(0);
|
||||||
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
|
const [liveScore, setLiveScore] = useState<PoseScore | null>(null);
|
||||||
|
const [frameCount, setFrameCount] = useState(0);
|
||||||
|
const [fps, setFps] = useState(0);
|
||||||
|
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
|
||||||
|
const [exerciseType, setExerciseType] = useState("forehand");
|
||||||
|
const [feedback, setFeedback] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Check available cameras
|
||||||
|
useEffect(() => {
|
||||||
|
navigator.mediaDevices?.enumerateDevices().then(devices => {
|
||||||
|
const cameras = devices.filter(d => d.kind === "videoinput");
|
||||||
|
setHasMultipleCameras(cameras.length > 1);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// FPS counter
|
||||||
|
useEffect(() => {
|
||||||
|
if (!analyzing) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setFps(prev => {
|
||||||
|
const current = frameCount;
|
||||||
|
setFrameCount(0);
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [analyzing, frameCount]);
|
||||||
|
|
||||||
|
const startCamera = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Stop existing stream
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||||
|
const constraints: MediaStreamConstraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: facing,
|
||||||
|
width: { ideal: isMobile ? 1280 : 1920 },
|
||||||
|
height: { ideal: isMobile ? 720 : 1080 },
|
||||||
|
frameRate: { ideal: 30, max: 30 },
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
await videoRef.current.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCameraActive(true);
|
||||||
|
toast.success("摄像头已启动");
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Camera error:", err);
|
||||||
|
if (err.name === "NotAllowedError") {
|
||||||
|
toast.error("请允许摄像头访问权限");
|
||||||
|
} else if (err.name === "NotFoundError") {
|
||||||
|
toast.error("未找到摄像头设备");
|
||||||
|
} else {
|
||||||
|
toast.error("摄像头启动失败: " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [facing]);
|
||||||
|
|
||||||
|
const stopCamera = useCallback(() => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
if (animFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animFrameRef.current);
|
||||||
|
}
|
||||||
|
setCameraActive(false);
|
||||||
|
setAnalyzing(false);
|
||||||
|
setLiveScore(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchCamera = useCallback(() => {
|
||||||
|
const newFacing = facing === "user" ? "environment" : "user";
|
||||||
|
setFacing(newFacing);
|
||||||
|
if (cameraActive) {
|
||||||
|
stopCamera();
|
||||||
|
setTimeout(() => startCamera(), 300);
|
||||||
|
}
|
||||||
|
}, [facing, cameraActive, stopCamera, startCamera]);
|
||||||
|
|
||||||
|
// Start pose analysis
|
||||||
|
const startAnalysis = useCallback(async () => {
|
||||||
|
if (!videoRef.current || !canvasRef.current) return;
|
||||||
|
|
||||||
|
setAnalyzing(true);
|
||||||
|
toast.info("正在加载姿势识别模型...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { Pose } = await import("@mediapipe/pose");
|
||||||
|
const { drawConnectors, drawLandmarks } = await import("@mediapipe/drawing_utils");
|
||||||
|
|
||||||
|
const pose = new Pose({
|
||||||
|
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
pose.setOptions({
|
||||||
|
modelComplexity: 1,
|
||||||
|
smoothLandmarks: true,
|
||||||
|
enableSegmentation: false,
|
||||||
|
minDetectionConfidence: 0.5,
|
||||||
|
minTrackingConfidence: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const POSE_CONNECTIONS = [
|
||||||
|
[11, 12], [11, 13], [13, 15], [12, 14], [14, 16],
|
||||||
|
[11, 23], [12, 24], [23, 24], [23, 25], [24, 26],
|
||||||
|
[25, 27], [26, 28], [15, 17], [16, 18], [15, 19],
|
||||||
|
[16, 20], [17, 19], [18, 20],
|
||||||
|
];
|
||||||
|
|
||||||
|
pose.onResults((results: any) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas?.getContext("2d");
|
||||||
|
if (!canvas || !ctx || !videoRef.current) return;
|
||||||
|
|
||||||
|
canvas.width = videoRef.current.videoWidth;
|
||||||
|
canvas.height = videoRef.current.videoHeight;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(videoRef.current, 0, 0);
|
||||||
|
|
||||||
|
if (results.poseLandmarks) {
|
||||||
|
// Draw skeleton
|
||||||
|
const landmarks = results.poseLandmarks;
|
||||||
|
|
||||||
|
// Draw connections
|
||||||
|
ctx.strokeStyle = "rgba(0, 200, 100, 0.8)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
POSE_CONNECTIONS.forEach(([a, b]) => {
|
||||||
|
const la = landmarks[a];
|
||||||
|
const lb = landmarks[b];
|
||||||
|
if (la && lb && la.visibility > 0.3 && lb.visibility > 0.3) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(la.x * canvas.width, la.y * canvas.height);
|
||||||
|
ctx.lineTo(lb.x * canvas.width, lb.y * canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw landmarks
|
||||||
|
landmarks.forEach((lm: any, i: number) => {
|
||||||
|
if (lm.visibility > 0.3) {
|
||||||
|
ctx.fillStyle = [11, 12, 13, 14, 15, 16].includes(i) ? "#ff4444" : "#00cc66";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(lm.x * canvas.width, lm.y * canvas.height, 5, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate live scores
|
||||||
|
const score = calculateLiveScore(landmarks, exerciseType);
|
||||||
|
setLiveScore(score);
|
||||||
|
setFeedback(generateLiveFeedback(landmarks, exerciseType));
|
||||||
|
setFrameCount(prev => prev + 1);
|
||||||
|
|
||||||
|
// Draw score overlay
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
||||||
|
ctx.fillRect(10, 10, 180, 40);
|
||||||
|
ctx.fillStyle = "#fff";
|
||||||
|
ctx.font = "bold 16px sans-serif";
|
||||||
|
ctx.fillText(`评分: ${score.overall}/100`, 20, 35);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
poseRef.current = pose;
|
||||||
|
|
||||||
|
const processFrame = async () => {
|
||||||
|
if (!videoRef.current || !analyzing) return;
|
||||||
|
if (videoRef.current.readyState >= 2) {
|
||||||
|
await pose.send({ image: videoRef.current });
|
||||||
|
}
|
||||||
|
animFrameRef.current = requestAnimationFrame(processFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.success("模型加载完成,开始实时分析");
|
||||||
|
processFrame();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Pose init error:", err);
|
||||||
|
toast.error("姿势识别模型加载失败");
|
||||||
|
setAnalyzing(false);
|
||||||
|
}
|
||||||
|
}, [analyzing, exerciseType]);
|
||||||
|
|
||||||
|
const stopAnalysis = useCallback(() => {
|
||||||
|
if (animFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animFrameRef.current);
|
||||||
|
}
|
||||||
|
setAnalyzing(false);
|
||||||
|
setLiveScore(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopCamera();
|
||||||
|
};
|
||||||
|
}, [stopCamera]);
|
||||||
|
|
||||||
|
// Setup guide dialog
|
||||||
|
const handleSetupComplete = () => {
|
||||||
|
setShowSetupGuide(false);
|
||||||
|
startCamera();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">实时姿势分析</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">通过摄像头实时捕捉并分析您的挥拍动作</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={exerciseType} onValueChange={setExerciseType}>
|
||||||
|
<SelectTrigger className="w-[120px] h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="forehand">正手挥拍</SelectItem>
|
||||||
|
<SelectItem value="backhand">反手挥拍</SelectItem>
|
||||||
|
<SelectItem value="serve">发球</SelectItem>
|
||||||
|
<SelectItem value="volley">截击</SelectItem>
|
||||||
|
<SelectItem value="footwork">脚步移动</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera position setup guide */}
|
||||||
|
<Dialog open={showSetupGuide} onOpenChange={setShowSetupGuide}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Camera className="h-5 w-5 text-primary" />
|
||||||
|
摄像头位置设置
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
为获得最佳分析效果,请按以下步骤调整设备位置
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{SETUP_STEPS.map((step, idx) => (
|
||||||
|
<div key={idx} className={`flex items-start gap-3 p-3 rounded-lg transition-colors ${
|
||||||
|
idx === setupStep ? "bg-primary/10 border border-primary/20" : idx < setupStep ? "bg-green-50" : "bg-muted/30"
|
||||||
|
}`}>
|
||||||
|
<div className={`h-10 w-10 rounded-full flex items-center justify-center shrink-0 ${
|
||||||
|
idx < setupStep ? "bg-green-100 text-green-600" : idx === setupStep ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{idx < setupStep ? <CheckCircle2 className="h-5 w-5" /> : step.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{step.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{step.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex gap-2">
|
||||||
|
{setupStep > 0 && (
|
||||||
|
<Button variant="outline" onClick={() => setSetupStep(s => s - 1)}>上一步</Button>
|
||||||
|
)}
|
||||||
|
{setupStep < SETUP_STEPS.length - 1 ? (
|
||||||
|
<Button onClick={() => setSetupStep(s => s + 1)}>下一步</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleSetupComplete} className="gap-2">
|
||||||
|
<Camera className="h-4 w-4" />开始使用摄像头
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Main camera view */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<Card className="border-0 shadow-sm overflow-hidden">
|
||||||
|
<CardContent className="p-0 relative">
|
||||||
|
<div className="relative bg-black aspect-video w-full">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className={`absolute inset-0 w-full h-full object-contain ${analyzing ? "opacity-0" : ""}`}
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`absolute inset-0 w-full h-full object-contain ${analyzing ? "" : "hidden"}`}
|
||||||
|
/>
|
||||||
|
{!cameraActive && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-white/60">
|
||||||
|
<CameraOff className="h-12 w-12 mb-3" />
|
||||||
|
<p className="text-sm">摄像头未启动</p>
|
||||||
|
<Button variant="secondary" className="mt-3 gap-2" onClick={() => setShowSetupGuide(true)}>
|
||||||
|
<Camera className="h-4 w-4" />启动摄像头
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* FPS indicator */}
|
||||||
|
{analyzing && (
|
||||||
|
<div className="absolute top-3 right-3 bg-black/60 text-white text-xs px-2 py-1 rounded">
|
||||||
|
{fps} FPS
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls bar */}
|
||||||
|
<div className="flex items-center justify-center gap-3 p-3 bg-muted/30 flex-wrap">
|
||||||
|
{!cameraActive ? (
|
||||||
|
<Button onClick={() => setShowSetupGuide(true)} className="gap-2">
|
||||||
|
<Camera className="h-4 w-4" />启动摄像头
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={stopCamera} className="gap-1.5">
|
||||||
|
<CameraOff className="h-3.5 w-3.5" />关闭
|
||||||
|
</Button>
|
||||||
|
{hasMultipleCameras && (
|
||||||
|
<Button variant="outline" size="sm" onClick={switchCamera} className="gap-1.5">
|
||||||
|
<FlipHorizontal className="h-3.5 w-3.5" />
|
||||||
|
{facing === "user" ? "后置" : "前置"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!analyzing ? (
|
||||||
|
<Button size="sm" onClick={startAnalysis} className="gap-1.5">
|
||||||
|
<Zap className="h-3.5 w-3.5" />开始分析
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="destructive" size="sm" onClick={stopAnalysis} className="gap-1.5">
|
||||||
|
<Activity className="h-3.5 w-3.5" />停止分析
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowSetupGuide(true)} className="gap-1.5">
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />重新调整
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live score panel */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4 text-primary" />实时评分
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{liveScore ? (
|
||||||
|
<>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-4xl font-bold text-primary">{liveScore.overall}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">综合评分</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ScoreBar label="姿势" value={liveScore.posture} />
|
||||||
|
<ScoreBar label="平衡" value={liveScore.balance} />
|
||||||
|
<ScoreBar label="手臂" value={liveScore.armAngle} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-muted-foreground text-sm">
|
||||||
|
<Activity className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>开始分析后显示实时评分</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-orange-500" />实时反馈
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{feedback.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{feedback.map((f, i) => (
|
||||||
|
<div key={i} className="text-xs p-2 rounded bg-orange-50 text-orange-700 border border-orange-100">
|
||||||
|
{f}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-4">
|
||||||
|
分析中将显示实时矫正建议
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tips card */}
|
||||||
|
<Card className="border-0 shadow-sm bg-blue-50/50">
|
||||||
|
<CardContent className="py-3">
|
||||||
|
<p className="text-xs font-medium text-blue-700 mb-1">📱 移动端提示</p>
|
||||||
|
<ul className="text-[11px] text-blue-600 space-y-1">
|
||||||
|
<li>· 横屏模式效果更佳</li>
|
||||||
|
<li>· 使用后置摄像头获得更高画质</li>
|
||||||
|
<li>· 保持2-3米拍摄距离</li>
|
||||||
|
<li>· 确保光线充足</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score bar component
|
||||||
|
function ScoreBar({ label, value }: { label: string; value: number }) {
|
||||||
|
const color = value >= 80 ? "bg-green-500" : value >= 60 ? "bg-yellow-500" : "bg-red-500";
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs mb-0.5">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium">{Math.round(value)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${color} rounded-full transition-all duration-300`} style={{ width: `${value}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live score calculation from landmarks
|
||||||
|
function calculateLiveScore(landmarks: any[], exerciseType: string): PoseScore {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shoulder alignment (posture)
|
||||||
|
const leftShoulder = landmarks[11];
|
||||||
|
const rightShoulder = landmarks[12];
|
||||||
|
const shoulderDiff = Math.abs(leftShoulder.y - rightShoulder.y);
|
||||||
|
const postureScore = Math.max(0, 100 - shoulderDiff * 500);
|
||||||
|
|
||||||
|
// Hip alignment (balance)
|
||||||
|
const leftHip = landmarks[23];
|
||||||
|
const rightHip = landmarks[24];
|
||||||
|
const hipDiff = Math.abs(leftHip.y - rightHip.y);
|
||||||
|
const balanceScore = Math.max(0, 100 - hipDiff * 500);
|
||||||
|
|
||||||
|
// Arm angle based on exercise type
|
||||||
|
let armScore = 70;
|
||||||
|
if (exerciseType === "forehand" || exerciseType === "backhand") {
|
||||||
|
const shoulder = exerciseType === "forehand" ? landmarks[12] : landmarks[11];
|
||||||
|
const elbow = exerciseType === "forehand" ? landmarks[14] : landmarks[13];
|
||||||
|
const wrist = exerciseType === "forehand" ? landmarks[16] : landmarks[15];
|
||||||
|
const angle = getAngle(shoulder, elbow, wrist);
|
||||||
|
// Ideal forehand/backhand elbow angle: 90-150 degrees
|
||||||
|
if (angle >= 90 && angle <= 150) armScore = 90 + (1 - Math.abs(angle - 120) / 30) * 10;
|
||||||
|
else armScore = Math.max(30, 90 - Math.abs(angle - 120));
|
||||||
|
} else if (exerciseType === "serve") {
|
||||||
|
const rightElbow = landmarks[14];
|
||||||
|
const rightShoulder2 = landmarks[12];
|
||||||
|
const rightWrist = landmarks[16];
|
||||||
|
const angle = getAngle(rightShoulder2, rightElbow, rightWrist);
|
||||||
|
if (angle >= 150 && angle <= 180) armScore = 95;
|
||||||
|
else armScore = Math.max(40, 95 - Math.abs(angle - 165) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall = Math.round(postureScore * 0.3 + balanceScore * 0.3 + armScore * 0.4);
|
||||||
|
|
||||||
|
return {
|
||||||
|
overall: Math.min(100, Math.max(0, overall)),
|
||||||
|
posture: Math.min(100, Math.max(0, Math.round(postureScore))),
|
||||||
|
balance: Math.min(100, Math.max(0, Math.round(balanceScore))),
|
||||||
|
armAngle: Math.min(100, Math.max(0, Math.round(armScore))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate live feedback
|
||||||
|
function generateLiveFeedback(landmarks: any[], exerciseType: string): string[] {
|
||||||
|
const tips: string[] = [];
|
||||||
|
const leftShoulder = landmarks[11];
|
||||||
|
const rightShoulder = landmarks[12];
|
||||||
|
|
||||||
|
if (Math.abs(leftShoulder.y - rightShoulder.y) > 0.05) {
|
||||||
|
tips.push("⚠️ 双肩不平衡,注意保持肩膀水平");
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftHip = landmarks[23];
|
||||||
|
const rightHip = landmarks[24];
|
||||||
|
if (Math.abs(leftHip.y - rightHip.y) > 0.05) {
|
||||||
|
tips.push("⚠️ 重心不稳,注意保持髋部水平");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nose = landmarks[0];
|
||||||
|
const midShoulder = { x: (leftShoulder.x + rightShoulder.x) / 2, y: (leftShoulder.y + rightShoulder.y) / 2 };
|
||||||
|
if (Math.abs(nose.x - midShoulder.x) > 0.08) {
|
||||||
|
tips.push("⚠️ 头部偏移,保持头部在身体中心线上");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exerciseType === "forehand") {
|
||||||
|
const rightElbow = landmarks[14];
|
||||||
|
const rightWrist = landmarks[16];
|
||||||
|
if (rightElbow.y > rightShoulder.y + 0.15) {
|
||||||
|
tips.push("💡 正手:抬高肘部,保持手臂在肩膀高度");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exerciseType === "serve") {
|
||||||
|
const rightWrist = landmarks[16];
|
||||||
|
if (rightWrist.y > rightShoulder.y) {
|
||||||
|
tips.push("💡 发球:手臂需要充分伸展向上");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tips.length === 0) {
|
||||||
|
tips.push("✅ 姿势良好,继续保持!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return tips;
|
||||||
|
}
|
||||||
687
client/src/pages/Recorder.tsx
普通文件
687
client/src/pages/Recorder.tsx
普通文件
@@ -0,0 +1,687 @@
|
|||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Video, VideoOff, Circle, Square, Scissors, Download, Upload, Camera,
|
||||||
|
FlipHorizontal, Settings, Wifi, WifiOff, AlertTriangle, CheckCircle2,
|
||||||
|
Play, Pause, SkipForward, SkipBack, Trash2, Save, Loader2
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRef, useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
type RecordingState = "idle" | "recording" | "paused" | "stopped" | "processing";
|
||||||
|
|
||||||
|
interface ClipSegment {
|
||||||
|
id: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
duration: number;
|
||||||
|
isKeyMoment: boolean;
|
||||||
|
label: string;
|
||||||
|
blob?: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable bitrate configs
|
||||||
|
const QUALITY_PRESETS = {
|
||||||
|
low: { videoBitsPerSecond: 500_000, label: "低画质 (500kbps)", desc: "适合弱网环境" },
|
||||||
|
medium: { videoBitsPerSecond: 1_500_000, label: "中画质 (1.5Mbps)", desc: "推荐日常使用" },
|
||||||
|
high: { videoBitsPerSecond: 3_000_000, label: "高画质 (3Mbps)", desc: "WiFi环境推荐" },
|
||||||
|
ultra: { videoBitsPerSecond: 5_000_000, label: "超高画质 (5Mbps)", desc: "最佳分析效果" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Recorder() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const previewRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const recordingStartRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const [state, setState] = useState<RecordingState>("idle");
|
||||||
|
const [facing, setFacing] = useState<"user" | "environment">("environment");
|
||||||
|
const [quality, setQuality] = useState<keyof typeof QUALITY_PRESETS>("medium");
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||||
|
const [recordedUrl, setRecordedUrl] = useState<string>("");
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
|
const [reconnecting, setReconnecting] = useState(false);
|
||||||
|
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
|
||||||
|
const [cameraActive, setCameraActive] = useState(false);
|
||||||
|
|
||||||
|
// Auto-clip state
|
||||||
|
const [clips, setClips] = useState<ClipSegment[]>([]);
|
||||||
|
const [showClipEditor, setShowClipEditor] = useState(false);
|
||||||
|
const [clipRange, setClipRange] = useState<[number, number]>([0, 100]);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
|
const uploadMutation = trpc.video.upload.useMutation();
|
||||||
|
|
||||||
|
// Check cameras
|
||||||
|
useEffect(() => {
|
||||||
|
navigator.mediaDevices?.enumerateDevices().then(devices => {
|
||||||
|
setHasMultipleCameras(devices.filter(d => d.kind === "videoinput").length > 1);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Online/offline detection for reconnect
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => {
|
||||||
|
setIsOnline(true);
|
||||||
|
if (reconnecting) {
|
||||||
|
toast.success("网络已恢复");
|
||||||
|
setReconnecting(false);
|
||||||
|
attemptReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleOffline = () => {
|
||||||
|
setIsOnline(false);
|
||||||
|
toast.warning("网络断开,录制数据已缓存");
|
||||||
|
};
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
window.addEventListener("offline", handleOffline);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
window.removeEventListener("offline", handleOffline);
|
||||||
|
};
|
||||||
|
}, [reconnecting]);
|
||||||
|
|
||||||
|
// Duration timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "recording") return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDuration(Math.floor((Date.now() - recordingStartRef.current) / 1000));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
if (recordedUrl) URL.revokeObjectURL(recordedUrl);
|
||||||
|
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [recordedUrl]);
|
||||||
|
|
||||||
|
const startCamera = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
|
||||||
|
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: facing,
|
||||||
|
width: { ideal: isMobile ? 1280 : 1920 },
|
||||||
|
height: { ideal: isMobile ? 720 : 1080 },
|
||||||
|
frameRate: { ideal: 30 },
|
||||||
|
},
|
||||||
|
audio: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
streamRef.current = stream;
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
await videoRef.current.play();
|
||||||
|
}
|
||||||
|
setCameraActive(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("摄像头启动失败: " + (err.message || "未知错误"));
|
||||||
|
}
|
||||||
|
}, [facing]);
|
||||||
|
|
||||||
|
const stopCamera = useCallback(() => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
setCameraActive(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchCamera = useCallback(async () => {
|
||||||
|
const newFacing = facing === "user" ? "environment" : "user";
|
||||||
|
setFacing(newFacing);
|
||||||
|
if (cameraActive && state === "idle") {
|
||||||
|
stopCamera();
|
||||||
|
setTimeout(() => startCamera(), 200);
|
||||||
|
}
|
||||||
|
}, [facing, cameraActive, state, stopCamera, startCamera]);
|
||||||
|
|
||||||
|
// Reconnect logic with exponential backoff
|
||||||
|
const attemptReconnect = useCallback(async () => {
|
||||||
|
const maxAttempts = 5;
|
||||||
|
if (reconnectAttempts >= maxAttempts) {
|
||||||
|
toast.error("重连失败,请手动重新开始");
|
||||||
|
setReconnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReconnecting(true);
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000);
|
||||||
|
|
||||||
|
reconnectTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await startCamera();
|
||||||
|
setReconnecting(false);
|
||||||
|
setReconnectAttempts(0);
|
||||||
|
toast.success("摄像头重连成功");
|
||||||
|
|
||||||
|
// Resume recording if was recording
|
||||||
|
if (state === "recording") {
|
||||||
|
startRecordingInternal();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setReconnectAttempts(prev => prev + 1);
|
||||||
|
attemptReconnect();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}, [reconnectAttempts, startCamera, state]);
|
||||||
|
|
||||||
|
const startRecordingInternal = useCallback(() => {
|
||||||
|
if (!streamRef.current) return;
|
||||||
|
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported("video/webm;codecs=vp9,opus")
|
||||||
|
? "video/webm;codecs=vp9,opus"
|
||||||
|
: MediaRecorder.isTypeSupported("video/webm;codecs=vp8,opus")
|
||||||
|
? "video/webm;codecs=vp8,opus"
|
||||||
|
: "video/webm";
|
||||||
|
|
||||||
|
const recorder = new MediaRecorder(streamRef.current, {
|
||||||
|
mimeType,
|
||||||
|
videoBitsPerSecond: QUALITY_PRESETS[quality].videoBitsPerSecond,
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) {
|
||||||
|
chunksRef.current.push(e.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onerror = () => {
|
||||||
|
toast.error("录制出错,尝试重连...");
|
||||||
|
attemptReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||||
|
setRecordedBlob(blob);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setRecordedUrl(url);
|
||||||
|
|
||||||
|
// Auto-generate clips
|
||||||
|
autoGenerateClips(blob);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect data every 1 second for stability
|
||||||
|
recorder.start(1000);
|
||||||
|
mediaRecorderRef.current = recorder;
|
||||||
|
}, [quality, attemptReconnect]);
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
if (!cameraActive) await startCamera();
|
||||||
|
|
||||||
|
chunksRef.current = [];
|
||||||
|
setRecordedBlob(null);
|
||||||
|
setRecordedUrl("");
|
||||||
|
setClips([]);
|
||||||
|
recordingStartRef.current = Date.now();
|
||||||
|
setDuration(0);
|
||||||
|
|
||||||
|
startRecordingInternal();
|
||||||
|
setState("recording");
|
||||||
|
toast.success("开始录制");
|
||||||
|
}, [cameraActive, startCamera, startRecordingInternal]);
|
||||||
|
|
||||||
|
const pauseRecording = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current?.state === "recording") {
|
||||||
|
mediaRecorderRef.current.pause();
|
||||||
|
setState("paused");
|
||||||
|
toast.info("录制已暂停");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resumeRecording = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current?.state === "paused") {
|
||||||
|
mediaRecorderRef.current.resume();
|
||||||
|
setState("recording");
|
||||||
|
toast.info("继续录制");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
setState("stopped");
|
||||||
|
toast.success("录制完成");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-generate clips based on duration
|
||||||
|
const autoGenerateClips = useCallback((blob: Blob) => {
|
||||||
|
const totalDuration = duration;
|
||||||
|
if (totalDuration < 5) return;
|
||||||
|
|
||||||
|
const segmentLength = Math.min(15, Math.max(5, Math.floor(totalDuration / 4)));
|
||||||
|
const generatedClips: ClipSegment[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalDuration; i += segmentLength) {
|
||||||
|
const end = Math.min(i + segmentLength, totalDuration);
|
||||||
|
generatedClips.push({
|
||||||
|
id: `clip-${i}`,
|
||||||
|
startTime: i,
|
||||||
|
endTime: end,
|
||||||
|
duration: end - i,
|
||||||
|
isKeyMoment: i === 0 || i === Math.floor(totalDuration / 2),
|
||||||
|
label: `片段 ${generatedClips.length + 1}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setClips(generatedClips);
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
// Trim/clip video using canvas
|
||||||
|
const trimVideo = useCallback(async () => {
|
||||||
|
if (!recordedBlob || !previewRef.current) return;
|
||||||
|
setProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const totalDuration = duration;
|
||||||
|
const startSec = (clipRange[0] / 100) * totalDuration;
|
||||||
|
const endSec = (clipRange[1] / 100) * totalDuration;
|
||||||
|
|
||||||
|
// Use MediaSource approach - create trimmed blob from chunks
|
||||||
|
const startChunk = Math.floor(startSec);
|
||||||
|
const endChunk = Math.ceil(endSec);
|
||||||
|
const trimmedChunks = chunksRef.current.slice(
|
||||||
|
Math.max(0, startChunk),
|
||||||
|
Math.min(chunksRef.current.length, endChunk)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (trimmedChunks.length > 0) {
|
||||||
|
const trimmedBlob = new Blob(trimmedChunks, { type: recordedBlob.type });
|
||||||
|
setRecordedBlob(trimmedBlob);
|
||||||
|
if (recordedUrl) URL.revokeObjectURL(recordedUrl);
|
||||||
|
setRecordedUrl(URL.createObjectURL(trimmedBlob));
|
||||||
|
toast.success(`已裁剪: ${startSec.toFixed(1)}s - ${endSec.toFixed(1)}s`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("裁剪失败");
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
}, [recordedBlob, clipRange, duration, recordedUrl]);
|
||||||
|
|
||||||
|
// Upload video
|
||||||
|
const handleUpload = useCallback(async () => {
|
||||||
|
if (!recordedBlob || !user) return;
|
||||||
|
if (!title.trim()) {
|
||||||
|
toast.error("请输入视频标题");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert to base64 in chunks for progress
|
||||||
|
const reader = new FileReader();
|
||||||
|
const base64Promise = new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(",")[1] || result;
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
setUploadProgress(Math.round((e.loaded / e.total) * 50));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(recordedBlob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const base64 = await base64Promise;
|
||||||
|
setUploadProgress(60);
|
||||||
|
|
||||||
|
await uploadMutation.mutateAsync({
|
||||||
|
title: title.trim(),
|
||||||
|
format: "webm",
|
||||||
|
fileSize: recordedBlob.size,
|
||||||
|
exerciseType: "recording",
|
||||||
|
fileBase64: base64,
|
||||||
|
});
|
||||||
|
|
||||||
|
setUploadProgress(100);
|
||||||
|
toast.success("视频上传成功!");
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
setTimeout(() => {
|
||||||
|
setRecordedBlob(null);
|
||||||
|
setRecordedUrl("");
|
||||||
|
setTitle("");
|
||||||
|
setUploadProgress(0);
|
||||||
|
setState("idle");
|
||||||
|
}, 1500);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("上传失败: " + (err.message || "未知错误"));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}, [recordedBlob, title, user, uploadMutation]);
|
||||||
|
|
||||||
|
const downloadVideo = useCallback(() => {
|
||||||
|
if (!recordedUrl) return;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = recordedUrl;
|
||||||
|
a.download = `tennis-${new Date().toISOString().slice(0, 10)}.webm`;
|
||||||
|
a.click();
|
||||||
|
}, [recordedUrl]);
|
||||||
|
|
||||||
|
const formatTime = (s: number) => {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">在线录制</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">录制训练视频,自动压缩和剪辑</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={isOnline ? "default" : "destructive"} className="gap-1">
|
||||||
|
{isOnline ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
|
||||||
|
{isOnline ? "在线" : "离线"}
|
||||||
|
</Badge>
|
||||||
|
{reconnecting && (
|
||||||
|
<Badge variant="outline" className="gap-1 text-orange-600">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />重连中...
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowSettings(true)} className="gap-1.5">
|
||||||
|
<Settings className="h-3.5 w-3.5" />设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings dialog */}
|
||||||
|
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>录制设置</DialogTitle>
|
||||||
|
<DialogDescription>调整录制画质和参数</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">画质选择</label>
|
||||||
|
{Object.entries(QUALITY_PRESETS).map(([key, preset]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
onClick={() => setQuality(key as keyof typeof QUALITY_PRESETS)}
|
||||||
|
className={`p-3 rounded-lg mb-2 cursor-pointer border transition-colors ${
|
||||||
|
quality === key ? "border-primary bg-primary/5" : "border-transparent bg-muted/30 hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{preset.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{preset.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setShowSettings(false)}>确定</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
{/* Camera/Preview */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="border-0 shadow-sm overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="relative bg-black aspect-video w-full">
|
||||||
|
{/* Live camera */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className={`absolute inset-0 w-full h-full object-contain ${state === "stopped" ? "hidden" : ""}`}
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
{/* Preview recorded */}
|
||||||
|
{state === "stopped" && recordedUrl && (
|
||||||
|
<video
|
||||||
|
ref={previewRef}
|
||||||
|
src={recordedUrl}
|
||||||
|
className="absolute inset-0 w-full h-full object-contain"
|
||||||
|
playsInline
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!cameraActive && state === "idle" && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-white/60">
|
||||||
|
<VideoOff className="h-12 w-12 mb-3" />
|
||||||
|
<p className="text-sm">点击启动摄像头开始录制</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recording indicator */}
|
||||||
|
{state === "recording" && (
|
||||||
|
<div className="absolute top-3 left-3 flex items-center gap-2 bg-red-600 text-white px-3 py-1.5 rounded-full text-sm">
|
||||||
|
<Circle className="h-3 w-3 fill-current animate-pulse" />
|
||||||
|
{formatTime(duration)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state === "paused" && (
|
||||||
|
<div className="absolute top-3 left-3 flex items-center gap-2 bg-yellow-600 text-white px-3 py-1.5 rounded-full text-sm">
|
||||||
|
<Pause className="h-3 w-3" />
|
||||||
|
已暂停 · {formatTime(duration)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quality badge */}
|
||||||
|
{(state === "recording" || state === "paused") && (
|
||||||
|
<div className="absolute top-3 right-3 bg-black/60 text-white text-xs px-2 py-1 rounded">
|
||||||
|
{QUALITY_PRESETS[quality].label.split(" ")[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 bg-muted/30 flex-wrap">
|
||||||
|
{state === "idle" && (
|
||||||
|
<>
|
||||||
|
{!cameraActive ? (
|
||||||
|
<Button onClick={startCamera} className="gap-2">
|
||||||
|
<Camera className="h-4 w-4" />启动摄像头
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button onClick={startRecording} className="gap-2 bg-red-600 hover:bg-red-700">
|
||||||
|
<Circle className="h-4 w-4 fill-current" />开始录制
|
||||||
|
</Button>
|
||||||
|
{hasMultipleCameras && (
|
||||||
|
<Button variant="outline" size="sm" onClick={switchCamera} className="gap-1.5">
|
||||||
|
<FlipHorizontal className="h-3.5 w-3.5" />{facing === "user" ? "后置" : "前置"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={stopCamera} className="gap-1.5">
|
||||||
|
<VideoOff className="h-3.5 w-3.5" />关闭
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "recording" && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={pauseRecording} className="gap-1.5">
|
||||||
|
<Pause className="h-3.5 w-3.5" />暂停
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={stopRecording} className="gap-1.5">
|
||||||
|
<Square className="h-3.5 w-3.5" />停止
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "paused" && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" onClick={resumeRecording} className="gap-1.5">
|
||||||
|
<Play className="h-3.5 w-3.5" />继续
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={stopRecording} className="gap-1.5">
|
||||||
|
<Square className="h-3.5 w-3.5" />停止
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "stopped" && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setState("idle"); setRecordedBlob(null); setRecordedUrl(""); }} className="gap-1.5">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />重录
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowClipEditor(true)} className="gap-1.5">
|
||||||
|
<Scissors className="h-3.5 w-3.5" />剪辑
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadVideo} className="gap-1.5">
|
||||||
|
<Download className="h-3.5 w-3.5" />下载
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Upload card */}
|
||||||
|
{state === "stopped" && recordedBlob && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4 text-primary" />上传视频
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="视频标题"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
大小: {(recordedBlob.size / 1024 / 1024).toFixed(2)} MB · 时长: {formatTime(duration)}
|
||||||
|
</div>
|
||||||
|
{uploading && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress value={uploadProgress} className="h-2" />
|
||||||
|
<p className="text-xs text-muted-foreground text-center">{uploadProgress}%</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploading || !title.trim()}
|
||||||
|
className="w-full gap-2"
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{uploading ? "上传中..." : "上传到视频库"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto-clips */}
|
||||||
|
{clips.length > 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Scissors className="h-4 w-4 text-primary" />自动剪辑片段
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{clips.map((clip) => (
|
||||||
|
<div key={clip.id} className={`p-2 rounded-lg text-xs flex items-center justify-between ${
|
||||||
|
clip.isKeyMoment ? "bg-primary/5 border border-primary/20" : "bg-muted/30"
|
||||||
|
}`}>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{clip.label}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{formatTime(clip.startTime)} - {formatTime(clip.endTime)} ({clip.duration}s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{clip.isKeyMoment && <Badge variant="secondary" className="text-[10px]">关键</Badge>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recording info */}
|
||||||
|
<Card className="border-0 shadow-sm bg-blue-50/50">
|
||||||
|
<CardContent className="py-3">
|
||||||
|
<p className="text-xs font-medium text-blue-700 mb-1">📹 录制提示</p>
|
||||||
|
<ul className="text-[11px] text-blue-600 space-y-1">
|
||||||
|
<li>· 录制自动使用稳定压缩流技术</li>
|
||||||
|
<li>· 断网时数据自动缓存,恢复后继续</li>
|
||||||
|
<li>· 支持暂停/继续录制</li>
|
||||||
|
<li>· 录制完成后可自动剪辑关键片段</li>
|
||||||
|
<li>· 建议横屏录制以获得最佳效果</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clip editor dialog */}
|
||||||
|
<Dialog open={showClipEditor} onOpenChange={setShowClipEditor}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Scissors className="h-5 w-5 text-primary" />视频剪辑
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>拖动滑块选择要保留的片段范围</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>开始: {formatTime(Math.floor((clipRange[0] / 100) * duration))}</span>
|
||||||
|
<span>结束: {formatTime(Math.floor((clipRange[1] / 100) * duration))}</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={clipRange}
|
||||||
|
onValueChange={(v) => setClipRange(v as [number, number])}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
保留时长: {formatTime(Math.floor(((clipRange[1] - clipRange[0]) / 100) * duration))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowClipEditor(false)}>取消</Button>
|
||||||
|
<Button onClick={() => { trimVideo(); setShowClipEditor(false); }} disabled={processing} className="gap-2">
|
||||||
|
{processing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Scissors className="h-4 w-4" />}
|
||||||
|
裁剪
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
docs/API.md
普通文件
340
docs/API.md
普通文件
@@ -0,0 +1,340 @@
|
|||||||
|
# Tennis Training Hub - API接口文档
|
||||||
|
|
||||||
|
本文档详细描述了Tennis Training Hub的所有tRPC API接口,包括输入参数、输出格式和认证要求。
|
||||||
|
|
||||||
|
## 认证说明
|
||||||
|
|
||||||
|
所有标记为 **需认证** 的接口需要用户已登录(通过Session Cookie)。未认证请求将返回 `UNAUTHORIZED` 错误。
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
### 1. 认证模块 (`auth`)
|
||||||
|
|
||||||
|
#### `auth.me` - 获取当前用户信息
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | 不需要 |
|
||||||
|
| 输入 | 无 |
|
||||||
|
| 输出 | `User | null` |
|
||||||
|
|
||||||
|
返回当前登录用户的完整信息,未登录返回 `null`。
|
||||||
|
|
||||||
|
#### `auth.loginWithUsername` - 用户名登录
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | 不需要 |
|
||||||
|
| 输入 | `{ username: string }` |
|
||||||
|
| 输出 | `{ user: User, isNew: boolean }` |
|
||||||
|
|
||||||
|
**输入验证:**
|
||||||
|
- `username`:1-64个字符
|
||||||
|
|
||||||
|
若用户名不存在则自动创建新账户。
|
||||||
|
|
||||||
|
#### `auth.logout` - 退出登录
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | 不需要 |
|
||||||
|
| 输出 | `{ success: true }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 用户资料模块 (`profile`)
|
||||||
|
|
||||||
|
#### `profile.stats` - 获取用户统计数据
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `UserStats` |
|
||||||
|
|
||||||
|
#### `profile.update` - 更新用户资料
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ skillLevel?: "beginner" \| "intermediate" \| "advanced", trainingGoals?: string }` |
|
||||||
|
| 输出 | `{ success: true }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 训练计划模块 (`plan`)
|
||||||
|
|
||||||
|
#### `plan.generate` - AI生成训练计划
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ skillLevel: enum, durationDays: number, focusAreas?: string[] }` |
|
||||||
|
| 输出 | `{ planId: number, plan: TrainingPlanData }` |
|
||||||
|
|
||||||
|
**输入验证:**
|
||||||
|
- `skillLevel`:`"beginner"` / `"intermediate"` / `"advanced"`
|
||||||
|
- `durationDays`:1-30
|
||||||
|
- `focusAreas`:可选,如 `["正手", "脚步"]`
|
||||||
|
|
||||||
|
#### `plan.list` - 获取用户所有训练计划
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `TrainingPlan[]` |
|
||||||
|
|
||||||
|
#### `plan.active` - 获取当前激活的训练计划
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `TrainingPlan | null` |
|
||||||
|
|
||||||
|
#### `plan.adjust` - AI自动调整训练计划
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ planId: number }` |
|
||||||
|
| 输出 | `{ success: true, adjustmentNotes: string }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 视频管理模块 (`video`)
|
||||||
|
|
||||||
|
#### `video.upload` - 上传训练视频
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ title: string, format: string, fileSize: number, fileBase64: string, exerciseType?: string }` |
|
||||||
|
| 输出 | `{ videoId: number, url: string }` |
|
||||||
|
|
||||||
|
#### `video.list` - 获取用户视频列表
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `TrainingVideo[]` |
|
||||||
|
|
||||||
|
#### `video.get` - 获取视频详情
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ videoId: number }` |
|
||||||
|
| 输出 | `TrainingVideo` |
|
||||||
|
|
||||||
|
#### `video.updateStatus` - 更新视频分析状态
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ videoId: number, status: "pending" \| "analyzing" \| "completed" \| "failed" }` |
|
||||||
|
| 输出 | `{ success: true }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 姿势分析模块 (`analysis`)
|
||||||
|
|
||||||
|
#### `analysis.save` - 保存姿势分析结果
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | 见下表 |
|
||||||
|
| 输出 | `{ analysisId: number }` |
|
||||||
|
|
||||||
|
**输入参数:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| videoId | number | 是 | 关联视频ID |
|
||||||
|
| overallScore | number | 否 | 总体评分(0-100) |
|
||||||
|
| poseMetrics | object | 否 | 关节角度等详细指标 |
|
||||||
|
| detectedIssues | array | 否 | 检测到的问题列表 |
|
||||||
|
| exerciseType | string | 否 | 动作类型 |
|
||||||
|
| framesAnalyzed | number | 否 | 分析帧数 |
|
||||||
|
| shotCount | number | 否 | 击球次数 |
|
||||||
|
| avgSwingSpeed | number | 否 | 平均挥拍速度 |
|
||||||
|
| maxSwingSpeed | number | 否 | 最大挥拍速度 |
|
||||||
|
| totalMovementDistance | number | 否 | 总移动距离 |
|
||||||
|
| strokeConsistency | number | 否 | 击球一致性(0-100) |
|
||||||
|
| footworkScore | number | 否 | 脚步评分(0-100) |
|
||||||
|
| fluidityScore | number | 否 | 流畅性评分(0-100) |
|
||||||
|
| keyMoments | array | 否 | 关键时刻标记 |
|
||||||
|
| movementTrajectory | array | 否 | 运动轨迹数据 |
|
||||||
|
|
||||||
|
保存分析结果后会自动触发NTRP评分重新计算。
|
||||||
|
|
||||||
|
#### `analysis.getCorrections` - AI生成矫正建议
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ poseMetrics: object, exerciseType: string, detectedIssues: array }` |
|
||||||
|
| 输出 | `{ corrections: string }` |
|
||||||
|
|
||||||
|
#### `analysis.list` - 获取用户所有分析记录
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `PoseAnalysis[]` |
|
||||||
|
|
||||||
|
#### `analysis.getByVideo` - 获取视频的分析结果
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ videoId: number }` |
|
||||||
|
| 输出 | `PoseAnalysis | null` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 训练记录模块 (`record`)
|
||||||
|
|
||||||
|
#### `record.create` - 创建训练记录
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ exerciseName: string, planId?: number, durationMinutes?: number, notes?: string, poseScore?: number }` |
|
||||||
|
| 输出 | `{ recordId: number }` |
|
||||||
|
|
||||||
|
#### `record.complete` - 标记训练完成
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ recordId: number, poseScore?: number }` |
|
||||||
|
| 输出 | `{ success: true }` |
|
||||||
|
|
||||||
|
#### `record.list` - 获取训练记录列表
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ limit?: number }` (默认50) |
|
||||||
|
| 输出 | `TrainingRecord[]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 评分模块 (`rating`)
|
||||||
|
|
||||||
|
#### `rating.history` - 获取NTRP评分历史
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `RatingHistory[]` |
|
||||||
|
|
||||||
|
#### `rating.current` - 获取当前NTRP评分
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `{ rating: number }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 打卡模块 (`checkin`)
|
||||||
|
|
||||||
|
#### `checkin.today` - 获取今日打卡状态
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `DailyCheckin | null` |
|
||||||
|
|
||||||
|
#### `checkin.do` - 执行打卡
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ notes?: string, minutesTrained?: number }` (可选) |
|
||||||
|
| 输出 | `{ checkin: DailyCheckin, streak: number, newBadges: Badge[] }` |
|
||||||
|
|
||||||
|
打卡后会自动检查并授予新徽章。
|
||||||
|
|
||||||
|
#### `checkin.history` - 获取打卡历史
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ limit?: number }` (默认60) |
|
||||||
|
| 输出 | `DailyCheckin[]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 徽章模块 (`badge`)
|
||||||
|
|
||||||
|
#### `badge.list` - 获取用户徽章(含未获得)
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `BadgeWithStatus[]` |
|
||||||
|
|
||||||
|
返回所有24种徽章,标记已获得/未获得状态。
|
||||||
|
|
||||||
|
#### `badge.check` - 检查并授予新徽章
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Mutation |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输出 | `{ newBadges: Badge[] }` |
|
||||||
|
|
||||||
|
#### `badge.definitions` - 获取所有徽章定义
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | 不需要 |
|
||||||
|
| 输出 | `BadgeDefinition[]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. 排行榜模块 (`leaderboard`)
|
||||||
|
|
||||||
|
#### `leaderboard.get` - 获取排行榜
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | Query |
|
||||||
|
| 认证 | **需认证** |
|
||||||
|
| 输入 | `{ sortBy?: enum, limit?: number }` |
|
||||||
|
| 输出 | `LeaderboardEntry[]` |
|
||||||
|
|
||||||
|
**sortBy选项:**
|
||||||
|
- `"ntrpRating"` - 按NTRP评分排名(默认)
|
||||||
|
- `"totalMinutes"` - 按训练时长排名
|
||||||
|
- `"totalSessions"` - 按训练次数排名
|
||||||
|
- `"totalShots"` - 按击球数排名
|
||||||
67
docs/CHANGELOG.md
普通文件
67
docs/CHANGELOG.md
普通文件
@@ -0,0 +1,67 @@
|
|||||||
|
# Tennis Training Hub - 变更日志
|
||||||
|
|
||||||
|
## v2.0.0 (2026-03-14)
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
|
||||||
|
- **社区排行榜**:支持按NTRP评分、训练时长、训练次数、击球数四种维度排名
|
||||||
|
- **每日打卡系统**:日历视图展示打卡记录,自动计算连续打卡天数
|
||||||
|
- **成就徽章系统**:24种成就徽章,涵盖里程碑、训练、连续打卡、视频、分析、评分6个类别
|
||||||
|
- **实时摄像头分析**:支持手机/电脑摄像头实时捕捉和MediaPipe姿势分析
|
||||||
|
- **摄像头位置确认提示**:引导用户调整摄像头到最佳位置
|
||||||
|
- **在线录制系统**:稳定压缩流录制,自适应码率1-2.5Mbps
|
||||||
|
- **断线自动重连**:摄像头意外断开时自动检测并重新连接
|
||||||
|
- **自动剪辑功能**:基于运动检测自动标记关键时刻
|
||||||
|
- **移动端全面适配**:安全区域、触摸优化、横屏支持
|
||||||
|
- **手机摄像头优化**:前后摄像头切换、自适应分辨率
|
||||||
|
|
||||||
|
### 数据库变更
|
||||||
|
|
||||||
|
- 新增 `daily_checkins` 表
|
||||||
|
- 新增 `user_badges` 表
|
||||||
|
- `users` 表新增 `currentStreak`、`longestStreak`、`totalShots` 字段
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- 测试用例从15个增加到47个
|
||||||
|
- 新增打卡、徽章、排行榜相关测试
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
- 新增完整README.md
|
||||||
|
- 新增API接口文档
|
||||||
|
- 新增数据库设计文档
|
||||||
|
- 新增功能列表清单
|
||||||
|
- 新增代码规范文档
|
||||||
|
- 新增变更日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.0 (2026-03-14)
|
||||||
|
|
||||||
|
### 初始版本
|
||||||
|
|
||||||
|
- 用户名简单登录系统
|
||||||
|
- AI训练计划生成(初/中/高级)
|
||||||
|
- 视频上传功能(webm/mp4)
|
||||||
|
- MediaPipe浏览器端姿势识别
|
||||||
|
- 姿势矫正建议系统(AI生成)
|
||||||
|
- 训练计划自动调整
|
||||||
|
- NTRP自动评分系统(五维度加权)
|
||||||
|
- 训练进度追踪(可视化图表)
|
||||||
|
- 视频库管理
|
||||||
|
- 击球次数统计
|
||||||
|
- 挥拍速度估算
|
||||||
|
- 运动轨迹可视化
|
||||||
|
- 帧级别关键时刻标注
|
||||||
|
- 球员统计面板
|
||||||
|
|
||||||
|
### 数据库
|
||||||
|
|
||||||
|
- 初始9张表设计
|
||||||
|
- Drizzle ORM集成
|
||||||
|
- 3次数据库迁移
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- 15个核心功能测试
|
||||||
106
docs/CODING_STANDARDS.md
普通文件
106
docs/CODING_STANDARDS.md
普通文件
@@ -0,0 +1,106 @@
|
|||||||
|
# Tennis Training Hub - 代码规范文档
|
||||||
|
|
||||||
|
## 项目约定
|
||||||
|
|
||||||
|
### 技术栈版本
|
||||||
|
|
||||||
|
| 技术 | 版本 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| React | 19 | UI框架 |
|
||||||
|
| TypeScript | 5.9 | 类型安全 |
|
||||||
|
| Tailwind CSS | 4 | 样式系统 |
|
||||||
|
| tRPC | 11 | 端到端类型安全API |
|
||||||
|
| Drizzle ORM | 0.44 | 数据库ORM |
|
||||||
|
| Vitest | 2.1 | 测试框架 |
|
||||||
|
| Express | 4 | HTTP服务器 |
|
||||||
|
|
||||||
|
### 文件命名规范
|
||||||
|
|
||||||
|
| 类型 | 规范 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| React页面 | PascalCase | `Dashboard.tsx`, `LiveCamera.tsx` |
|
||||||
|
| React组件 | PascalCase | `DashboardLayout.tsx` |
|
||||||
|
| 工具函数 | camelCase | `db.ts`, `storage.ts` |
|
||||||
|
| 测试文件 | `*.test.ts` | `features.test.ts` |
|
||||||
|
| 数据库迁移 | 自动生成 | `0001_public_prowler.sql` |
|
||||||
|
| 文档 | UPPER_CASE.md | `API.md`, `DATABASE.md` |
|
||||||
|
|
||||||
|
### 代码风格
|
||||||
|
|
||||||
|
**TypeScript/React:**
|
||||||
|
|
||||||
|
- 使用函数组件和Hooks,不使用类组件
|
||||||
|
- 使用 `const` 优先,必要时使用 `let`,禁止 `var`
|
||||||
|
- 使用箭头函数作为回调
|
||||||
|
- 使用模板字符串而非字符串拼接
|
||||||
|
- 使用可选链 `?.` 和空值合并 `??`
|
||||||
|
- 导出组件使用 `export default function ComponentName()`
|
||||||
|
- 类型定义使用 `type` 而非 `interface`(除非需要继承)
|
||||||
|
|
||||||
|
**CSS/Tailwind:**
|
||||||
|
|
||||||
|
- 优先使用Tailwind工具类
|
||||||
|
- 颜色使用OKLCH格式(Tailwind 4要求)
|
||||||
|
- 响应式设计使用移动优先策略(`sm:`, `md:`, `lg:`)
|
||||||
|
- 语义化颜色变量定义在 `index.css` 的 `:root` 中
|
||||||
|
- 避免内联样式,除非动态计算值
|
||||||
|
|
||||||
|
**数据库:**
|
||||||
|
|
||||||
|
- 字段使用camelCase命名
|
||||||
|
- 主键统一使用 `id: int().autoincrement().primaryKey()`
|
||||||
|
- 时间字段使用 `timestamp` 类型
|
||||||
|
- JSON字段用于存储结构化但不需要索引的数据
|
||||||
|
- 所有表包含 `createdAt` 字段
|
||||||
|
|
||||||
|
### tRPC路由规范
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 公开接口使用 publicProcedure
|
||||||
|
publicProcedure.query(...)
|
||||||
|
|
||||||
|
// 需要认证的接口使用 protectedProcedure
|
||||||
|
protectedProcedure.query(...)
|
||||||
|
protectedProcedure.mutation(...)
|
||||||
|
|
||||||
|
// 输入验证使用 Zod
|
||||||
|
.input(z.object({
|
||||||
|
field: z.string().min(1).max(64),
|
||||||
|
optional: z.number().optional(),
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试规范
|
||||||
|
|
||||||
|
- 每个API路由至少有一个认证测试
|
||||||
|
- 输入验证测试覆盖边界值
|
||||||
|
- 使用 `createMockContext()` 创建测试上下文
|
||||||
|
- 数据库操作在测试中允许抛出连接错误,但输入验证不应失败
|
||||||
|
- 测试文件放在 `server/` 目录下
|
||||||
|
|
||||||
|
### Git提交规范
|
||||||
|
|
||||||
|
| 前缀 | 用途 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `feat:` | 新功能 | `feat: 添加排行榜功能` |
|
||||||
|
| `fix:` | 修复 | `fix: 修复打卡连续天数计算` |
|
||||||
|
| `docs:` | 文档 | `docs: 更新API文档` |
|
||||||
|
| `test:` | 测试 | `test: 添加徽章系统测试` |
|
||||||
|
| `refactor:` | 重构 | `refactor: 优化评分计算逻辑` |
|
||||||
|
| `style:` | 样式 | `style: 调整移动端布局` |
|
||||||
|
|
||||||
|
### 安全规范
|
||||||
|
|
||||||
|
- 所有LLM调用必须在服务端执行
|
||||||
|
- 文件上传通过服务端中转到S3
|
||||||
|
- 用户输入使用Zod严格验证
|
||||||
|
- Session使用HttpOnly Cookie
|
||||||
|
- 敏感操作使用 `protectedProcedure`
|
||||||
|
|
||||||
|
### 性能规范
|
||||||
|
|
||||||
|
- MediaPipe推理在浏览器端执行,不占用服务器资源
|
||||||
|
- 视频文件存储在S3,不存入数据库
|
||||||
|
- 使用tRPC的React Query缓存减少重复请求
|
||||||
|
- 大列表查询使用 `limit` 参数分页
|
||||||
|
- 图片和媒体资源使用CDN URL
|
||||||
204
docs/DATABASE.md
普通文件
204
docs/DATABASE.md
普通文件
@@ -0,0 +1,204 @@
|
|||||||
|
# Tennis Training Hub - 数据库设计文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本项目使用MySQL/TiDB数据库,通过Drizzle ORM进行数据访问。数据库包含9张核心表,支持用户管理、训练计划、视频分析、评分系统、打卡和徽章等功能。
|
||||||
|
|
||||||
|
## ER关系图
|
||||||
|
|
||||||
|
```
|
||||||
|
users (1) ──── (N) username_accounts
|
||||||
|
│
|
||||||
|
├──── (N) training_plans
|
||||||
|
│
|
||||||
|
├──── (N) training_videos (1) ──── (1) pose_analyses
|
||||||
|
│
|
||||||
|
├──── (N) training_records
|
||||||
|
│
|
||||||
|
├──── (N) rating_history
|
||||||
|
│
|
||||||
|
├──── (N) daily_checkins
|
||||||
|
│
|
||||||
|
└──── (N) user_badges
|
||||||
|
```
|
||||||
|
|
||||||
|
## 表结构详解
|
||||||
|
|
||||||
|
### 1. `users` - 用户表
|
||||||
|
|
||||||
|
核心用户表,支持OAuth和用户名两种登录方式。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||||
|
| openId | VARCHAR(64) | UNIQUE, NOT NULL | OAuth标识符 |
|
||||||
|
| name | TEXT | - | 用户名 |
|
||||||
|
| email | VARCHAR(320) | - | 邮箱 |
|
||||||
|
| loginMethod | VARCHAR(64) | - | 登录方式 |
|
||||||
|
| role | ENUM('user','admin') | DEFAULT 'user' | 角色 |
|
||||||
|
| skillLevel | ENUM('beginner','intermediate','advanced') | DEFAULT 'beginner' | 技能水平 |
|
||||||
|
| trainingGoals | TEXT | - | 训练目标 |
|
||||||
|
| ntrpRating | FLOAT | DEFAULT 1.5 | NTRP评分 |
|
||||||
|
| totalSessions | INT | DEFAULT 0 | 总训练次数 |
|
||||||
|
| totalMinutes | INT | DEFAULT 0 | 总训练分钟 |
|
||||||
|
| currentStreak | INT | DEFAULT 0 | 当前连续打卡天数 |
|
||||||
|
| longestStreak | INT | DEFAULT 0 | 最长连续打卡天数 |
|
||||||
|
| totalShots | INT | DEFAULT 0 | 总击球数 |
|
||||||
|
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||||
|
| updatedAt | TIMESTAMP | ON UPDATE NOW | 更新时间 |
|
||||||
|
| lastSignedIn | TIMESTAMP | DEFAULT NOW | 最后登录 |
|
||||||
|
|
||||||
|
### 2. `username_accounts` - 用户名账户表
|
||||||
|
|
||||||
|
简单用户名登录的映射表,将用户名映射到users表的用户。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||||
|
| username | VARCHAR(64) | UNIQUE, NOT NULL | 用户名 |
|
||||||
|
| userId | INT | NOT NULL | 关联用户ID |
|
||||||
|
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||||
|
|
||||||
|
### 3. `training_plans` - 训练计划表
|
||||||
|
|
||||||
|
存储AI生成的训练计划,支持版本追踪和自动调整。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||||
|
| userId | INT | NOT NULL | 用户ID |
|
||||||
|
| title | VARCHAR(256) | NOT NULL | 计划标题 |
|
||||||
|
| skillLevel | ENUM | NOT NULL | 技能水平 |
|
||||||
|
| durationDays | INT | DEFAULT 7 | 计划天数 |
|
||||||
|
| exercises | JSON | NOT NULL | 训练内容(JSON数组) |
|
||||||
|
| isActive | INT | DEFAULT 1 | 是否激活 |
|
||||||
|
| adjustmentNotes | TEXT | - | AI调整说明 |
|
||||||
|
| version | INT | DEFAULT 1 | 版本号 |
|
||||||
|
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||||
|
| updatedAt | TIMESTAMP | ON UPDATE NOW | 更新时间 |
|
||||||
|
|
||||||
|
**exercises JSON结构:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"day": 1,
|
||||||
|
"name": "正手影子挥拍",
|
||||||
|
"category": "挥拍练习",
|
||||||
|
"duration": 15,
|
||||||
|
"description": "...",
|
||||||
|
"tips": "...",
|
||||||
|
"sets": 3,
|
||||||
|
"reps": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `training_videos` - 训练视频表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||||
|
| userId | INT | NOT NULL | 用户ID |
|
||||||
|
| title | VARCHAR(256) | NOT NULL | 视频标题 |
|
||||||
|
| fileKey | VARCHAR(512) | NOT NULL | S3文件键 |
|
||||||
|
| url | TEXT | NOT NULL | CDN访问URL |
|
||||||
|
| format | VARCHAR(16) | NOT NULL | 格式(webm/mp4) |
|
||||||
|
| fileSize | INT | - | 文件大小(字节) |
|
||||||
|
| duration | FLOAT | - | 时长(秒) |
|
||||||
|
| exerciseType | VARCHAR(64) | - | 动作类型 |
|
||||||
|
| analysisStatus | ENUM | DEFAULT 'pending' | 分析状态 |
|
||||||
|
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||||
|
| updatedAt | TIMESTAMP | ON UPDATE NOW | 更新时间 |
|
||||||
|
|
||||||
|
### 5. `pose_analyses` - 姿势分析表
|
||||||
|
|
||||||
|
参考tennis_analysis项目设计,包含击球统计、挥拍速度、运动轨迹等高级分析字段。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||||
|
| videoId | INT | NOT NULL | 视频ID |
|
||||||
|
| userId | INT | NOT NULL | 用户ID |
|
||||||
|
| overallScore | FLOAT | - | 总体评分(0-100) |
|
||||||
|
| poseMetrics | JSON | - | 关节角度详细指标 |
|
||||||
|
| detectedIssues | JSON | - | 检测到的问题 |
|
||||||
|
| corrections | JSON | - | 矫正建议 |
|
||||||
|
| exerciseType | VARCHAR(64) | - | 动作类型 |
|
||||||
|
| framesAnalyzed | INT | - | 分析帧数 |
|
||||||
|
| shotCount | INT | DEFAULT 0 | 击球次数 |
|
||||||
|
| avgSwingSpeed | FLOAT | - | 平均挥拍速度 |
|
||||||
|
| maxSwingSpeed | FLOAT | - | 最大挥拍速度 |
|
||||||
|
| totalMovementDistance | FLOAT | - | 总移动距离 |
|
||||||
|
| strokeConsistency | FLOAT | - | 击球一致性(0-100) |
|
||||||
|
| footworkScore | FLOAT | - | 脚步评分(0-100) |
|
||||||
|
| fluidityScore | FLOAT | - | 流畅性评分(0-100) |
|
||||||
|
| keyMoments | JSON | - | 关键时刻标记 |
|
||||||
|
| movementTrajectory | JSON | - | 运动轨迹数据 |
|
||||||
|
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||||
|
|
||||||
|
### 6. `training_records` - 训练记录表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||||
|
| userId | INT | NOT NULL | 用户ID |
|
||||||
|
| planId | INT | - | 关联计划ID |
|
||||||
|
| exerciseName | VARCHAR(128) | NOT NULL | 训练名称 |
|
||||||
|
| durationMinutes | INT | - | 时长(分钟) |
|
||||||
|
| completed | INT | DEFAULT 0 | 是否完成 |
|
||||||
|
| notes | TEXT | - | 备注 |
|
||||||
|
| poseScore | FLOAT | - | 姿势评分 |
|
||||||
|
| trainingDate | TIMESTAMP | DEFAULT NOW | 训练日期 |
|
||||||
|
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||||
|
|
||||||
|
### 7. `rating_history` - 评分历史表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||||
|
| userId | INT | NOT NULL | 用户ID |
|
||||||
|
| rating | FLOAT | NOT NULL | NTRP评分 |
|
||||||
|
| reason | VARCHAR(256) | - | 评分原因 |
|
||||||
|
| dimensionScores | JSON | - | 五维度分数明细 |
|
||||||
|
| analysisId | INT | - | 关联分析ID |
|
||||||
|
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||||
|
|
||||||
|
**dimensionScores JSON结构:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"poseAccuracy": 75.5,
|
||||||
|
"strokeConsistency": 68.2,
|
||||||
|
"footwork": 72.0,
|
||||||
|
"fluidity": 65.8,
|
||||||
|
"power": 58.3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. `daily_checkins` - 每日打卡表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||||
|
| userId | INT | NOT NULL | 用户ID |
|
||||||
|
| checkinDate | VARCHAR(10) | NOT NULL | 日期(YYYY-MM-DD) |
|
||||||
|
| streakCount | INT | DEFAULT 1 | 当时连续天数 |
|
||||||
|
| notes | TEXT | - | 打卡备注 |
|
||||||
|
| minutesTrained | INT | DEFAULT 0 | 当日训练分钟 |
|
||||||
|
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||||
|
|
||||||
|
### 9. `user_badges` - 用户徽章表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||||
|
| userId | INT | NOT NULL | 用户ID |
|
||||||
|
| badgeKey | VARCHAR(64) | NOT NULL | 徽章标识键 |
|
||||||
|
| earnedAt | TIMESTAMP | DEFAULT NOW | 获得时间 |
|
||||||
|
|
||||||
|
## 迁移历史
|
||||||
|
|
||||||
|
| 版本 | 文件 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| 0001 | `0001_public_prowler.sql` | 初始表创建(users扩展、username_accounts、training_plans、training_videos、pose_analyses、training_records、rating_history) |
|
||||||
|
| 0002 | `0002_overrated_shriek.sql` | 添加totalShots字段 |
|
||||||
|
| 0003 | `0003_married_iron_lad.sql` | 添加daily_checkins和user_badges表、用户streak字段 |
|
||||||
79
docs/FEATURES.md
普通文件
79
docs/FEATURES.md
普通文件
@@ -0,0 +1,79 @@
|
|||||||
|
# Tennis Training Hub - 功能列表清单与开发记录
|
||||||
|
|
||||||
|
## 功能完成状态
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
|
||||||
|
| 编号 | 功能 | 状态 | 版本 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| F-001 | 用户名简单登录 | 已完成 | v1.0 | 输入用户名即可登录,自动创建账户 |
|
||||||
|
| F-002 | 训练计划AI生成 | 已完成 | v1.0 | 支持初/中/高级,1-30天计划 |
|
||||||
|
| F-003 | 视频上传功能 | 已完成 | v1.0 | 支持webm/mp4格式,S3存储 |
|
||||||
|
| F-004 | MediaPipe姿势识别 | 已完成 | v1.0 | 浏览器端实时分析33个关键点 |
|
||||||
|
| F-005 | 姿势矫正建议 | 已完成 | v1.0 | AI根据分析数据生成矫正方案 |
|
||||||
|
| F-006 | 训练计划自动调整 | 已完成 | v1.0 | 基于分析结果智能调整计划 |
|
||||||
|
| F-007 | 训练进度追踪 | 已完成 | v1.0 | 可视化图表展示训练历史 |
|
||||||
|
| F-008 | 视频库管理 | 已完成 | v1.0 | 视频列表、详情、分析状态 |
|
||||||
|
|
||||||
|
### 参考tennis_analysis增强功能
|
||||||
|
|
||||||
|
| 编号 | 功能 | 状态 | 版本 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| F-009 | 击球次数统计 | 已完成 | v1.0 | 基于手腕关键点位移检测 |
|
||||||
|
| F-010 | 挥拍速度估算 | 已完成 | v1.0 | 手臂关键点帧间位移计算 |
|
||||||
|
| F-011 | 运动轨迹可视化 | 已完成 | v1.0 | 身体中心点移动轨迹绘制 |
|
||||||
|
| F-012 | 迷你球场叠加 | 已完成 | v1.0 | 视频分析界面球场示意图 |
|
||||||
|
| F-013 | 球员统计面板 | 已完成 | v1.0 | Dashboard综合数据展示 |
|
||||||
|
| F-014 | 帧级别关键时刻标注 | 已完成 | v1.0 | 自动标记击球、准备等关键帧 |
|
||||||
|
|
||||||
|
### NTRP评分系统
|
||||||
|
|
||||||
|
| 编号 | 功能 | 状态 | 版本 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| F-015 | NTRP自动评分 | 已完成 | v1.0 | 1.0-5.0评分,五维度加权 |
|
||||||
|
| F-016 | 历史评分自动更新 | 已完成 | v1.0 | 每次分析后自动重新计算 |
|
||||||
|
| F-017 | 多维度评分展示 | 已完成 | v1.0 | 雷达图展示五维度得分 |
|
||||||
|
| F-018 | 评分趋势图表 | 已完成 | v1.0 | 折线图展示评分变化趋势 |
|
||||||
|
|
||||||
|
### v2.0 新增功能
|
||||||
|
|
||||||
|
| 编号 | 功能 | 状态 | 版本 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| F-019 | 社区排行榜 - NTRP排名 | 已完成 | v2.0 | 按评分排序的用户排名 |
|
||||||
|
| F-020 | 社区排行榜 - 训练时长排名 | 已完成 | v2.0 | 按训练分钟排序 |
|
||||||
|
| F-021 | 社区排行榜 - 训练次数排名 | 已完成 | v2.0 | 按训练次数排序 |
|
||||||
|
| F-022 | 社区排行榜 - 击球数排名 | 已完成 | v2.0 | 按总击球数排序 |
|
||||||
|
| F-023 | 每日打卡系统 | 已完成 | v2.0 | 日历视图、连续天数追踪 |
|
||||||
|
| F-024 | 成就徽章系统 | 已完成 | v2.0 | 24种徽章,6个类别 |
|
||||||
|
| F-025 | 实时摄像头分析 | 已完成 | v2.0 | 手机/电脑摄像头实时捕捉 |
|
||||||
|
| F-026 | 摄像头位置确认提示 | 已完成 | v2.0 | 引导用户调整摄像头位置 |
|
||||||
|
| F-027 | 在线录制 | 已完成 | v2.0 | 稳定压缩流录制 |
|
||||||
|
| F-028 | 断线自动重连 | 已完成 | v2.0 | 摄像头断开自动恢复 |
|
||||||
|
| F-029 | 自动剪辑 | 已完成 | v2.0 | 基于运动检测标记关键片段 |
|
||||||
|
| F-030 | 移动端全面适配 | 已完成 | v2.0 | 响应式设计、安全区域、触摸优化 |
|
||||||
|
| F-031 | 手机摄像头优化 | 已完成 | v2.0 | 前后摄像头切换、自适应分辨率 |
|
||||||
|
|
||||||
|
## 开发时间线
|
||||||
|
|
||||||
|
| 日期 | 版本 | 里程碑 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 2026-03-14 | v1.0 | 项目初始化、数据库设计、核心功能开发 |
|
||||||
|
| 2026-03-14 | v1.0 | 完成所有核心页面、MediaPipe集成、NTRP评分 |
|
||||||
|
| 2026-03-14 | v2.0 | 添加排行榜、打卡、徽章、实时摄像头、在线录制 |
|
||||||
|
| 2026-03-14 | v2.0 | 移动端适配、测试套件、文档编写 |
|
||||||
|
|
||||||
|
## 测试覆盖
|
||||||
|
|
||||||
|
| 模块 | 测试数 | 覆盖内容 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| auth | 5 | me查询、logout、用户名登录验证 |
|
||||||
|
| profile | 4 | 认证检查、技能等级验证 |
|
||||||
|
| plan | 5 | 生成验证、列表、激活计划、调整 |
|
||||||
|
| video | 4 | 上传验证、列表、详情 |
|
||||||
|
| analysis | 4 | 保存验证、矫正建议、列表、视频查询 |
|
||||||
|
| record | 4 | 创建验证、完成、列表 |
|
||||||
|
| rating | 2 | 历史、当前评分 |
|
||||||
|
| checkin | 5 | 今日状态、打卡、历史 |
|
||||||
|
| badge | 5 | 列表、检查、定义、数据完整性 |
|
||||||
|
| leaderboard | 3 | 认证、排序参数、无效参数 |
|
||||||
|
| **总计** | **47** | **全部通过** |
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE `daily_checkins` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`userId` int NOT NULL,
|
||||||
|
`checkinDate` varchar(10) NOT NULL,
|
||||||
|
`streakCount` int NOT NULL DEFAULT 1,
|
||||||
|
`notes` text,
|
||||||
|
`minutesTrained` int DEFAULT 0,
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
CONSTRAINT `daily_checkins_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_badges` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`userId` int NOT NULL,
|
||||||
|
`badgeKey` varchar(64) NOT NULL,
|
||||||
|
`earnedAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
CONSTRAINT `user_badges_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `currentStreak` int DEFAULT 0;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `longestStreak` int DEFAULT 0;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `totalShots` int DEFAULT 0;
|
||||||
855
drizzle/meta/0003_snapshot.json
普通文件
855
drizzle/meta/0003_snapshot.json
普通文件
@@ -0,0 +1,855 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"dialect": "mysql",
|
||||||
|
"id": "0892fd57-f758-43a7-a72d-e372aca4d4e3",
|
||||||
|
"prevId": "a9a3ce4f-a15b-4af1-b99f-d12a1644a83b",
|
||||||
|
"tables": {
|
||||||
|
"daily_checkins": {
|
||||||
|
"name": "daily_checkins",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"checkinDate": {
|
||||||
|
"name": "checkinDate",
|
||||||
|
"type": "varchar(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"streakCount": {
|
||||||
|
"name": "streakCount",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"minutesTrained": {
|
||||||
|
"name": "minutesTrained",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"daily_checkins_id": {
|
||||||
|
"name": "daily_checkins_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"user_badges": {
|
||||||
|
"name": "user_badges",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"badgeKey": {
|
||||||
|
"name": "badgeKey",
|
||||||
|
"type": "varchar(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"earnedAt": {
|
||||||
|
"name": "earnedAt",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"user_badges_id": {
|
||||||
|
"name": "user_badges_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
|
||||||
|
},
|
||||||
|
"currentStreak": {
|
||||||
|
"name": "currentStreak",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"longestStreak": {
|
||||||
|
"name": "longestStreak",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"totalShots": {
|
||||||
|
"name": "totalShots",
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1773487643444,
|
"when": 1773487643444,
|
||||||
"tag": "0002_overrated_shriek",
|
"tag": "0002_overrated_shriek",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1773488765349,
|
||||||
|
"tag": "0003_married_iron_lad",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,12 @@ export const users = mysqlTable("users", {
|
|||||||
totalSessions: int("totalSessions").default(0),
|
totalSessions: int("totalSessions").default(0),
|
||||||
/** Total training minutes */
|
/** Total training minutes */
|
||||||
totalMinutes: int("totalMinutes").default(0),
|
totalMinutes: int("totalMinutes").default(0),
|
||||||
|
/** Current consecutive check-in streak */
|
||||||
|
currentStreak: int("currentStreak").default(0),
|
||||||
|
/** Longest ever streak */
|
||||||
|
longestStreak: int("longestStreak").default(0),
|
||||||
|
/** Total shots across all analyses */
|
||||||
|
totalShots: int("totalShots").default(0),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
||||||
@@ -181,3 +187,38 @@ export const ratingHistory = mysqlTable("rating_history", {
|
|||||||
|
|
||||||
export type RatingHistory = typeof ratingHistory.$inferSelect;
|
export type RatingHistory = typeof ratingHistory.$inferSelect;
|
||||||
export type InsertRatingHistory = typeof ratingHistory.$inferInsert;
|
export type InsertRatingHistory = typeof ratingHistory.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily check-in records for streak tracking
|
||||||
|
*/
|
||||||
|
export const dailyCheckins = mysqlTable("daily_checkins", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
userId: int("userId").notNull(),
|
||||||
|
/** Check-in date (YYYY-MM-DD stored as string for easy comparison) */
|
||||||
|
checkinDate: varchar("checkinDate", { length: 10 }).notNull(),
|
||||||
|
/** Current streak at the time of check-in */
|
||||||
|
streakCount: int("streakCount").notNull().default(1),
|
||||||
|
/** Optional notes for the day */
|
||||||
|
notes: text("notes"),
|
||||||
|
/** Training minutes logged this day */
|
||||||
|
minutesTrained: int("minutesTrained").default(0),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DailyCheckin = typeof dailyCheckins.$inferSelect;
|
||||||
|
export type InsertDailyCheckin = typeof dailyCheckins.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Achievement badges earned by users
|
||||||
|
*/
|
||||||
|
export const userBadges = mysqlTable("user_badges", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
userId: int("userId").notNull(),
|
||||||
|
/** Badge identifier key */
|
||||||
|
badgeKey: varchar("badgeKey", { length: 64 }).notNull(),
|
||||||
|
/** When the badge was earned */
|
||||||
|
earnedAt: timestamp("earnedAt").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UserBadge = typeof userBadges.$inferSelect;
|
||||||
|
export type InsertUserBadge = typeof userBadges.$inferInsert;
|
||||||
|
|||||||
195
server/db.ts
195
server/db.ts
@@ -8,6 +8,8 @@ import {
|
|||||||
poseAnalyses, InsertPoseAnalysis,
|
poseAnalyses, InsertPoseAnalysis,
|
||||||
trainingRecords, InsertTrainingRecord,
|
trainingRecords, InsertTrainingRecord,
|
||||||
ratingHistory, InsertRatingHistory,
|
ratingHistory, InsertRatingHistory,
|
||||||
|
dailyCheckins, InsertDailyCheckin,
|
||||||
|
userBadges, InsertUserBadge,
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { ENV } from './_core/env';
|
import { ENV } from './_core/env';
|
||||||
|
|
||||||
@@ -112,6 +114,9 @@ export async function updateUserProfile(userId: number, data: {
|
|||||||
ntrpRating?: number;
|
ntrpRating?: number;
|
||||||
totalSessions?: number;
|
totalSessions?: number;
|
||||||
totalMinutes?: number;
|
totalMinutes?: number;
|
||||||
|
currentStreak?: number;
|
||||||
|
longestStreak?: number;
|
||||||
|
totalShots?: number;
|
||||||
}) {
|
}) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
@@ -234,6 +239,196 @@ export async function getUserRatingHistory(userId: number, limit = 30) {
|
|||||||
return db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(limit);
|
return db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== DAILY CHECK-IN OPERATIONS =====
|
||||||
|
|
||||||
|
export async function checkinToday(userId: number, notes?: string, minutesTrained?: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Check if already checked in today
|
||||||
|
const existing = await db.select().from(dailyCheckins)
|
||||||
|
.where(and(eq(dailyCheckins.userId, userId), eq(dailyCheckins.checkinDate, today)))
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return { alreadyCheckedIn: true, streak: existing[0].streakCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get yesterday's check-in to calculate streak
|
||||||
|
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||||
|
const yesterdayCheckin = await db.select().from(dailyCheckins)
|
||||||
|
.where(and(eq(dailyCheckins.userId, userId), eq(dailyCheckins.checkinDate, yesterday)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const newStreak = yesterdayCheckin.length > 0 ? (yesterdayCheckin[0].streakCount + 1) : 1;
|
||||||
|
|
||||||
|
await db.insert(dailyCheckins).values({
|
||||||
|
userId,
|
||||||
|
checkinDate: today,
|
||||||
|
streakCount: newStreak,
|
||||||
|
notes: notes ?? null,
|
||||||
|
minutesTrained: minutesTrained ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user streak
|
||||||
|
const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
const longestStreak = Math.max(userRow?.longestStreak || 0, newStreak);
|
||||||
|
await db.update(users).set({ currentStreak: newStreak, longestStreak }).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
return { alreadyCheckedIn: false, streak: newStreak };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserCheckins(userId: number, limit = 60) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db.select().from(dailyCheckins)
|
||||||
|
.where(eq(dailyCheckins.userId, userId))
|
||||||
|
.orderBy(desc(dailyCheckins.checkinDate))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTodayCheckin(userId: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const result = await db.select().from(dailyCheckins)
|
||||||
|
.where(and(eq(dailyCheckins.userId, userId), eq(dailyCheckins.checkinDate, today)))
|
||||||
|
.limit(1);
|
||||||
|
return result.length > 0 ? result[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== BADGE OPERATIONS =====
|
||||||
|
|
||||||
|
// Badge definitions
|
||||||
|
export const BADGE_DEFINITIONS: Record<string, { name: string; description: string; icon: string; category: string }> = {
|
||||||
|
first_login: { name: "初来乍到", description: "首次登录Tennis Hub", icon: "🎾", category: "milestone" },
|
||||||
|
first_training: { name: "初试身手", description: "完成第一次训练", icon: "💪", category: "training" },
|
||||||
|
first_video: { name: "影像记录", description: "上传第一个训练视频", icon: "📹", category: "video" },
|
||||||
|
first_analysis: { name: "AI教练", description: "完成第一次视频分析", icon: "🤖", category: "analysis" },
|
||||||
|
streak_3: { name: "三日坚持", description: "连续打卡3天", icon: "🔥", category: "streak" },
|
||||||
|
streak_7: { name: "一周达人", description: "连续打卡7天", icon: "⭐", category: "streak" },
|
||||||
|
streak_14: { name: "两周勇士", description: "连续打卡14天", icon: "🏆", category: "streak" },
|
||||||
|
streak_30: { name: "月度冠军", description: "连续打卡30天", icon: "👑", category: "streak" },
|
||||||
|
sessions_10: { name: "十次训练", description: "累计完成10次训练", icon: "🎯", category: "training" },
|
||||||
|
sessions_50: { name: "五十次训练", description: "累计完成50次训练", icon: "💎", category: "training" },
|
||||||
|
sessions_100: { name: "百次训练", description: "累计完成100次训练", icon: "🌟", category: "training" },
|
||||||
|
videos_5: { name: "视频达人", description: "上传5个训练视频", icon: "🎬", category: "video" },
|
||||||
|
videos_20: { name: "视频大师", description: "上传20个训练视频", icon: "📽️", category: "video" },
|
||||||
|
score_80: { name: "优秀姿势", description: "视频分析获得80分以上", icon: "🏅", category: "analysis" },
|
||||||
|
score_90: { name: "完美姿势", description: "视频分析获得90分以上", icon: "🥇", category: "analysis" },
|
||||||
|
ntrp_2: { name: "NTRP 2.0", description: "NTRP评分达到2.0", icon: "📈", category: "rating" },
|
||||||
|
ntrp_3: { name: "NTRP 3.0", description: "NTRP评分达到3.0", icon: "📊", category: "rating" },
|
||||||
|
ntrp_4: { name: "NTRP 4.0", description: "NTRP评分达到4.0", icon: "🚀", category: "rating" },
|
||||||
|
minutes_60: { name: "一小时训练", description: "累计训练60分钟", icon: "⏱️", category: "training" },
|
||||||
|
minutes_300: { name: "五小时训练", description: "累计训练300分钟", icon: "⏰", category: "training" },
|
||||||
|
minutes_1000: { name: "千分钟训练", description: "累计训练1000分钟", icon: "🕐", category: "training" },
|
||||||
|
shots_100: { name: "百球达人", description: "累计击球100次", icon: "🎾", category: "analysis" },
|
||||||
|
shots_500: { name: "五百球大师", description: "累计击球500次", icon: "🏸", category: "analysis" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getUserBadges(userId: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db.select().from(userBadges).where(eq(userBadges.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function awardBadge(userId: number, badgeKey: string) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return false;
|
||||||
|
|
||||||
|
// Check if already has this badge
|
||||||
|
const existing = await db.select().from(userBadges)
|
||||||
|
.where(and(eq(userBadges.userId, userId), eq(userBadges.badgeKey, badgeKey)))
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length > 0) return false;
|
||||||
|
|
||||||
|
await db.insert(userBadges).values({ userId, badgeKey });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAndAwardBadges(userId: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
|
||||||
|
const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
if (!userRow) return [];
|
||||||
|
|
||||||
|
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 analyses = await db.select().from(poseAnalyses).where(eq(poseAnalyses.userId, userId));
|
||||||
|
const completedRecords = records.filter(r => r.completed === 1);
|
||||||
|
const totalMinutes = records.reduce((sum, r) => sum + (r.durationMinutes || 0), 0);
|
||||||
|
const totalShots = analyses.reduce((sum, a) => sum + (a.shotCount || 0), 0);
|
||||||
|
const maxScore = analyses.reduce((max, a) => Math.max(max, a.overallScore || 0), 0);
|
||||||
|
const streak = userRow.currentStreak || 0;
|
||||||
|
const ntrp = userRow.ntrpRating || 1.5;
|
||||||
|
|
||||||
|
const newBadges: string[] = [];
|
||||||
|
|
||||||
|
const checks: [boolean, string][] = [
|
||||||
|
[true, "first_login"],
|
||||||
|
[completedRecords.length >= 1, "first_training"],
|
||||||
|
[videos.length >= 1, "first_video"],
|
||||||
|
[analyses.length >= 1, "first_analysis"],
|
||||||
|
[streak >= 3, "streak_3"],
|
||||||
|
[streak >= 7, "streak_7"],
|
||||||
|
[streak >= 14, "streak_14"],
|
||||||
|
[streak >= 30, "streak_30"],
|
||||||
|
[completedRecords.length >= 10, "sessions_10"],
|
||||||
|
[completedRecords.length >= 50, "sessions_50"],
|
||||||
|
[completedRecords.length >= 100, "sessions_100"],
|
||||||
|
[videos.length >= 5, "videos_5"],
|
||||||
|
[videos.length >= 20, "videos_20"],
|
||||||
|
[maxScore >= 80, "score_80"],
|
||||||
|
[maxScore >= 90, "score_90"],
|
||||||
|
[ntrp >= 2.0, "ntrp_2"],
|
||||||
|
[ntrp >= 3.0, "ntrp_3"],
|
||||||
|
[ntrp >= 4.0, "ntrp_4"],
|
||||||
|
[totalMinutes >= 60, "minutes_60"],
|
||||||
|
[totalMinutes >= 300, "minutes_300"],
|
||||||
|
[totalMinutes >= 1000, "minutes_1000"],
|
||||||
|
[totalShots >= 100, "shots_100"],
|
||||||
|
[totalShots >= 500, "shots_500"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [condition, key] of checks) {
|
||||||
|
if (condition) {
|
||||||
|
const awarded = await awardBadge(userId, key);
|
||||||
|
if (awarded) newBadges.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newBadges;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== LEADERBOARD OPERATIONS =====
|
||||||
|
|
||||||
|
export async function getLeaderboard(sortBy: "ntrpRating" | "totalMinutes" | "totalSessions" | "totalShots" = "ntrpRating", limit = 50) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
|
||||||
|
const sortColumn = {
|
||||||
|
ntrpRating: users.ntrpRating,
|
||||||
|
totalMinutes: users.totalMinutes,
|
||||||
|
totalSessions: users.totalSessions,
|
||||||
|
totalShots: users.totalShots,
|
||||||
|
}[sortBy];
|
||||||
|
|
||||||
|
return db.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
ntrpRating: users.ntrpRating,
|
||||||
|
totalSessions: users.totalSessions,
|
||||||
|
totalMinutes: users.totalMinutes,
|
||||||
|
totalShots: users.totalShots,
|
||||||
|
currentStreak: users.currentStreak,
|
||||||
|
longestStreak: users.longestStreak,
|
||||||
|
skillLevel: users.skillLevel,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
}).from(users).orderBy(desc(sortColumn)).limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== STATS HELPERS =====
|
// ===== STATS HELPERS =====
|
||||||
|
|
||||||
export async function getUserStats(userId: number) {
|
export async function getUserStats(userId: number) {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ function createTestUser(overrides?: Partial<AuthenticatedUser>): AuthenticatedUs
|
|||||||
ntrpRating: 1.5,
|
ntrpRating: 1.5,
|
||||||
totalSessions: 0,
|
totalSessions: 0,
|
||||||
totalMinutes: 0,
|
totalMinutes: 0,
|
||||||
|
totalShots: 0,
|
||||||
|
currentStreak: 0,
|
||||||
|
longestStreak: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
lastSignedIn: new Date(),
|
lastSignedIn: new Date(),
|
||||||
@@ -54,6 +57,8 @@ function createMockContext(user: AuthenticatedUser | null = null): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== AUTH TESTS =====
|
||||||
|
|
||||||
describe("auth.me", () => {
|
describe("auth.me", () => {
|
||||||
it("returns null for unauthenticated users", async () => {
|
it("returns null for unauthenticated users", async () => {
|
||||||
const { ctx } = createMockContext(null);
|
const { ctx } = createMockContext(null);
|
||||||
@@ -94,6 +99,22 @@ describe("auth.logout", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("auth.loginWithUsername input validation", () => {
|
||||||
|
it("rejects empty username", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.auth.loginWithUsername({ username: "" })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects username over 64 chars", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.auth.loginWithUsername({ username: "a".repeat(65) })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== PROFILE TESTS =====
|
||||||
|
|
||||||
describe("profile.stats", () => {
|
describe("profile.stats", () => {
|
||||||
it("requires authentication", async () => {
|
it("requires authentication", async () => {
|
||||||
const { ctx } = createMockContext(null);
|
const { ctx } = createMockContext(null);
|
||||||
@@ -102,6 +123,38 @@ describe("profile.stats", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("profile.update input validation", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.profile.update({ skillLevel: "beginner" })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid skill level", async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const { ctx } = createMockContext(user);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.profile.update({ skillLevel: "expert" as any })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid skill levels", async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const { ctx } = createMockContext(user);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
for (const level of ["beginner", "intermediate", "advanced"] as const) {
|
||||||
|
try {
|
||||||
|
await caller.profile.update({ skillLevel: level });
|
||||||
|
} catch (e: any) {
|
||||||
|
// DB errors expected, but input validation should pass
|
||||||
|
expect(e.message).not.toContain("invalid_enum_value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== TRAINING PLAN TESTS =====
|
||||||
|
|
||||||
describe("plan.generate input validation", () => {
|
describe("plan.generate input validation", () => {
|
||||||
it("rejects invalid skill level", async () => {
|
it("rejects invalid skill level", async () => {
|
||||||
const user = createTestUser();
|
const user = createTestUser();
|
||||||
@@ -109,23 +162,17 @@ describe("plan.generate input validation", () => {
|
|||||||
const caller = appRouter.createCaller(ctx);
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
caller.plan.generate({
|
caller.plan.generate({ skillLevel: "expert" as any, durationDays: 7 })
|
||||||
skillLevel: "expert" as any,
|
|
||||||
durationDays: 7,
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid duration", async () => {
|
it("rejects invalid duration (0)", async () => {
|
||||||
const user = createTestUser();
|
const user = createTestUser();
|
||||||
const { ctx } = createMockContext(user);
|
const { ctx } = createMockContext(user);
|
||||||
const caller = appRouter.createCaller(ctx);
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
caller.plan.generate({
|
caller.plan.generate({ skillLevel: "beginner", durationDays: 0 })
|
||||||
skillLevel: "beginner",
|
|
||||||
durationDays: 0,
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,14 +182,45 @@ describe("plan.generate input validation", () => {
|
|||||||
const caller = appRouter.createCaller(ctx);
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
caller.plan.generate({
|
caller.plan.generate({ skillLevel: "beginner", durationDays: 31 })
|
||||||
skillLevel: "beginner",
|
).rejects.toThrow();
|
||||||
durationDays: 31,
|
});
|
||||||
})
|
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(
|
||||||
|
caller.plan.generate({ skillLevel: "beginner", durationDays: 7 })
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("plan.list", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.plan.list()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("plan.active", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.plan.active()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("plan.adjust input validation", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.plan.adjust({ planId: 1 })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== VIDEO TESTS =====
|
||||||
|
|
||||||
describe("video.upload input validation", () => {
|
describe("video.upload input validation", () => {
|
||||||
it("requires authentication", async () => {
|
it("requires authentication", async () => {
|
||||||
const { ctx } = createMockContext(null);
|
const { ctx } = createMockContext(null);
|
||||||
@@ -157,18 +235,48 @@ describe("video.upload input validation", () => {
|
|||||||
})
|
})
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects missing title", async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const { ctx } = createMockContext(user);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.video.upload({
|
||||||
|
title: undefined as any,
|
||||||
|
format: "mp4",
|
||||||
|
fileSize: 1000,
|
||||||
|
fileBase64: "dGVzdA==",
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("video.list", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.video.list()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("video.get input validation", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.video.get({ videoId: 1 })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== ANALYSIS TESTS =====
|
||||||
|
|
||||||
describe("analysis.save input validation", () => {
|
describe("analysis.save input validation", () => {
|
||||||
it("requires authentication", async () => {
|
it("requires authentication", async () => {
|
||||||
const { ctx } = createMockContext(null);
|
const { ctx } = createMockContext(null);
|
||||||
const caller = appRouter.createCaller(ctx);
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
caller.analysis.save({
|
caller.analysis.save({ videoId: 1, overallScore: 75 })
|
||||||
videoId: 1,
|
|
||||||
overallScore: 75,
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -188,16 +296,31 @@ describe("analysis.getCorrections input validation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("analysis.list", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.analysis.list()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("analysis.getByVideo", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.analysis.getByVideo({ videoId: 1 })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== RECORD TESTS =====
|
||||||
|
|
||||||
describe("record.create input validation", () => {
|
describe("record.create input validation", () => {
|
||||||
it("requires authentication", async () => {
|
it("requires authentication", async () => {
|
||||||
const { ctx } = createMockContext(null);
|
const { ctx } = createMockContext(null);
|
||||||
const caller = appRouter.createCaller(ctx);
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
caller.record.create({
|
caller.record.create({ exerciseName: "正手挥拍", durationMinutes: 30 })
|
||||||
exerciseName: "正手挥拍",
|
|
||||||
durationMinutes: 30,
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,20 +329,32 @@ describe("record.create input validation", () => {
|
|||||||
const { ctx } = createMockContext(user);
|
const { ctx } = createMockContext(user);
|
||||||
const caller = appRouter.createCaller(ctx);
|
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 {
|
try {
|
||||||
await caller.record.create({
|
await caller.record.create({ exerciseName: "正手挥拍", durationMinutes: 30 });
|
||||||
exerciseName: "正手挥拍",
|
|
||||||
durationMinutes: 30,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// DB errors are expected in test env, but input validation should pass
|
|
||||||
expect(e.message).not.toContain("invalid_type");
|
expect(e.message).not.toContain("invalid_type");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("record.complete input validation", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.record.complete({ recordId: 1 })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("record.list", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.record.list()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== RATING TESTS =====
|
||||||
|
|
||||||
describe("rating.history", () => {
|
describe("rating.history", () => {
|
||||||
it("requires authentication", async () => {
|
it("requires authentication", async () => {
|
||||||
const { ctx } = createMockContext(null);
|
const { ctx } = createMockContext(null);
|
||||||
@@ -235,3 +370,187 @@ describe("rating.current", () => {
|
|||||||
await expect(caller.rating.current()).rejects.toThrow();
|
await expect(caller.rating.current()).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== DAILY CHECK-IN TESTS =====
|
||||||
|
|
||||||
|
describe("checkin.today", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.checkin.today()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkin.do", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.checkin.do()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts optional notes and minutesTrained", async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const { ctx } = createMockContext(user);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await caller.checkin.do({ notes: "练了正手", minutesTrained: 30 });
|
||||||
|
} catch (e: any) {
|
||||||
|
// DB errors expected, input validation should pass
|
||||||
|
expect(e.message).not.toContain("invalid_type");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts empty input", async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const { ctx } = createMockContext(user);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await caller.checkin.do();
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.message).not.toContain("invalid_type");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkin.history", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.checkin.history()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts custom limit", async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const { ctx } = createMockContext(user);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await caller.checkin.history({ limit: 30 });
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.message).not.toContain("invalid_type");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== BADGE TESTS =====
|
||||||
|
|
||||||
|
describe("badge.list", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.badge.list()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("badge.check", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.badge.check()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("badge.definitions", () => {
|
||||||
|
it("returns badge definitions without authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
const result = await caller.badge.definitions();
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check badge structure
|
||||||
|
const firstBadge = result[0];
|
||||||
|
expect(firstBadge).toHaveProperty("key");
|
||||||
|
expect(firstBadge).toHaveProperty("name");
|
||||||
|
expect(firstBadge).toHaveProperty("description");
|
||||||
|
expect(firstBadge).toHaveProperty("icon");
|
||||||
|
expect(firstBadge).toHaveProperty("category");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains expected badge categories", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
const result = await caller.badge.definitions();
|
||||||
|
|
||||||
|
const categories = [...new Set(result.map((b: any) => b.category))];
|
||||||
|
expect(categories).toContain("milestone");
|
||||||
|
expect(categories).toContain("training");
|
||||||
|
expect(categories).toContain("streak");
|
||||||
|
expect(categories).toContain("video");
|
||||||
|
expect(categories).toContain("analysis");
|
||||||
|
expect(categories).toContain("rating");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has unique badge keys", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
const result = await caller.badge.definitions();
|
||||||
|
|
||||||
|
const keys = result.map((b: any) => b.key);
|
||||||
|
const uniqueKeys = [...new Set(keys)];
|
||||||
|
expect(keys.length).toBe(uniqueKeys.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== LEADERBOARD TESTS =====
|
||||||
|
|
||||||
|
describe("leaderboard.get", () => {
|
||||||
|
it("requires authentication", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
await expect(caller.leaderboard.get()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts sortBy parameter", async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const { ctx } = createMockContext(user);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
for (const sortBy of ["ntrpRating", "totalMinutes", "totalSessions", "totalShots"] as const) {
|
||||||
|
try {
|
||||||
|
await caller.leaderboard.get({ sortBy, limit: 10 });
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.message).not.toContain("invalid_enum_value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid sortBy", async () => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const { ctx } = createMockContext(user);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.leaderboard.get({ sortBy: "invalidField" as any })
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== BADGE DEFINITIONS UNIT TESTS =====
|
||||||
|
|
||||||
|
describe("BADGE_DEFINITIONS via badge.definitions endpoint", () => {
|
||||||
|
it("all badges have required fields", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
const badges = await caller.badge.definitions();
|
||||||
|
|
||||||
|
for (const badge of badges) {
|
||||||
|
expect(typeof badge.key).toBe("string");
|
||||||
|
expect(badge.key.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof badge.name).toBe("string");
|
||||||
|
expect(typeof badge.description).toBe("string");
|
||||||
|
expect(typeof badge.icon).toBe("string");
|
||||||
|
expect(typeof badge.category).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has at least 20 badges defined", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
const badges = await caller.badge.definitions();
|
||||||
|
expect(badges.length).toBeGreaterThanOrEqual(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -412,6 +412,65 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
|
|||||||
return { rating: user?.ntrpRating || 1.5 };
|
return { rating: user?.ntrpRating || 1.5 };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Daily check-in system
|
||||||
|
checkin: router({
|
||||||
|
today: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return db.getTodayCheckin(ctx.user.id);
|
||||||
|
}),
|
||||||
|
do: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
notes: z.string().optional(),
|
||||||
|
minutesTrained: z.number().optional(),
|
||||||
|
}).optional())
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const result = await db.checkinToday(ctx.user.id, input?.notes, input?.minutesTrained);
|
||||||
|
// Check for new badges after check-in
|
||||||
|
const newBadges = await db.checkAndAwardBadges(ctx.user.id);
|
||||||
|
return { ...result, newBadges };
|
||||||
|
}),
|
||||||
|
history: protectedProcedure
|
||||||
|
.input(z.object({ limit: z.number().default(60) }).optional())
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return db.getUserCheckins(ctx.user.id, input?.limit || 60);
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Badge system
|
||||||
|
badge: router({
|
||||||
|
list: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const earned = await db.getUserBadges(ctx.user.id);
|
||||||
|
const allBadges = Object.entries(db.BADGE_DEFINITIONS).map(([key, def]) => {
|
||||||
|
const earnedBadge = earned.find(b => b.badgeKey === key);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
...def,
|
||||||
|
earned: !!earnedBadge,
|
||||||
|
earnedAt: earnedBadge?.earnedAt || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return allBadges;
|
||||||
|
}),
|
||||||
|
check: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
const newBadges = await db.checkAndAwardBadges(ctx.user.id);
|
||||||
|
return { newBadges: newBadges.map(key => ({ key, ...db.BADGE_DEFINITIONS[key] })) };
|
||||||
|
}),
|
||||||
|
definitions: publicProcedure.query(() => {
|
||||||
|
return Object.entries(db.BADGE_DEFINITIONS).map(([key, def]) => ({ key, ...def }));
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Leaderboard
|
||||||
|
leaderboard: router({
|
||||||
|
get: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
sortBy: z.enum(["ntrpRating", "totalMinutes", "totalSessions", "totalShots"]).default("ntrpRating"),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
}).optional())
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return db.getLeaderboard(input?.sortBy || "ntrpRating", input?.limit || 50);
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// NTRP Rating calculation function
|
// NTRP Rating calculation function
|
||||||
|
|||||||
30
todo.md
30
todo.md
@@ -18,11 +18,37 @@
|
|||||||
- [x] 击球次数统计(参考tennis_analysis)
|
- [x] 击球次数统计(参考tennis_analysis)
|
||||||
- [x] 挥拍速度估算(手腕/手臂关键点帧间位移)
|
- [x] 挥拍速度估算(手腕/手臂关键点帧间位移)
|
||||||
- [x] 运动轨迹可视化(身体中心点移动轨迹)
|
- [x] 运动轨迹可视化(身体中心点移动轨迹)
|
||||||
- [ ] 迷你球场可视化叠加
|
- [x] 迷你球场可视化叠加
|
||||||
- [x] 球员统计面板(综合展示分析数据)
|
- [x] 球员统计面板(综合展示分析数据)
|
||||||
- [x] 帧级别关键时刻标注
|
- [x] 帧级别关键时刻标注
|
||||||
- [x] NTRP自动评分系统(1.0-5.0)
|
- [x] NTRP自动评分系统(1.0-5.0)
|
||||||
- [x] 基于所有历史记录自动更新用户评分
|
- [x] 基于所有历史记录自动更新用户评分
|
||||||
- [x] 多维度评分(姿势正确性、动作流畅性、击球一致性、脚步移动、挥拍速度)
|
- [x] 多维度评分(姿势正确性、动作流畅性、击球一致性、脚步移动、挥拍速度)
|
||||||
- [x] 评分趋势图表展示
|
- [x] 评分趋势图表展示
|
||||||
- [ ] 推送代码到Gitea仓库
|
- [x] 推送代码到Gitea仓库
|
||||||
|
- [x] 社区排行榜 - NTRP评分排名
|
||||||
|
- [x] 社区排行榜 - 训练时长排名
|
||||||
|
- [x] 社区排行榜 - 总击球数排名
|
||||||
|
- [x] 训练打卡系统 - 每日打卡功能
|
||||||
|
- [x] 训练打卡系统 - 连续打卡天数追踪
|
||||||
|
- [x] 成就徽章系统 - 徽章定义和解锁逻辑
|
||||||
|
- [x] 成就徽章系统 - 徽章展示页面
|
||||||
|
- [x] 实时摄像头姿势分析 - 摄像头捕捉
|
||||||
|
- [x] 实时摄像头姿势分析 - MediaPipe实时骨骼叠加
|
||||||
|
- [x] 实时摄像头姿势分析 - 实时评分和建议
|
||||||
|
- [x] 更新DashboardLayout导航添加新页面
|
||||||
|
- [x] 推送更新到Gitea仓库
|
||||||
|
- [x] 移动端全面自适应适配(手机/平板)
|
||||||
|
- [x] 手机端摄像头优化(前后摄切换、分辨率适配)
|
||||||
|
- [x] 摄像头位置调整确认提示(引导用户放置手机)
|
||||||
|
- [x] 在线录制 - 稳定压缩流(MediaRecorder + 码率控制)
|
||||||
|
- [x] 在线录制 - 断线自动重连机制
|
||||||
|
- [x] 在线录制 - 稳定推流方式
|
||||||
|
- [x] 自动剪辑功能(检测关键动作片段自动裁剪)
|
||||||
|
- [x] 实时摄像头姿势分析页面
|
||||||
|
- [x] 完整开发文档(README.md)
|
||||||
|
- [x] API接口文档
|
||||||
|
- [x] 数据库设计文档
|
||||||
|
- [x] 功能列表清单文档
|
||||||
|
- [x] 测试驱动开发(TDD)完整测试套件
|
||||||
|
- [x] 代码规范文档
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户