diff --git a/.manus/db/db-query-1773490572266.json b/.manus/db/db-query-1773490572266.json new file mode 100644 index 0000000..ebc48b6 --- /dev/null +++ b/.manus/db/db-query-1773490572266.json @@ -0,0 +1,9 @@ +{ + "query": "CREATE TABLE `notification_log` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`reminderId` int,\n\t`notificationType` varchar(32) NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`message` text,\n\t`isRead` int DEFAULT 0,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `notification_log_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_reminders` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`reminderType` varchar(32) NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`message` text,\n\t`timeOfDay` varchar(5) NOT NULL,\n\t`daysOfWeek` json NOT NULL,\n\t`isActive` int DEFAULT 1,\n\t`lastTriggered` timestamp,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_reminders_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `tutorial_progress` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`tutorialId` int NOT NULL,\n\t`watched` int DEFAULT 0,\n\t`comparisonVideoId` int,\n\t`selfScore` float,\n\t`notes` text,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `tutorial_progress_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `tutorial_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`category` varchar(64) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner',\n\t`description` text,\n\t`keyPoints` json,\n\t`commonMistakes` json,\n\t`videoUrl` text,\n\t`thumbnailUrl` text,\n\t`duration` int,\n\t`sortOrder` int DEFAULT 0,\n\t`isPublished` int DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `tutorial_videos_id` PRIMARY KEY(`id`)\n);", + "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 `notification_log` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`reminderId` int,\n\t`notificationType` varchar(32) NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`message` text,\n\t`isRead` int DEFAULT 0,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `notification_log_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_reminders` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`reminderType` varchar(32) NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`message` text,\n\t`timeOfDay` varchar(5) NOT NULL,\n\t`daysOfWeek` json NOT NULL,\n\t`isActive` int DEFAULT 1,\n\t`lastTriggered` timestamp,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_reminders_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `tutorial_progress` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`tutorialId` int NOT NULL,\n\t`watched` int DEFAULT 0,\n\t`comparisonVideoId` int,\n\t`selfScore` float,\n\t`notes` text,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `tutorial_progress_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `tutorial_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`category` varchar(64) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner',\n\t`description` text,\n\t`keyPoints` json,\n\t`commonMistakes` json,\n\t`videoUrl` text,\n\t`thumbnailUrl` text,\n\t`duration` int,\n\t`sortOrder` int DEFAULT 0,\n\t`isPublished` int DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `tutorial_videos_id` PRIMARY KEY(`id`)\n);", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 3057 +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 8f2eda0..781275d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,8 @@ import Leaderboard from "./pages/Leaderboard"; import Checkin from "./pages/Checkin"; import LiveCamera from "./pages/LiveCamera"; import Recorder from "./pages/Recorder"; +import Tutorials from "./pages/Tutorials"; +import Reminders from "./pages/Reminders"; function DashboardRoute({ component: Component }: { component: React.ComponentType }) { return ( @@ -61,6 +63,12 @@ function Router() { + + + + + + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index b29dffd..f691ea0 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -22,7 +22,8 @@ import { import { useIsMobile } from "@/hooks/useMobile"; import { LayoutDashboard, LogOut, PanelLeft, Target, Video, - Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot + Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot, + BookOpen, Bell } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation, Redirect } from "wouter"; @@ -39,6 +40,8 @@ const menuItems = [ { icon: Activity, label: "训练进度", path: "/progress", group: "stats" }, { icon: Award, label: "NTRP评分", path: "/rating", group: "stats" }, { icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" }, + { icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" }, + { icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" }, ]; const SIDEBAR_WIDTH_KEY = "sidebar-width"; @@ -226,6 +229,27 @@ function DashboardLayoutContent({ ); })} + + {/* Divider */} + {!isCollapsed &&
} + {!isCollapsed &&

学习与提醒

} + + {menuItems.filter(i => i.group === "learn").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/pages/Analysis.tsx b/client/src/pages/Analysis.tsx index 9fbc498..613ce04 100644 --- a/client/src/pages/Analysis.tsx +++ b/client/src/pages/Analysis.tsx @@ -315,7 +315,7 @@ export default function Analysis() {

视频姿势分析

-

上传训练视频,AI自动识别姿势并给出矫正建议

+

AI姿势识别与矫正反馈

{/* Upload section */} diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 77e0fd5..18ff403 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -19,7 +19,7 @@ export default function Home() {
- Tennis Training Hub + Tennis Hub
@@ -57,37 +56,37 @@ export default function Home() { { icon: Video, title: "AI姿势识别", - desc: "基于MediaPipe的浏览器端实时姿势分析,识别33个身体关键点,精准评估挥拍动作", + desc: "MediaPipe实时分析33个关键点,精准评估挥拍动作", color: "bg-blue-50 text-blue-600", }, { icon: Target, title: "智能训练计划", - desc: "根据您的水平和分析结果,AI自动生成和调整个性化训练方案,只需球拍即可在家训练", + desc: "根据水平和分析结果,AI自动生成和调整训练方案", color: "bg-green-50 text-green-600", }, { icon: Award, title: "NTRP自动评分", - desc: "基于美国网球协会标准,从5个维度综合评估您的技术水平,自动更新评分", + desc: "USTA标准五维度评估,每次分析自动更新评分", color: "bg-purple-50 text-purple-600", }, { icon: Zap, - title: "击球统计分析", - desc: "自动检测击球次数、挥拍速度、击球一致性,量化每次训练效果", + title: "击球统计", + desc: "击球次数、挥拍速度、一致性,量化训练效果", color: "bg-orange-50 text-orange-600", }, { icon: Footprints, - title: "运动轨迹追踪", - desc: "记录身体重心移动轨迹,分析脚步移动模式,提升步法灵活性", + title: "运动轨迹", + desc: "重心移动轨迹分析,优化脚步移动模式", color: "bg-teal-50 text-teal-600", }, { icon: TrendingUp, - title: "进度可视化", - desc: "直观展示训练历史、能力提升趋势和评分变化,激励持续进步", + title: "进度追踪", + desc: "训练历史、能力趋势、评分变化一目了然", color: "bg-indigo-50 text-indigo-600", }, ].map((feature) => ( @@ -107,10 +106,10 @@ export default function Home() {

使用流程

{[ - { step: "1", title: "输入用户名", desc: "无需注册,输入用户名即可开始" }, - { step: "2", title: "生成训练计划", desc: "选择水平,AI生成个性化方案" }, - { step: "3", title: "上传训练视频", desc: "录制挥拍视频并上传分析" }, - { step: "4", title: "获取评分建议", desc: "查看分析结果和矫正建议" }, + { step: "1", title: "输入用户名", desc: "用户名登录即可" }, + { step: "2", title: "生成计划", desc: "AI个性化训练方案" }, + { step: "3", title: "上传视频", desc: "录制挥拍并分析" }, + { step: "4", title: "获取反馈", desc: "评分与矫正建议" }, ].map((item) => (
@@ -126,8 +125,8 @@ export default function Home() { {/* CTA */}
-

准备好提升网球技术了吗?

-

完全免费,无需注册,输入用户名即可开始

+

提升技术,从这里开始

+

输入用户名即可使用全部功能

diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index c2e0507..3670bf1 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -36,8 +36,8 @@ export default function Login() {
-

Tennis Training Hub

-

AI驱动的在家网球训练助手

+

Tennis Hub

+

AI网球训练助手

@@ -94,7 +94,7 @@ export default function Login() {

- 无需注册,输入用户名即可使用全部功能 + 输入用户名即可使用全部功能

diff --git a/client/src/pages/Reminders.tsx b/client/src/pages/Reminders.tsx new file mode 100644 index 0000000..9688a26 --- /dev/null +++ b/client/src/pages/Reminders.tsx @@ -0,0 +1,472 @@ +import { useAuth } from "@/_core/hooks/useAuth"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { toast } from "sonner"; +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + Bell, BellRing, Plus, Trash2, Clock, Calendar, + CheckCircle2, XCircle, Settings, BellOff, Volume2 +} from "lucide-react"; + +const DAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"]; + +const REMINDER_TYPES = [ + { value: "training", label: "训练提醒", icon: }, + { value: "checkin", label: "打卡提醒", icon: }, + { value: "analysis", label: "分析提醒", icon: }, +]; + +export default function Reminders() { + const { user } = useAuth(); + const [showCreate, setShowCreate] = useState(false); + const [newReminder, setNewReminder] = useState({ + reminderType: "training", + title: "", + message: "", + timeOfDay: "08:00", + daysOfWeek: [1, 2, 3, 4, 5] as number[], + }); + + const utils = trpc.useUtils(); + const { data: reminders, isLoading } = trpc.reminder.list.useQuery(undefined, { enabled: !!user }); + const { data: notifications } = trpc.notification.list.useQuery(undefined, { enabled: !!user }); + const { data: unreadCount } = trpc.notification.unreadCount.useQuery(undefined, { enabled: !!user }); + + const createReminder = trpc.reminder.create.useMutation({ + onSuccess: () => { + toast.success("提醒已创建"); + setShowCreate(false); + setNewReminder({ reminderType: "training", title: "", message: "", timeOfDay: "08:00", daysOfWeek: [1, 2, 3, 4, 5] }); + utils.reminder.list.invalidate(); + }, + }); + + const deleteReminder = trpc.reminder.delete.useMutation({ + onSuccess: () => { + toast.success("提醒已删除"); + utils.reminder.list.invalidate(); + }, + }); + + const toggleReminder = trpc.reminder.toggle.useMutation({ + onSuccess: () => { + utils.reminder.list.invalidate(); + }, + }); + + const markAllRead = trpc.notification.markAllRead.useMutation({ + onSuccess: () => { + utils.notification.list.invalidate(); + utils.notification.unreadCount.invalidate(); + toast.success("全部已读"); + }, + }); + + const markRead = trpc.notification.markRead.useMutation({ + onSuccess: () => { + utils.notification.list.invalidate(); + utils.notification.unreadCount.invalidate(); + }, + }); + + const toggleDay = useCallback((day: number) => { + setNewReminder(prev => ({ + ...prev, + daysOfWeek: prev.daysOfWeek.includes(day) + ? prev.daysOfWeek.filter(d => d !== day) + : [...prev.daysOfWeek, day].sort(), + })); + }, []); + + const handleCreate = () => { + if (!newReminder.title.trim()) { + toast.error("请输入提醒标题"); + return; + } + if (newReminder.daysOfWeek.length === 0) { + toast.error("请至少选择一天"); + return; + } + createReminder.mutate(newReminder); + }; + + // Browser notification permission + const [notifPermission, setNotifPermission] = useState("default"); + + useEffect(() => { + if ("Notification" in window) { + setNotifPermission(Notification.permission); + } + }, []); + + const requestPermission = async () => { + if ("Notification" in window) { + const perm = await Notification.requestPermission(); + setNotifPermission(perm); + if (perm === "granted") { + toast.success("通知权限已开启"); + new Notification("Tennis Training Hub", { body: "训练提醒已开启!" }); + } + } + }; + + // Check reminders and trigger browser notifications + useEffect(() => { + if (!reminders || notifPermission !== "granted") return; + + const checkInterval = setInterval(() => { + const now = new Date(); + const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; + const currentDay = now.getDay(); + + reminders.forEach((r: any) => { + if (r.isActive && r.timeOfDay === currentTime) { + const days = typeof r.daysOfWeek === "string" ? JSON.parse(r.daysOfWeek) : r.daysOfWeek; + if (Array.isArray(days) && days.includes(currentDay)) { + new Notification(r.title, { + body: r.message || "该训练了!", + icon: "/favicon.ico", + }); + } + } + }); + }, 60000); // Check every minute + + return () => clearInterval(checkInterval); + }, [reminders, notifPermission]); + + const activeReminders = useMemo(() => reminders?.filter((r: any) => r.isActive) || [], [reminders]); + const inactiveReminders = useMemo(() => reminders?.filter((r: any) => !r.isActive) || [], [reminders]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + 训练提醒 +

+

设置定时提醒,保持训练节奏

+
+
+ {notifPermission !== "granted" && ( + + )} + + + + + + + 创建训练提醒 + +
+
+ + +
+
+ + setNewReminder(p => ({ ...p, title: e.target.value }))} + /> +
+
+ +