Implement live analysis achievements and admin console

这个提交包含在:
cryptocommuniums-afk
2026-03-15 01:39:34 +08:00
父节点 d1b6603061
当前提交 edc66ea5bc
修改 23 个文件,包含 4033 行新增1022 行删除

查看文件

@@ -21,6 +21,7 @@ import Tutorials from "./pages/Tutorials";
import Reminders from "./pages/Reminders";
import VisionLab from "./pages/VisionLab";
import Logs from "./pages/Logs";
import AdminConsole from "./pages/AdminConsole";
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
return (
@@ -59,6 +60,9 @@ function Router() {
<Route path="/checkin">
<DashboardRoute component={Checkin} />
</Route>
<Route path="/achievements">
<DashboardRoute component={Checkin} />
</Route>
<Route path="/live-camera">
<DashboardRoute component={LiveCamera} />
</Route>
@@ -77,6 +81,9 @@ function Router() {
<Route path="/vision-lab">
<DashboardRoute component={VisionLab} />
</Route>
<Route path="/admin">
<DashboardRoute component={AdminConsole} />
</Route>
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>

查看文件

@@ -23,7 +23,7 @@ import { useIsMobile } from "@/hooks/useMobile";
import {
LayoutDashboard, LogOut, PanelLeft, Target, Video,
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot,
BookOpen, Bell, Microscope, ScrollText
BookOpen, Bell, Microscope, ScrollText, Shield
} from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation, Redirect } from "wouter";
@@ -41,7 +41,7 @@ type MenuItem = {
const menuItems: MenuItem[] = [
{ icon: LayoutDashboard, label: "仪表盘", path: "/dashboard", group: "main" },
{ icon: Target, label: "训练计划", path: "/training", group: "main" },
{ icon: Flame, label: "每日打卡", path: "/checkin", 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" },
@@ -53,6 +53,7 @@ const menuItems: MenuItem[] = [
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
{ icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" },
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
{ icon: Shield, label: "管理系统", path: "/admin", group: "learn", adminOnly: true },
];
const mobileNavItems = [

查看文件

@@ -0,0 +1,316 @@
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react";
export default function AdminConsole() {
const { user } = useAuth();
const utils = trpc.useUtils();
const usersQuery = trpc.admin.users.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
const tasksQuery = trpc.admin.tasks.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
const liveSessionsQuery = trpc.admin.liveSessions.useQuery({ limit: 50 }, { enabled: user?.role === "admin" });
const settingsQuery = trpc.admin.settings.useQuery(undefined, { enabled: user?.role === "admin" });
const auditQuery = trpc.admin.auditLogs.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
const [settingsDrafts, setSettingsDrafts] = useState<Record<string, string>>({});
const refreshAllMutation = trpc.admin.refreshAllNtrp.useMutation({
onSuccess: () => {
toast.success("已提交全量 NTRP 刷新任务");
utils.admin.tasks.invalidate();
},
onError: (error) => toast.error(`提交失败: ${error.message}`),
});
const refreshUserMutation = trpc.admin.refreshUserNtrp.useMutation({
onSuccess: () => {
toast.success("已提交用户 NTRP 刷新任务");
utils.admin.tasks.invalidate();
},
onError: (error) => toast.error(`提交失败: ${error.message}`),
});
const refreshUserNowMutation = trpc.admin.refreshUserNtrpNow.useMutation({
onSuccess: () => {
toast.success("用户 NTRP 已即时刷新");
utils.admin.users.invalidate();
utils.admin.auditLogs.invalidate();
},
onError: (error) => toast.error(`即时刷新失败: ${error.message}`),
});
const updateSettingMutation = trpc.admin.updateSetting.useMutation({
onSuccess: () => {
toast.success("设置已更新");
utils.admin.settings.invalidate();
utils.admin.auditLogs.invalidate();
},
onError: (error) => toast.error(`设置更新失败: ${error.message}`),
});
useEffect(() => {
const drafts: Record<string, string> = {};
(settingsQuery.data || []).forEach((item: any) => {
drafts[item.settingKey] = JSON.stringify(item.value ?? null);
});
setSettingsDrafts(drafts);
}, [settingsQuery.data]);
const totals = useMemo(() => ({
users: (usersQuery.data || []).length,
tasks: (tasksQuery.data || []).length,
sessions: (liveSessionsQuery.data || []).length,
}), [liveSessionsQuery.data, tasksQuery.data, usersQuery.data]);
if (user?.role !== "admin") {
return (
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>访</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_30%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
H1
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={() => refreshAllMutation.mutate()} disabled={refreshAllMutation.isPending} className="gap-2">
<RefreshCw className="h-4 w-4" />
NTRP
</Button>
</div>
</div>
</section>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-emerald-700" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{totals.users}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-sky-700" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{totals.tasks}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Sparkles className="h-5 w-5 text-orange-700" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{totals.sessions}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs defaultValue="users" className="space-y-4">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="users"></TabsTrigger>
<TabsTrigger value="tasks"></TabsTrigger>
<TabsTrigger value="sessions"></TabsTrigger>
<TabsTrigger value="settings"></TabsTrigger>
<TabsTrigger value="audit"></TabsTrigger>
</TabsList>
<TabsContent value="users">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription> NTRP</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(usersQuery.data || []).map((item: any) => (
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{item.name}</span>
<Badge variant="outline">{item.role}</Badge>
<Badge variant="outline">NTRP {Number(item.ntrpRating || 1.5).toFixed(1)}</Badge>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{item.totalSessions || 0} · {item.totalMinutes || 0} · {item.currentStreak || 0}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => refreshUserMutation.mutate({ userId: item.id })}>
</Button>
<Button size="sm" onClick={() => refreshUserNowMutation.mutate({ userId: item.id })}>
</Button>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tasks">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(tasksQuery.data || []).map((task: any) => (
<div key={task.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{task.title}</span>
<Badge variant="outline">{task.type}</Badge>
<Badge variant={task.status === "failed" ? "destructive" : task.status === "succeeded" ? "secondary" : "outline"}>
{task.status}
</Badge>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{task.userName || task.userId} · {new Date(task.createdAt).toLocaleString("zh-CN")}
</div>
</div>
<div className="min-w-[180px]">
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{task.message || "无描述"}</span>
<span>{task.progress || 0}%</span>
</div>
<div className="h-2 rounded-full bg-muted">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${task.progress || 0}%` }} />
</div>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="sessions">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(liveSessionsQuery.data || []).map((session: any) => (
<div key={session.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{session.title}</span>
<Badge variant="outline">{session.userName || session.userId}</Badge>
<Badge variant="outline">{session.sessionMode}</Badge>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{session.dominantAction || "unknown"} · {session.effectiveSegments || 0}/{session.totalSegments || 0}
</div>
</div>
<div className="text-sm text-muted-foreground">
{Math.round(session.overallScore || 0)} · {Math.round((session.durationMs || 0) / 1000)}
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Settings2 className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription> JSON </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(settingsQuery.data || []).map((setting: any) => (
<div key={setting.settingKey} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="min-w-0 flex-1">
<div className="font-medium">{setting.label}</div>
<div className="mt-1 text-sm text-muted-foreground">{setting.description}</div>
<Input
value={settingsDrafts[setting.settingKey] || ""}
onChange={(event) => setSettingsDrafts((current) => ({ ...current, [setting.settingKey]: event.target.value }))}
className="mt-3 h-11 rounded-2xl"
/>
</div>
<Button
className="gap-2"
onClick={() => {
try {
const parsed = JSON.parse(settingsDrafts[setting.settingKey] || "null");
updateSettingMutation.mutate({ settingKey: setting.settingKey, value: parsed });
} catch {
toast.error("设置值必须是合法 JSON");
}
}}
>
<Database className="h-4 w-4" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="audit">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(auditQuery.data || []).map((item: any) => (
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{item.actionType}</span>
<Badge variant="outline">{item.entityType}</Badge>
{item.targetUserId ? <Badge variant="outline"> {item.targetUserId}</Badge> : null}
</div>
<div className="mt-2 text-sm text-muted-foreground">
{item.adminName || item.adminUserId} · {new Date(item.createdAt).toLocaleString("zh-CN")}
</div>
</div>
{item.entityId ? <div className="text-sm text-muted-foreground"> {item.entityId}</div> : null}
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

查看文件

@@ -1,238 +1,288 @@
import { trpc } from "@/lib/trpc";
import { useMemo } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { trpc } from "@/lib/trpc";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
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";
import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-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" },
const CATEGORY_META: Record<string, { label: string; tone: string }> = {
consistency: { label: "稳定性", tone: "bg-rose-500/10 text-rose-700" },
volume: { label: "训练", tone: "bg-emerald-500/10 text-emerald-700" },
technique: { label: "动作质量", tone: "bg-sky-500/10 text-sky-700" },
recording: { label: "录制归档", tone: "bg-amber-500/10 text-amber-700" },
analysis: { label: "分析进度", tone: "bg-indigo-500/10 text-indigo-700" },
quality: { label: "高分片段", tone: "bg-fuchsia-500/10 text-fuchsia-700" },
rating: { label: "评分", tone: "bg-violet-500/10 text-violet-700" },
pk: { label: "训练 PK", tone: "bg-orange-500/10 text-orange-700" },
plan: { label: "计划匹配", tone: "bg-cyan-500/10 text-cyan-700" },
};
function getProgressText(item: any) {
if (item.unlockedAt) {
return `已于 ${new Date(item.unlockedAt).toLocaleDateString("zh-CN")} 解锁`;
}
return `${Math.round(item.currentValue || 0)} / ${Math.round(item.targetValue || 0)}`;
}
export default function Checkin() {
const { user } = useAuth();
const [notes, setNotes] = useState("");
const [checkinDone, setCheckinDone] = useState(false);
const achievementQuery = trpc.achievement.list.useQuery();
const statsQuery = trpc.profile.stats.useQuery();
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 achievements = useMemo(() => achievementQuery.data ?? [], [achievementQuery.data]);
const stats = statsQuery.data;
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 groupedAchievements = useMemo(() => {
const groups: Record<string, any[]> = {};
(badges || []).forEach((b: any) => {
if (!groups[b.category]) groups[b.category] = [];
groups[b.category].push(b);
achievements.forEach((item: any) => {
const key = item.category || "other";
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
return groups;
}, [badges]);
}, [achievements]);
if (loadingToday || loadingBadges) {
const unlockedCount = achievements.filter((item: any) => item.unlocked).length;
const nextTarget = achievements
.filter((item: any) => !item.unlocked)
.sort((a: any, b: any) => (b.progressPct || 0) - (a.progressPct || 0))[0];
const heatmapDays = useMemo(() => {
const dayMap = new Map<string, any>();
(stats?.dailyTraining || []).forEach((day: any) => dayMap.set(day.trainingDate, day));
const days = [];
for (let offset = 34; offset >= 0; offset -= 1) {
const current = new Date(Date.now() - offset * 24 * 60 * 60 * 1000);
const key = current.toISOString().slice(0, 10);
const entry = dayMap.get(key);
days.push({
date: key,
sessions: entry?.sessionCount || 0,
minutes: entry?.totalMinutes || 0,
score: entry?.averageScore || 0,
day: current.getDate(),
});
}
return days;
}, [stats?.dailyTraining]);
if (achievementQuery.isLoading || statsQuery.isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
<Skeleton className="h-40 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-80 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">
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(244,63,94,0.12),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<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>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
</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 className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
<div className="text-muted-foreground"></div>
<div className="mt-2 text-xl font-semibold">{unlockedCount}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
<div className="text-muted-foreground"></div>
<div className="mt-2 text-xl font-semibold">{user?.currentStreak || 0}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
<div className="text-muted-foreground"></div>
<div className="mt-2 text-xl font-semibold">{user?.longestStreak || 0}</div>
</div>
</div>
</div>
</section>
{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 className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.9fr)]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription> 35 </CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-7 gap-2 sm:grid-cols-10 lg:grid-cols-7 xl:grid-cols-10">
{heatmapDays.map((day) => {
const level =
day.sessions === 0 ? "bg-muted/45 text-muted-foreground/50" :
day.minutes >= 45 ? "bg-emerald-600 text-white" :
day.minutes >= 20 ? "bg-emerald-400 text-white" :
"bg-emerald-200 text-emerald-900";
return (
<div
key={day.date}
title={`${day.date} · ${day.minutes} 分钟 · ${day.sessions}`}
className={`aspect-square rounded-xl text-[11px] transition-colors flex items-center justify-center ${level}`}
>
{day.day}
</div>
);
})}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-muted/45" /></span>
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-200" /></span>
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-400" /></span>
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-600" /></span>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{nextTarget ? (
<>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold">{nextTarget.name}</div>
<div className="mt-1 text-sm text-muted-foreground">{nextTarget.description}</div>
</div>
<Badge className={CATEGORY_META[nextTarget.category]?.tone || "bg-muted text-foreground"}>
{CATEGORY_META[nextTarget.category]?.label || nextTarget.category}
</Badge>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">{Math.round(nextTarget.progressPct || 0)}%</span>
</div>
<Progress value={nextTarget.progressPct || 0} className="h-2" />
<div className="mt-2 text-xs text-muted-foreground">{getProgressText(nextTarget)}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Trophy className="h-4 w-4" />
</div>
<div className="mt-2 font-medium">{nextTarget.rarity || "common"}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Award className="h-4 w-4" />
</div>
<div className="mt-2 font-medium">Tier {nextTarget.tier || 1}</div>
</div>
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
</div>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{Object.entries(groupedAchievements).map(([category, items]) => (
<section key={category} className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge className={CATEGORY_META[category]?.tone || "bg-muted text-foreground"}>
{CATEGORY_META[category]?.label || category}
</Badge>
<span className="text-sm text-muted-foreground">
{(items as any[]).filter((item) => item.unlocked).length}/{(items as any[]).length}
</span>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{(items as any[]).map((item) => (
<div
key={item.key}
className={`rounded-2xl border p-4 transition-colors ${item.unlocked ? "border-emerald-200 bg-emerald-50/70" : "border-border/60 bg-muted/20"}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="text-2xl">{item.icon || "🎾"}</span>
<div className="font-medium">{item.name}</div>
</div>
)}
</CardContent>
</Card>
<div className="mt-2 text-sm text-muted-foreground">{item.description}</div>
</div>
<Badge variant={item.unlocked ? "secondary" : "outline"}>
{item.unlocked ? "已解锁" : "进行中"}
</Badge>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{getProgressText(item)}</span>
<span>{Math.round(item.progressPct || 0)}%</span>
</div>
<Progress value={item.progressPct || 0} className="h-2" />
</div>
</div>
))}
</div>
</section>
))}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Flame className="h-5 w-5 text-rose-600" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{user?.currentStreak || 0}</div>
</div>
</div>
);
})}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Radar className="h-5 w-5 text-sky-600" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{(stats?.recentLiveSessions || []).length}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Swords className="h-5 w-5 text-orange-600" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{(stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5).toFixed(1)}</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);

