文件
tennis-training-hub/client/src/pages/Progress.tsx
2026-03-15 17:34:24 +08:00

344 行
16 KiB
TypeScript

import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Activity, Calendar, CheckCircle2, ChevronDown, ChevronUp, Clock, TrendingUp, Target, Sparkles } from "lucide-react";
import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time";
import { useState } from "react";
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
LineChart, Line, Legend
} from "recharts";
import { useLocation } from "wouter";
const ACTION_LABEL_MAP: Record<string, string> = {
forehand: "正手挥拍",
backhand: "反手挥拍",
serve: "发球",
volley: "截击",
overhead: "高压",
slice: "切削",
lob: "挑高球",
unknown: "未知动作",
};
function getRecordMetadata(record: any) {
if (!record?.metadata || typeof record.metadata !== "object") {
return null;
}
return record.metadata as Record<string, any>;
}
function getActionLabel(actionType: string) {
return ACTION_LABEL_MAP[actionType] || actionType;
}
export default function Progress() {
const { user } = useAuth();
const { data: records, isLoading } = trpc.record.list.useQuery({ limit: 100 });
const { data: analyses } = trpc.analysis.list.useQuery();
const { data: stats } = trpc.profile.stats.useQuery();
const [, setLocation] = useLocation();
const [expandedRecordId, setExpandedRecordId] = useState<number | null>(null);
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
// Aggregate data by date for charts
const dateMap = new Map<string, { date: string; sessions: number; minutes: number; avgScore: number; scores: number[] }>();
(records || []).forEach((r: any) => {
const date = formatMonthDayShanghai(r.trainingDate || r.createdAt);
const existing = dateMap.get(date) || { date, sessions: 0, minutes: 0, avgScore: 0, scores: [] };
existing.sessions++;
existing.minutes += r.durationMinutes || 0;
if (r.poseScore) existing.scores.push(r.poseScore);
dateMap.set(date, existing);
});
const chartData = Array.from(dateMap.values()).map(d => ({
...d,
avgScore: d.scores.length > 0 ? Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length) : 0,
}));
// Analysis score trend
const scoreTrend = (analyses || []).map((a: any) => ({
date: formatMonthDayShanghai(a.createdAt),
overall: Math.round(a.overallScore || 0),
consistency: Math.round(a.strokeConsistency || 0),
footwork: Math.round(a.footworkScore || 0),
fluidity: Math.round(a.fluidityScore || 0),
}));
const completedRecords = (records || []).filter((r: any) => r.completed === 1);
const totalMinutes = (records || []).reduce((sum: number, r: any) => sum + (r.durationMinutes || 0), 0);
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>
{/* Summary stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Activity className="h-3 w-3" />
</div>
<p className="text-2xl font-bold">{stats?.totalSessions || 0}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Clock className="h-3 w-3" />
</div>
<p className="text-2xl font-bold">{totalMinutes}<span className="text-sm font-normal ml-1"></span></p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<CheckCircle2 className="h-3 w-3" />
</div>
<p className="text-2xl font-bold">{completedRecords.length}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Target className="h-3 w-3" />
</div>
<p className="text-2xl font-bold">{analyses?.length || 0}<span className="text-sm font-normal ml-1"></span></p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Sparkles className="h-3 w-3" />
</div>
<p className="text-2xl font-bold">{stats?.recentLiveSessions?.length || 0}<span className="text-sm font-normal ml-1"></span></p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Training frequency chart */}
<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" />
</CardTitle>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="sessions" fill="oklch(0.55 0.16 145)" radius={[4, 4, 0, 0]} name="训练次数" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<Calendar className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Score improvement 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>
</CardHeader>
<CardContent>
{scoreTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={scoreTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="overall" stroke="oklch(0.55 0.16 145)" strokeWidth={2} name="综合" dot={{ r: 3 }} />
<Line type="monotone" dataKey="consistency" stroke="#3b82f6" strokeWidth={1.5} name="一致性" dot={{ r: 2 }} />
<Line type="monotone" dataKey="footwork" stroke="#f59e0b" strokeWidth={1.5} name="脚步" dot={{ r: 2 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[220px] 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>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Recent records */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{(records?.length || 0) > 0 ? (
<div className="space-y-2">
{(records || []).slice(0, 20).map((record: any) => {
const metadata = getRecordMetadata(record);
const actionSummary = metadata?.actionSummary && typeof metadata.actionSummary === "object"
? Object.entries(metadata.actionSummary as Record<string, number>).filter(([, count]) => Number(count) > 0)
: [];
const topActions = actionSummary
.sort((left, right) => Number(right[1]) - Number(left[1]))
.slice(0, 3);
return (
<div key={record.id} className="border-b py-2 last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className={`mt-0.5 h-8 w-8 rounded-lg flex items-center justify-center ${
record.completed ? "bg-green-50 text-green-600" : "bg-muted text-muted-foreground"
}`}>
{record.completed ? <CheckCircle2 className="h-4 w-4" /> : <Activity className="h-4 w-4" />}
</div>
<div>
<p className="text-sm font-medium">{record.exerciseName}</p>
<p className="text-xs text-muted-foreground">
{formatDateTimeShanghai(record.trainingDate || record.createdAt, { second: "2-digit" })}
{record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
{record.sourceType ? ` · ${record.sourceType}` : ""}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
{record.actionCount ? (
<Badge variant="outline" className="text-[11px]">
{record.actionCount}
</Badge>
) : null}
{metadata?.dominantAction ? (
<Badge variant="secondary" className="text-[11px]">
{getActionLabel(String(metadata.dominantAction))}
</Badge>
) : null}
{topActions.map(([actionType, count]) => (
<Badge key={`${record.id}-${actionType}`} variant="secondary" className="text-[11px]">
{getActionLabel(actionType)} {count}
</Badge>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{record.poseScore && (
<Badge variant="secondary" className="text-xs">{Math.round(record.poseScore)}</Badge>
)}
{record.completed ? (
<Badge className="bg-green-100 text-green-700 text-xs"></Badge>
) : (
<Badge variant="outline" className="text-xs"></Badge>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setExpandedRecordId((current) => current === record.id ? null : record.id)}
>
{expandedRecordId === record.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</div>
{expandedRecordId === record.id ? (
<div className="mt-3 rounded-2xl border border-border/60 bg-muted/20 p-4 text-sm">
<div className="grid gap-3 md:grid-cols-2">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-1 font-medium">{formatDateTimeShanghai(record.trainingDate || record.createdAt, { second: "2-digit" })}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-1 font-medium"> {record.actionCount || 0}</div>
</div>
</div>
{metadata ? (
<div className="mt-4 space-y-3">
{metadata.dominantAction ? (
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-1 font-medium">{getActionLabel(String(metadata.dominantAction))}</div>
</div>
) : null}
{metadata.actionSummary && Object.keys(metadata.actionSummary).length > 0 ? (
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 flex flex-wrap gap-2">
{Object.entries(metadata.actionSummary as Record<string, number>)
.filter(([, count]) => Number(count) > 0)
.map(([actionType, count]) => (
<Badge key={actionType} variant="secondary">
{getActionLabel(actionType)} {count}
</Badge>
))}
</div>
</div>
) : null}
{metadata.validityStatus ? (
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-1 font-medium">{String(metadata.validityStatus)}</div>
{metadata.invalidReason ? (
<div className="mt-1 text-xs text-muted-foreground">{String(metadata.invalidReason)}</div>
) : null}
</div>
) : null}
{record.notes ? (
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-1 text-sm text-muted-foreground">{record.notes}</div>
</div>
) : null}
</div>
) : null}
</div>
) : null}
</div>
);
})}
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
<Activity className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p></p>
<Button variant="link" size="sm" onClick={() => setLocation("/training")} className="mt-2">
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}