Implement live analysis achievements and admin console
这个提交包含在:
@@ -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 = [
|
||||
|
||||
316
client/src/pages/AdminConsole.tsx
普通文件
316
client/src/pages/AdminConsole.tsx
普通文件
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户