查看文件

@@ -1,30 +1,24 @@
import { useMemo } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Target, Video, Activity, TrendingUp, Award, Clock,
Zap, BarChart3, ChevronRight
} from "lucide-react";
import { Activity, Award, ChevronRight, Clock3, Sparkles, Swords, Target, Video } from "lucide-react";
import { useLocation } from "wouter";
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, AreaChart, Area } from "recharts";
function NTRPBadge({ rating }: { rating: number }) {
let level = "初学者";
let color = "bg-gray-100 text-gray-700";
if (rating >= 4.0) { level = "高级竞技"; color = "bg-purple-100 text-purple-700"; }
else if (rating >= 3.0) { level = "中高级"; color = "bg-blue-100 text-blue-700"; }
else if (rating >= 2.5) { level = "中级"; color = "bg-green-100 text-green-700"; }
else if (rating >= 2.0) { level = "初中级"; color = "bg-yellow-100 text-yellow-700"; }
else if (rating >= 1.5) { level = "初级"; color = "bg-orange-100 text-orange-700"; }
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${color}`}>
NTRP {rating.toFixed(1)} · {level}
</span>
);
const level =
rating >= 4.0 ? "高级竞技" :
rating >= 3.5 ? "高级" :
rating >= 3.0 ? "中高级" :
rating >= 2.5 ? "中级" :
rating >= 2.0 ? "初中级" :
rating >= 1.5 ? "初级" :
"入门";
return <Badge className="bg-emerald-500/10 text-emerald-700">NTRP {rating.toFixed(1)} · {level}</Badge>;
}
export default function Dashboard() {
@@ -32,247 +26,218 @@ export default function Dashboard() {
const { data: stats, isLoading } = trpc.profile.stats.useQuery();
const [, setLocation] = useLocation();
const unlockedAchievements = useMemo(
() => (stats?.achievements || []).filter((item: any) => item.unlocked).length,
[stats?.achievements],
);
const recentTrainingDays = useMemo(
() => [...(stats?.dailyTraining || [])].slice(-7).reverse(),
[stats?.dailyTraining],
);
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-32 w-full" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <Skeleton key={i} className="h-28" />)}
<div className="space-y-4">
<Skeleton className="h-40 w-full" />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{[1, 2, 3, 4].map((index) => <Skeleton key={index} className="h-32" />)}
</div>
<Skeleton className="h-80 w-full" />
</div>
);
}
const ratingData = stats?.ratingHistory?.map((r: any) => ({
date: new Date(r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
rating: r.rating,
...((r.dimensionScores as any) || {}),
})) || [];
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight" data-testid="dashboard-title">
{user?.name || "未命名用户"}
</h1>
<div className="flex items-center gap-3 mt-2">
<NTRPBadge rating={stats?.ntrpRating || 1.5} />
<span className="text-sm text-muted-foreground">
{stats?.totalSessions || 0}
</span>
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_30%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight" data-testid="dashboard-title">
{user?.name || "未命名用户"}
</h1>
<div className="mt-3 flex flex-wrap items-center gap-2">
<NTRPBadge rating={stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5} />
<Badge variant="outline"> {stats?.totalSessions || 0} </Badge>
<Badge variant="outline"> {unlockedAchievements} </Badge>
</div>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button data-testid="dashboard-training-button" onClick={() => setLocation("/training")} className="gap-2">
<Target className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => setLocation("/live-camera")} className="gap-2">
<Sparkles className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => setLocation("/analysis")} className="gap-2">
<Video className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex gap-2">
<Button data-testid="dashboard-training-button" onClick={() => setLocation("/training")} className="gap-2">
<Target className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => setLocation("/analysis")} className="gap-2">
<Video className="h-4 w-4" />
</Button>
</div>
</div>
</section>
{/* Stats cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-0 shadow-sm bg-gradient-to-br from-green-50 to-emerald-50">
<CardContent className="pt-5 pb-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground">NTRP评分</p>
<p className="text-2xl font-bold text-primary mt-1">
{(stats?.ntrpRating || 1.5).toFixed(1)}
</p>
<div className="text-sm text-muted-foreground"> NTRP</div>
<div className="mt-2 text-2xl font-semibold">{(stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5).toFixed(1)}</div>
</div>
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Award className="h-5 w-5 text-primary" />
<div className="rounded-2xl bg-emerald-500/10 p-3 text-emerald-700">
<Award className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5 pb-4">
<CardContent className="pt-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold mt-1">{stats?.totalSessions || 0}</p>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-2xl font-semibold">{stats?.totalMinutes || 0}<span className="ml-1 text-sm font-normal text-muted-foreground"></span></div>
</div>
<div className="h-10 w-10 rounded-xl bg-blue-50 flex items-center justify-center">
<Activity className="h-5 w-5 text-blue-600" />
<div className="rounded-2xl bg-sky-500/10 p-3 text-sky-700">
<Clock3 className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5 pb-4">
<CardContent className="pt-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold mt-1">{stats?.totalMinutes || 0}<span className="text-sm font-normal text-muted-foreground ml-1"></span></p>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-2xl font-semibold">{stats?.totalShots || 0}</div>
</div>
<div className="h-10 w-10 rounded-xl bg-orange-50 flex items-center justify-center">
<Clock className="h-5 w-5 text-orange-600" />
<div className="rounded-2xl bg-amber-500/10 p-3 text-amber-700">
<Activity className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5 pb-4">
<CardContent className="pt-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold mt-1">{stats?.totalShots || 0}</p>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-2 text-2xl font-semibold">{(stats?.recentLiveSessions || []).length}</div>
</div>
<div className="h-10 w-10 rounded-xl bg-purple-50 flex items-center justify-center">
<Zap className="h-5 w-5 text-purple-600" />
<div className="rounded-2xl bg-rose-500/10 p-3 text-rose-700">
<Swords className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Rating trend chart */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.85fr)]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary" />
NTRP评分趋势
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => setLocation("/rating")} className="text-xs gap-1">
<ChevronRight className="h-3 w-3" />
<div>
<CardTitle className="text-base"> 7 </CardTitle>
<CardDescription></CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => setLocation("/progress")} className="gap-1 text-xs">
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</CardHeader>
<CardContent>
{ratingData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={ratingData}>
<defs>
<linearGradient id="ratingGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis domain={[1, 5]} tick={{ fontSize: 11 }} />
<Tooltip />
<Area type="monotone" dataKey="rating" stroke="oklch(0.55 0.16 145)" fill="url(#ratingGradient)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
<CardContent className="space-y-3">
{recentTrainingDays.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<BarChart3 className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Recent analyses */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Video className="h-4 w-4 text-primary" />
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => setLocation("/videos")} className="text-xs gap-1">
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</CardHeader>
<CardContent>
{(stats?.recentAnalyses?.length || 0) > 0 ? (
<div className="space-y-3">
{stats!.recentAnalyses.slice(0, 4).map((a: any) => (
<div key={a.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-primary/5 flex items-center justify-center text-xs font-bold text-primary">
{Math.round(a.overallScore || 0)}
</div>
<div>
<p className="text-sm font-medium">{a.exerciseType || "综合分析"}</p>
<p className="text-xs text-muted-foreground">
{new Date(a.createdAt).toLocaleDateString("zh-CN")}
{a.shotCount ? ` · ${a.shotCount}次击球` : ""}
</p>
recentTrainingDays.map((day: any) => (
<div key={day.trainingDate} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="font-medium">{day.trainingDate}</div>
<div className="mt-1 text-sm text-muted-foreground">
{day.sessionCount || 0} · {day.totalMinutes || 0} · {day.effectiveActions || 0}
</div>
</div>
<div className="flex items-center gap-2">
<Progress value={a.overallScore || 0} className="w-16 h-1.5" />
<span className="text-xs text-muted-foreground">{Math.round(a.overallScore || 0)}</span>
<div className="min-w-[150px]">
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span></span>
<span>{Math.round(day.averageScore || 0)}</span>
</div>
<Progress value={day.averageScore || 0} className="h-2" />
</div>
</div>
))}
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<Video className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Quick actions */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base font-semibold"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={() => setLocation("/training")}
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
>
<div className="h-10 w-10 rounded-xl bg-green-100 flex items-center justify-center shrink-0">
<Target className="h-5 w-5 text-green-700" />
</div>
<div>
<p className="font-medium text-sm"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
</button>
<button
onClick={() => setLocation("/analysis")}
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
>
<div className="h-10 w-10 rounded-xl bg-blue-100 flex items-center justify-center shrink-0">
<Video className="h-5 w-5 text-blue-700" />
</div>
<div>
<p className="font-medium text-sm"></p>
<p className="text-xs text-muted-foreground">姿</p>
</div>
</button>
<button
onClick={() => setLocation("/rating")}
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
>
<div className="h-10 w-10 rounded-xl bg-purple-100 flex items-center justify-center shrink-0">
<Award className="h-5 w-5 text-purple-700" />
</div>
<div>
<p className="font-medium text-sm">NTRP评分</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</button>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(stats?.recentLiveSessions || []).length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
</div>
) : (
(stats?.recentLiveSessions || []).slice(-4).reverse().map((session: any) => (
<div key={session.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium">{session.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{new Date(session.createdAt).toLocaleString("zh-CN")}</div>
</div>
<Badge variant="outline">{Math.round(session.overallScore || 0)} </Badge>
</div>
<div className="mt-3 text-sm text-muted-foreground">
{session.totalSegments || 0} · {session.effectiveSegments || 0}
</div>
</div>
))
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(stats?.achievements || []).slice(0, 4).map((item: any) => (
<div key={item.key} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium">{item.name}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.description}</div>
</div>
<Badge variant={item.unlocked ? "secondary" : "outline"}>
{item.unlocked ? "已解锁" : `${Math.round(item.progressPct || 0)}%`}
</Badge>
</div>
</div>
))}
<Button variant="outline" className="w-full gap-2" onClick={() => setLocation("/checkin")}>
<ChevronRight className="h-4 w-4" />
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

文件差异内容过多而无法显示 加载差异

查看文件

@@ -1,228 +1,271 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { Award, TrendingUp, Target, Zap, Footprints, Activity, Wind } from "lucide-react";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { toast } from "sonner";
import { Activity, Award, Loader2, RefreshCw, Radar, TrendingUp } from "lucide-react";
import {
ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis,
PolarRadiusAxis, Radar, AreaChart, Area, XAxis, YAxis,
CartesianGrid, Tooltip, Legend
Area,
AreaChart,
CartesianGrid,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar as RadarChartShape,
RadarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
const NTRP_LEVELS = [
{ min: 1.0, max: 1.5, label: "初学者", desc: "刚开始学习网球,正在学习基本击球动作", color: "bg-gray-100 text-gray-700" },
{ min: 1.5, max: 2.0, label: "初级", desc: "能够进行简单的来回击球,但缺乏一致性", color: "bg-orange-100 text-orange-700" },
{ min: 2.0, max: 2.5, label: "初中级", desc: "击球更加稳定,开始理解基本策略", color: "bg-yellow-100 text-yellow-700" },
{ min: 2.5, max: 3.0, label: "中级", desc: "能够稳定地进行中速击球,具备基本的网前技术", color: "bg-green-100 text-green-700" },
{ min: 3.0, max: 3.5, label: "中高级", desc: "击球力量和控制力增强,开始使用旋转", color: "bg-blue-100 text-blue-700" },
{ min: 3.5, max: 4.0, label: "高级", desc: "具备全面的技术,能够在比赛中运用战术", color: "bg-indigo-100 text-indigo-700" },
{ min: 4.0, max: 4.5, label: "高级竞技", desc: "技术精湛,具备强大的进攻和防守能力", color: "bg-purple-100 text-purple-700" },
{ min: 4.5, max: 5.0, label: "专业水平", desc: "接近职业水平,全面的技术和战术能力", color: "bg-red-100 text-red-700" },
{ min: 1.0, max: 1.5, label: "入门" },
{ min: 1.5, max: 2.0, label: "初级" },
{ min: 2.0, max: 2.5, label: "初中级" },
{ min: 2.5, max: 3.0, label: "中级" },
{ min: 3.0, max: 3.5, label: "中高级" },
{ min: 3.5, max: 4.0, label: "高级" },
{ min: 4.0, max: 4.5, label: "高级竞技" },
{ min: 4.5, max: 5.1, label: "接近专业" },
];
function getNTRPLevel(rating: number) {
return NTRP_LEVELS.find(l => rating >= l.min && rating < l.max) || NTRP_LEVELS[0];
function getLevel(rating: number) {
return NTRP_LEVELS.find((item) => rating >= item.min && rating < item.max)?.label || "入门";
}
export default function Rating() {
const { user } = useAuth();
const { data: ratingData } = trpc.rating.current.useQuery();
const { data: history, isLoading } = trpc.rating.history.useQuery();
const { data: stats } = trpc.profile.stats.useQuery();
const [taskId, setTaskId] = useState<string | null>(null);
const currentQuery = trpc.rating.current.useQuery();
const historyQuery = trpc.rating.history.useQuery();
const refreshMineMutation = trpc.rating.refreshMine.useMutation({
onSuccess: (data) => {
setTaskId(data.taskId);
toast.success("NTRP 刷新任务已加入后台队列");
},
onError: (error) => toast.error(`NTRP 刷新失败: ${error.message}`),
});
const taskQuery = useBackgroundTask(taskId);
const currentRating = ratingData?.rating || 1.5;
const level = getNTRPLevel(currentRating);
const currentRating = currentQuery.data?.rating || 1.5;
const latestSnapshot = currentQuery.data?.latestSnapshot as any;
const history = historyQuery.data ?? [];
// Get latest dimension scores
const latestWithDimensions = history?.find((h: any) => h.dimensionScores);
const dimensions = (latestWithDimensions as any)?.dimensionScores || {};
const radarData = useMemo(() => {
const scores = latestSnapshot?.dimensionScores || {};
return [
{ dimension: "姿态", value: scores.poseAccuracy || 0 },
{ dimension: "一致性", value: scores.strokeConsistency || 0 },
{ dimension: "脚步", value: scores.footwork || 0 },
{ dimension: "流畅度", value: scores.fluidity || 0 },
{ dimension: "时机", value: scores.timing || 0 },
{ dimension: "比赛准备", value: scores.matchReadiness || 0 },
];
}, [latestSnapshot?.dimensionScores]);
const radarData = [
{ dimension: "姿势准确", value: dimensions.poseAccuracy || 0, fullMark: 100 },
{ dimension: "击球一致", value: dimensions.strokeConsistency || 0, fullMark: 100 },
{ dimension: "脚步移动", value: dimensions.footwork || 0, fullMark: 100 },
{ dimension: "动作流畅", value: dimensions.fluidity || 0, fullMark: 100 },
{ dimension: "力量", value: dimensions.power || 0, fullMark: 100 },
];
const trendData = useMemo(
() => history.map((item: any) => ({
date: new Date(item.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
rating: item.rating,
})).reverse(),
[history],
);
const trendData = (history || []).map((h: any) => ({
date: new Date(h.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
rating: h.rating,
}));
if (isLoading) {
if (currentQuery.isLoading || historyQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-60 w-full" />
<Skeleton className="h-80 w-full" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">NTRP评分系统</h1>
<p className="text-muted-foreground text-sm mt-1"></p>
</div>
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_32%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">NTRP </h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
</p>
</div>
<Button onClick={() => refreshMineMutation.mutate()} disabled={refreshMineMutation.isPending} className="gap-2">
{refreshMineMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
NTRP
</Button>
</div>
</section>
{/* Current rating card */}
<Card className="border-0 shadow-sm overflow-hidden">
<div className="bg-gradient-to-r from-primary/10 via-primary/5 to-transparent p-6">
<div className="flex flex-col sm:flex-row sm:items-center gap-6">
<div className="flex items-center gap-4">
<div className="h-20 w-20 rounded-2xl bg-primary/10 flex items-center justify-center">
<span className="text-3xl font-bold text-primary">{currentRating.toFixed(1)}</span>
</div>
{(taskQuery.data?.status === "queued" || taskQuery.data?.status === "running") ? (
<Alert>
<RefreshCw className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>{taskQuery.data.message || "NTRP 刷新任务正在后台执行。"}</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(320px,360px)]">
<Card className="border-0 shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xl font-bold">{level.label}</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-md">{level.desc}</p>
<div className="flex items-center gap-2 mt-2">
<Award className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">NTRP {currentRating.toFixed(1)}</span>
<div className="flex items-center gap-3">
<div className="rounded-3xl bg-emerald-500/10 px-5 py-4 text-4xl font-semibold text-emerald-700">
{currentRating.toFixed(1)}
</div>
<div>
<div className="text-lg font-semibold">{getLevel(currentRating)}</div>
<div className="mt-1 text-sm text-muted-foreground"></div>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge className="bg-emerald-500/10 text-emerald-700">
<Award className="mr-1 h-3.5 w-3.5" />
NTRP {currentRating.toFixed(1)}
</Badge>
{latestSnapshot?.triggerType ? <Badge variant="outline"> {latestSnapshot.triggerType}</Badge> : null}
{latestSnapshot?.createdAt ? (
<Badge variant="outline">
{new Date(latestSnapshot.createdAt).toLocaleString("zh-CN")}
</Badge>
) : null}
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-muted-foreground"></div>
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.activeDays || 0}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-muted-foreground"></div>
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.totalEffectiveActions || 0}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-muted-foreground"></div>
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.liveSessions || 0}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-muted-foreground">PK </div>
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.totalPk || 0}</div>
</div>
</div>
</div>
</div>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Radar chart */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Target className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{Object.keys(dimensions).length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<RadarChart data={radarData}>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 12 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 10 }} />
<Radar
name="能力值"
dataKey="value"
stroke="oklch(0.55 0.16 145)"
fill="oklch(0.55 0.16 145)"
fillOpacity={0.3}
strokeWidth={2}
/>
</RadarChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<Target className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Rating trend */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription>NTRP评分随时间的变化</CardDescription>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{trendData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={trendData}>
<defs>
<linearGradient id="ratingGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis domain={[1, 5]} tick={{ fontSize: 11 }} />
<Tooltip />
<Area type="monotone" dataKey="rating" stroke="oklch(0.55 0.16 145)" fill="url(#ratingGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<TrendingUp className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
<CardContent className="space-y-3">
{radarData.map((item) => (
<div key={item.dimension}>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-muted-foreground">{item.dimension}</span>
<span className="font-medium">{Math.round(item.value)}</span>
</div>
<div className="h-2 rounded-full bg-muted/70">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${item.value}%` }} />
</div>
</div>
)}
))}
</CardContent>
</Card>
</div>
{/* Dimension details */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base"></CardTitle>
<CardDescription>NTRP评分由以下五个维度加权计算</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{[
{ icon: Target, label: "姿势准确性", weight: "30%", desc: "关节角度与标准动作的匹配度", value: dimensions.poseAccuracy },
{ icon: Zap, label: "击球一致性", weight: "25%", desc: "多次击球动作的稳定性", value: dimensions.strokeConsistency },
{ icon: Footprints, label: "脚步移动", weight: "20%", desc: "步法灵活性和重心转移", value: dimensions.footwork },
{ icon: Wind, label: "动作流畅性", weight: "15%", desc: "动作连贯性和平滑度", value: dimensions.fluidity },
{ icon: Activity, label: "力量", weight: "10%", desc: "挥拍速度和爆发力", value: dimensions.power },
].map(item => (
<div key={item.label} className="p-4 rounded-xl border bg-card">
<div className="flex items-center gap-2 mb-2">
<item.icon className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{item.label}</span>
</div>
<p className="text-2xl font-bold">{item.value ? Math.round(item.value) : "--"}</p>
<p className="text-xs text-muted-foreground mt-1"> {item.weight}</p>
<p className="text-xs text-muted-foreground">{item.desc}</p>
<div className="grid gap-4 xl:grid-cols-2">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<TrendingUp className="h-4 w-4 text-primary" />
NTRP
</CardTitle>
</CardHeader>
<CardContent>
{trendData.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-14 text-center text-sm text-muted-foreground">
</div>
))}
</div>
</CardContent>
</Card>
) : (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={trendData}>
<defs>
<linearGradient id="rating-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.26} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis domain={[1, 5]} tick={{ fontSize: 12 }} />
<Tooltip />
<Area type="monotone" dataKey="rating" stroke="#10b981" strokeWidth={2} fill="url(#rating-fill)" />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Radar className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription> NTRP </CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={280}>
<RadarChart data={radarData}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 12 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} />
<RadarChartShape dataKey="value" stroke="#10b981" fill="#10b981" fillOpacity={0.25} />
</RadarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* NTRP level reference */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">NTRP等级参考</CardTitle>
<CardDescription>(USTA)</CardDescription>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{NTRP_LEVELS.map(l => (
<div
key={l.label}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
currentRating >= l.min && currentRating < l.max
? "bg-primary/5 border border-primary/20"
: "hover:bg-muted/50"
}`}
>
<Badge className={`${l.color} border shrink-0`}>
{l.min.toFixed(1)}-{l.max.toFixed(1)}
</Badge>
<div>
<span className="text-sm font-medium">{l.label}</span>
<p className="text-xs text-muted-foreground">{l.desc}</p>
<CardContent className="space-y-3">
{history.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
history.map((item: any) => (
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">NTRP {Number(item.rating || 0).toFixed(1)}</span>
<Badge variant="outline">{item.triggerType}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">{new Date(item.createdAt).toLocaleString("zh-CN")}</div>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Activity className="h-4 w-4" />
{item.sourceSummary?.analyses || 0}
</span>
<span> {item.sourceSummary?.liveSessions || 0}</span>
<span> {item.sourceSummary?.totalEffectiveActions || 0}</span>
</div>
</div>
{currentRating >= l.min && currentRating < l.max && (
<Badge variant="default" className="ml-auto shrink-0"></Badge>
)}
</div>
))}
</div>
))
)}
</CardContent>
</Card>
</div>

查看文件

@@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
@@ -169,6 +170,7 @@ export default function Recorder() {
const [cameraActive, setCameraActive] = useState(false);
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
const [durationMs, setDurationMs] = useState(0);
const [sessionMode, setSessionMode] = useState<"practice" | "pk">("practice");
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [queuedSegments, setQueuedSegments] = useState(0);
@@ -637,13 +639,15 @@ export default function Recorder() {
sessionId: session.id,
title: title.trim() || session.title,
exerciseType: "recording",
sessionMode,
durationMinutes: Math.max(1, Math.round((Date.now() - recordingStartedAtRef.current) / 60000)),
});
toast.success("录制已提交,后台正在整理回放文件");
} catch (error: any) {
toast.error(`结束录制失败: ${error?.message || "未知错误"}`);
setMode("recording");
}
}, [closePeer, finalizeTaskMutation, flushPendingSegments, stopCamera, stopRecorder, syncSessionState, title]);
}, [closePeer, finalizeTaskMutation, flushPendingSegments, sessionMode, stopCamera, stopRecorder, syncSessionState, title]);
const resetRecorder = useCallback(async () => {
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
@@ -948,6 +952,10 @@ export default function Recorder() {
<MonitorUp className="h-3.5 w-3.5" />
WebRTC
</Badge>
<Badge variant="outline" className="gap-1.5 border-white/15 bg-white/5 text-white/80">
<Sparkles className="h-3.5 w-3.5" />
{sessionMode === "practice" ? "练习会话" : "训练 PK"}
</Badge>
</div>
<div>
@@ -1045,13 +1053,22 @@ export default function Recorder() {
</div>
<div className="border-t border-border/60 bg-card/80 p-4">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_auto]">
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="本次训练录制标题"
className="h-12 rounded-2xl border-border/60"
/>
<Select value={sessionMode} onValueChange={(value) => setSessionMode(value as "practice" | "pk")} disabled={mode !== "idle" && mode !== "archived"}>
<SelectTrigger className="h-12 rounded-2xl border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="practice"></SelectItem>
<SelectItem value="pk"> PK</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-wrap gap-2">
{renderPrimaryActions()}
</div>