diff --git a/.manus/db/db-query-1773488779079.json b/.manus/db/db-query-1773488779079.json new file mode 100644 index 0000000..db046a0 --- /dev/null +++ b/.manus/db/db-query-1773488779079.json @@ -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 +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..af70b19 --- /dev/null +++ b/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 diff --git a/client/src/App.tsx b/client/src/App.tsx index f244875..8f2eda0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,6 +13,10 @@ import Analysis from "./pages/Analysis"; import Videos from "./pages/Videos"; import Progress from "./pages/Progress"; 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 }) { return ( @@ -45,6 +49,18 @@ function Router() { + + + + + + + + + + + + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 3386b74..b29dffd 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -22,19 +22,23 @@ import { import { useIsMobile } from "@/hooks/useMobile"; import { LayoutDashboard, LogOut, PanelLeft, Target, Video, - Award, Activity, FileVideo + Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation, Redirect } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; const menuItems = [ - { icon: LayoutDashboard, label: "仪表盘", path: "/dashboard" }, - { icon: Target, label: "训练计划", path: "/training" }, - { icon: Video, label: "视频分析", path: "/analysis" }, - { icon: FileVideo, label: "视频库", path: "/videos" }, - { icon: Activity, label: "训练进度", path: "/progress" }, - { icon: Award, label: "NTRP评分", path: "/rating" }, + { icon: LayoutDashboard, label: "仪表盘", path: "/dashboard", group: "main" }, + { icon: Target, label: "训练计划", path: "/training", group: "main" }, + { icon: Flame, label: "每日打卡", path: "/checkin", group: "main" }, + { icon: Camera, label: "实时分析", path: "/live-camera", group: "analysis" }, + { icon: CircleDot, label: "在线录制", path: "/recorder", group: "analysis" }, + { 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"; @@ -163,7 +167,8 @@ function DashboardLayoutContent({ - {menuItems.map(item => { + {/* Main group */} + {menuItems.filter(i => i.group === "main").map(item => { const isActive = location === item.path; return ( @@ -173,9 +178,49 @@ function DashboardLayoutContent({ tooltip={item.label} className={`h-10 transition-all font-normal`} > - + + {item.label} + + + ); + })} + + {/* Divider */} + {!isCollapsed &&
} + {!isCollapsed &&

分析与录制

} + + {menuItems.filter(i => i.group === "analysis").map(item => { + const isActive = location === item.path; + return ( + + setLocation(item.path)} + tooltip={item.label} + className={`h-10 transition-all font-normal`} + > + + {item.label} + + + ); + })} + + {/* Divider */} + {!isCollapsed &&
} + {!isCollapsed &&

统计与排名

} + + {menuItems.filter(i => i.group === "stats").map(item => { + const isActive = location === item.path; + return ( + + setLocation(item.path)} + tooltip={item.label} + className={`h-10 transition-all font-normal`} + > + {item.label} diff --git a/client/src/index.css b/client/src/index.css index 8b81e49..ea5175b 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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); +} diff --git a/client/src/pages/Checkin.tsx b/client/src/pages/Checkin.tsx new file mode 100644 index 0000000..b929d2e --- /dev/null +++ b/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 = { + 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(); + (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 = {}; + (badges || []).forEach((b: any) => { + if (!groups[b.category]) groups[b.category] = []; + groups[b.category].push(b); + }); + return groups; + }, [badges]); + + if (loadingToday || loadingBadges) { + return ( +
+ {[1, 2, 3].map(i => )} +
+ ); + } + + return ( +
+
+

训练打卡

+

坚持每日打卡,解锁成就徽章

+
+ + {/* Check-in card */} + + +
+
+ {alreadyCheckedIn ? ( + + ) : ( + + )} +
+
+

+ {alreadyCheckedIn ? "今日已打卡 ✅" : "今日尚未打卡"} +

+

+ {alreadyCheckedIn + ? `当前连续打卡 ${todayCheckin?.streakCount || (checkinHistory?.[0] as any)?.streakCount || 1} 天` + : "记录今天的训练,保持连续打卡!" + } +

+ {!alreadyCheckedIn && ( +
+