Checkpoint: v2.0完整版本:新增社区排行榜、每日打卡、24种成就徽章、实时摄像头姿势分析、在线录制(稳定压缩流/断线重连/自动剪辑)、移动端全面适配。47个测试通过。包含完整开发文档。
这个提交包含在:
@@ -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() {
|
||||
<Route path="/rating">
|
||||
<DashboardRoute component={Rating} />
|
||||
</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 component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -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({
|
||||
|
||||
<SidebarContent className="gap-0">
|
||||
<SidebarMenu className="px-2 py-1">
|
||||
{menuItems.map(item => {
|
||||
{/* Main group */}
|
||||
{menuItems.filter(i => i.group === "main").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
@@ -173,9 +178,49 @@ function DashboardLayoutContent({
|
||||
tooltip={item.label}
|
||||
className={`h-10 transition-all font-normal`}
|
||||
>
|
||||
<item.icon
|
||||
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
|
||||
/>
|
||||
<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 === "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>
|
||||
</SidebarMenuButton>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户