- {idx < 3 ? rankIcons[idx] : (
-
+
-
+
{item.name || "匿名用户"}
- {isMe &&
我}
+ {isMe ?
我 : null}
NTRP {(item.ntrpRating || 1.5).toFixed(1)}
·
{skillLevelMap[item.skillLevel || "beginner"] || "初级"}
- {(item.currentStreak || 0) > 0 && (
+ {scope === "training" ? (
+ (item.currentStreak || 0) > 0 ? (
+ <>
+ ·
+ 🔥{item.currentStreak}天
+ >
+ ) : null
+ ) : (
<>
·
- 🔥{item.currentStreak}天
+ {item.wins || 0} 胜 / {item.losses || 0} 负
>
)}
- {/* Value */}
-
+
{getValue(item, tab.key)}
- {tab.unit &&
{tab.unit}
}
+ {tab.unit ?
{tab.unit}
: null}
);
diff --git a/client/src/pages/Market.tsx b/client/src/pages/Market.tsx
new file mode 100644
index 0000000..cda72f0
--- /dev/null
+++ b/client/src/pages/Market.tsx
@@ -0,0 +1,788 @@
+import { useState } from "react";
+import { trpc } from "@/lib/trpc";
+import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import {
+ BellRing,
+ CircleDollarSign,
+ ExternalLink,
+ Filter,
+ Gauge,
+ Plus,
+ Radar,
+ RefreshCcw,
+ Search,
+ ShieldCheck,
+ Siren,
+ Sparkles,
+} from "lucide-react";
+
+const SOURCE_LABELS = {
+ xianyu: "闲鱼",
+ jd: "京东",
+ zhuanzhuan: "转转",
+} as const;
+
+const CATEGORY_LABELS = {
+ adult: "成人球拍",
+ junior: "儿童球拍",
+ competitive: "比赛拍",
+ recreational: "娱乐拍",
+ unknown: "未知",
+} as const;
+
+const GRADE_LABELS = {
+ high_value: "高性价比",
+ standard: "标准价",
+ overpriced: "偏高",
+ pending_review: "待确认",
+} as const;
+
+const GRADE_TONES = {
+ high_value: "bg-emerald-50 text-emerald-700 border-emerald-200",
+ standard: "bg-sky-50 text-sky-700 border-sky-200",
+ overpriced: "bg-orange-50 text-orange-700 border-orange-200",
+ pending_review: "bg-muted text-muted-foreground border-border",
+} as const;
+
+const TASK_STATUS_LABELS = {
+ queued: "排队中",
+ running: "执行中",
+ succeeded: "已完成",
+ failed: "失败",
+} as const;
+
+type RuleDraft = {
+ ruleId?: number;
+ title: string;
+ brand: string;
+ modelKeyword: string;
+ seriesKeyword: string;
+ category: "all" | keyof typeof CATEGORY_LABELS;
+ weightMinGram: string;
+ weightMaxGram: string;
+ targetPrice: string;
+ pushEnabled: boolean;
+};
+
+const EMPTY_RULE: RuleDraft = {
+ title: "",
+ brand: "",
+ modelKeyword: "",
+ seriesKeyword: "",
+ category: "all",
+ weightMinGram: "",
+ weightMaxGram: "",
+ targetPrice: "",
+ pushEnabled: true,
+};
+
+function formatCurrency(value: number | null | undefined) {
+ if (value == null) return "-";
+ return `¥${Number(value).toFixed(0)}`;
+}
+
+function normalizeDraftToPayload(draft: RuleDraft) {
+ const targetPrice = Number.parseFloat(draft.targetPrice);
+ if (!Number.isFinite(targetPrice) || targetPrice <= 0) {
+ throw new Error("请输入有效的目标价格");
+ }
+
+ const payload = {
+ title: draft.title.trim() || undefined,
+ brand: draft.brand.trim(),
+ modelKeyword: draft.modelKeyword.trim() || undefined,
+ seriesKeyword: draft.seriesKeyword.trim() || undefined,
+ category: draft.category === "all" ? undefined : draft.category,
+ weightMinGram: draft.weightMinGram ? Number.parseFloat(draft.weightMinGram) : undefined,
+ weightMaxGram: draft.weightMaxGram ? Number.parseFloat(draft.weightMaxGram) : undefined,
+ targetPrice,
+ pushEnabled: draft.pushEnabled,
+ };
+
+ if (!payload.brand) {
+ throw new Error("请输入要监控的品牌");
+ }
+
+ if (
+ payload.weightMinGram != null &&
+ payload.weightMaxGram != null &&
+ payload.weightMinGram > payload.weightMaxGram
+ ) {
+ throw new Error("最小重量不能大于最大重量");
+ }
+
+ return payload;
+}
+
+export default function Market() {
+ const utils = trpc.useUtils();
+ const [filters, setFilters] = useState({
+ source: "all",
+ category: "all",
+ keyword: "",
+ lowPriceOnly: true,
+ });
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [ruleDraft, setRuleDraft] = useState
(EMPTY_RULE);
+ const [webhookDialogOpen, setWebhookDialogOpen] = useState(false);
+ const [webhookDraft, setWebhookDraft] = useState("");
+
+ const dashboardQuery = trpc.market.dashboard.useQuery(undefined, {
+ refetchInterval: (query) => {
+ const hasActive = (query.state.data?.recentTasks ?? []).some((task) => task.status === "queued" || task.status === "running");
+ return hasActive ? 4_000 : 12_000;
+ },
+ });
+ const listingsQuery = trpc.market.listings.useQuery({
+ source: filters.source === "all" ? undefined : filters.source as "xianyu" | "jd" | "zhuanzhuan",
+ category: filters.category === "all" ? undefined : filters.category as keyof typeof CATEGORY_LABELS,
+ keyword: filters.keyword.trim() || undefined,
+ lowPriceOnly: filters.lowPriceOnly,
+ limit: 40,
+ });
+ const rulesQuery = trpc.market.watchRuleList.useQuery();
+ const hitsQuery = trpc.market.watchHits.useQuery({ limit: 20 });
+ const pushConfigQuery = trpc.market.pushConfigGet.useQuery();
+
+ const createRuleMutation = trpc.market.watchRuleCreate.useMutation({
+ onSuccess: () => {
+ toast.success("监控规则已创建,后台开始抓取");
+ setDialogOpen(false);
+ setRuleDraft(EMPTY_RULE);
+ void utils.market.watchRuleList.invalidate();
+ void utils.market.dashboard.invalidate();
+ },
+ onError: (error) => toast.error(`创建失败: ${error.message}`),
+ });
+
+ const updateRuleMutation = trpc.market.watchRuleUpdate.useMutation({
+ onSuccess: () => {
+ toast.success("监控规则已更新");
+ setDialogOpen(false);
+ setRuleDraft(EMPTY_RULE);
+ void utils.market.watchRuleList.invalidate();
+ void utils.market.dashboard.invalidate();
+ },
+ onError: (error) => toast.error(`更新失败: ${error.message}`),
+ });
+
+ const deleteRuleMutation = trpc.market.watchRuleDelete.useMutation({
+ onSuccess: () => {
+ toast.success("监控规则已删除");
+ void utils.market.watchRuleList.invalidate();
+ void utils.market.dashboard.invalidate();
+ },
+ onError: (error) => toast.error(`删除失败: ${error.message}`),
+ });
+
+ const toggleRuleMutation = trpc.market.watchRuleToggle.useMutation({
+ onSuccess: () => {
+ void utils.market.watchRuleList.invalidate();
+ void utils.market.dashboard.invalidate();
+ },
+ onError: (error) => toast.error(`切换失败: ${error.message}`),
+ });
+
+ const refreshMutation = trpc.market.triggerRefresh.useMutation({
+ onSuccess: () => {
+ toast.success("刷新任务已加入后台队列");
+ void utils.market.dashboard.invalidate();
+ },
+ onError: (error) => toast.error(`刷新失败: ${error.message}`),
+ });
+
+ const updateWebhookMutation = trpc.market.pushConfigUpdate.useMutation({
+ onSuccess: () => {
+ toast.success("默认飞书 webhook 已更新");
+ setWebhookDialogOpen(false);
+ void utils.market.pushConfigGet.invalidate();
+ void utils.market.dashboard.invalidate();
+ },
+ onError: (error) => toast.error(`更新失败: ${error.message}`),
+ });
+
+ const handleSubmitRule = () => {
+ try {
+ const payload = normalizeDraftToPayload(ruleDraft);
+ if (ruleDraft.ruleId) {
+ updateRuleMutation.mutate({ ruleId: ruleDraft.ruleId, ...payload });
+ } else {
+ createRuleMutation.mutate(payload);
+ }
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "请检查规则输入");
+ }
+ };
+
+ const handleEditRule = (rule: any) => {
+ setRuleDraft({
+ ruleId: rule.id,
+ title: rule.title ?? "",
+ brand: rule.brand ?? "",
+ modelKeyword: rule.modelKeyword ?? "",
+ seriesKeyword: rule.seriesKeyword ?? "",
+ category: (rule.category as RuleDraft["category"]) ?? "all",
+ weightMinGram: rule.weightMinGram != null ? String(rule.weightMinGram) : "",
+ weightMaxGram: rule.weightMaxGram != null ? String(rule.weightMaxGram) : "",
+ targetPrice: rule.targetPrice != null ? String(rule.targetPrice) : "",
+ pushEnabled: rule.pushEnabled === 1,
+ });
+ setDialogOpen(true);
+ };
+
+ const recentTasks = dashboardQuery.data?.recentTasks ?? [];
+ const sourceSummary = dashboardQuery.data?.sourceSummary ?? [];
+ const listings = listingsQuery.data ?? [];
+ const rules = rulesQuery.data ?? [];
+ const hits = hitsQuery.data ?? [];
+
+ return (
+
+
+
+
+
+
+
+ 球拍行情雷达
+
+
全网抓取、规则分级、低价监控与飞书推送
+
+ 这块面板会异步抓取闲鱼、京东和转转来源,对球拍做品牌、型号、重量、品类和价格分级。命中你的目标价后,系统会先写入站内记录,再走默认飞书 webhook 推送。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Active Rules
+
{dashboardQuery.data?.overview.activeRuleCount ?? 0}
+
+
+
+
+
+
+
+
+
+
24H Listings
+
{dashboardQuery.data?.overview.recentListingCount ?? 0}
+
+
+
+
+
+
+
+
+
+
Recent Hits
+
{dashboardQuery.data?.overview.hitCount ?? 0}
+
+
+
+
+
+
+
+
+
+
Push Channel
+
+ {pushConfigQuery.data?.hasWebhookConfigured ? "飞书默认通道已启用" : "尚未配置"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 低价雷达
+
+ 这里展示已经被分级为低价候选,或命中过规则阈值的最新球拍。
+
+
+
+
+
+ setFilters((current) => ({ ...current, keyword: event.target.value }))}
+ />
+
+
+
+
+
+
+ 仅低价
+
+
setFilters((current) => ({ ...current, lowPriceOnly: checked }))}
+ />
+
+
+
+
+
+ {listingsQuery.isLoading ? (
+
+ 正在拉取行情数据...
+
+ ) : listings.length === 0 ? (
+
+ 当前筛选下还没有球拍数据。先添加监控规则,或点击“立即刷新”触发后台抓取。
+
+ ) : (
+ listings.map((listing) => (
+
+
+
+
+ {SOURCE_LABELS[listing.source as keyof typeof SOURCE_LABELS]}
+
+ {GRADE_LABELS[(listing.gradeLevel as keyof typeof GRADE_LABELS) ?? "pending_review"]}
+
+ {listing.isLowPriceCandidate === 1 ? (
+ 低价候选
+ ) : null}
+
+
+
{listing.title}
+
+ {listing.brand || "未识别品牌"}
+ {listing.model ? ` · ${listing.model}` : ""}
+ {listing.category ? ` · ${CATEGORY_LABELS[listing.category as keyof typeof CATEGORY_LABELS] ?? listing.category}` : ""}
+ {listing.weightGram != null ? ` · ${Math.round(listing.weightGram)}g` : ""}
+
+
+
{listing.gradeReason || "暂无分级说明"}
+
+
+
+
Price Watch
+
{formatCurrency(listing.price)}
+
+ 原价 {formatCurrency(listing.originalPrice)} · 抓取于 {formatDateTimeShanghai(listing.fetchedAt)}
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+ 监控规则
+ 按品牌、型号、重量和目标价管理你的关注清单。
+
+
+
+
+
+ {rules.length === 0 ? (
+
+ 还没有监控规则。先添加你关心的品牌、型号和目标价,系统之后会自动后台更新。
+
+ ) : (
+ rules.map((rule) => (
+
+
+
+
{rule.title}
+
{rule.brand}
+
+ {rule.isActive === 1 ? "监控中" : "已暂停"}
+
+ {rule.pushEnabled === 1 ? (
+
飞书推送
+ ) : null}
+
+
+ 目标价 {formatCurrency(rule.targetPrice)}
+ {rule.seriesKeyword ? ` · 系列 ${rule.seriesKeyword}` : ""}
+ {rule.modelKeyword ? ` · 型号 ${rule.modelKeyword}` : ""}
+ {rule.category ? ` · ${CATEGORY_LABELS[rule.category as keyof typeof CATEGORY_LABELS] ?? rule.category}` : ""}
+ {rule.weightMinGram != null ? ` · ≥${Math.round(rule.weightMinGram)}g` : ""}
+ {rule.weightMaxGram != null ? ` · ≤${Math.round(rule.weightMaxGram)}g` : ""}
+
+
+
+ 上次检查 {rule.lastCheckedAt ? formatDateTimeShanghai(rule.lastCheckedAt) : "尚未检查"}
+ {rule.lastMatchedAt ? ` · 最近命中 ${formatDateShanghai(rule.lastMatchedAt)}` : ""}
+
+
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ 来源状态
+ 按最近抓取结果观察每个来源的健康度和低价产出。
+
+
+ {sourceSummary.map((item) => (
+
+
+
+
{SOURCE_LABELS[item.source as keyof typeof SOURCE_LABELS]}
+
+ 最近抓取 {item.latestFetchedAt ? formatDateTimeShanghai(item.latestFetchedAt) : "暂无数据"}
+
+
+
+
{item.total}
+
{item.lowPriceCount} 条低价候选
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ 命中记录
+ 命中过规则的商品会在这里保留历史,用来做去重和价格二次下降提醒。
+
+
+ {hits.length === 0 ? (
+
+ 还没有命中记录。等后台抓取到低于目标价的球拍后,这里会显示推送状态和命中详情。
+
+ ) : (
+ hits.map((hit) => (
+
+
+
+
+ {SOURCE_LABELS[hit.listingSource as keyof typeof SOURCE_LABELS]}
+
+ {GRADE_LABELS[(hit.listingGradeLevel as keyof typeof GRADE_LABELS) ?? "pending_review"]}
+
+
+ {hit.status === "matched" ? "已命中" : hit.status === "push_queued" ? "待推送" : hit.status === "pushed" ? "已推送" : "已抑制"}
+
+
+
{hit.ruleTitle}
+
{hit.listingTitle}
+
+ 命中价 {formatCurrency(hit.matchedPrice)} · 当前价 {formatCurrency(hit.listingPrice)} · 推送 {hit.pushCount} 次
+
+
+ 首次命中 {formatDateTimeShanghai(hit.firstMatchedAt)} · 最近更新 {formatDateTimeShanghai(hit.lastMatchedAt)}
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ 最近后台动态
+
+ 抓取、规则刷新和飞书推送都会在这里留下最近状态。
+
+
+ {recentTasks.length === 0 ? (
+
+ 暂无市场相关后台任务。
+
+ ) : (
+ recentTasks.map((task) => (
+
+
+
+
{task.title}
+
{task.message || "无额外说明"}
+
+
+ {TASK_STATUS_LABELS[task.status as keyof typeof TASK_STATUS_LABELS] ?? task.status}
+
+
+
+ {formatDateTimeShanghai(task.createdAt)}
+ {task.progress}%
+
+ {task.error ? (
+
{task.error}
+ ) : null}
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/client/src/pages/Matches.tsx b/client/src/pages/Matches.tsx
new file mode 100644
index 0000000..3b913a7
--- /dev/null
+++ b/client/src/pages/Matches.tsx
@@ -0,0 +1,327 @@
+import { useEffect, useMemo, useState } from "react";
+import { useAuth } from "@/_core/hooks/useAuth";
+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 { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
+import { formatMatchScore, getMatchModeLabel, getMatchParticipant, getMatchStatusLabel, getParticipantResult, getWinnerName } from "@/lib/matches";
+import { toast } from "sonner";
+import { Camera, ClipboardCheck, ShieldCheck, Swords, Trophy } from "lucide-react";
+
+type StatusFilter = "all" | "draft" | "review_pending" | "reviewed" | "finalized";
+
+const statusOptions: Array<{ key: StatusFilter; label: string }> = [
+ { key: "all", label: "全部" },
+ { key: "review_pending", label: "待审核" },
+ { key: "reviewed", label: "待结算" },
+ { key: "finalized", label: "已入库" },
+];
+
+function StatCard(props: { icon: React.ReactNode; label: string; value: string | number; hint: string }) {
+ return (
+
+
+
+
+ {props.icon}
+
+
+
{props.label}
+
{props.value}
+
{props.hint}
+
+
+
+
+ );
+}
+
+export default function Matches() {
+ const { user } = useAuth();
+ const utils = trpc.useUtils();
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [selectedMatchId, setSelectedMatchId] = useState(null);
+
+ const statsQuery = trpc.match.stats.useQuery();
+ const listQuery = trpc.match.list.useQuery({
+ limit: 50,
+ workflowStatus: statusFilter,
+ });
+ const detailQuery = trpc.match.get.useQuery(
+ { matchId: selectedMatchId ?? 0 },
+ { enabled: selectedMatchId != null },
+ );
+
+ const requestSuggestionMutation = trpc.match.requestSuggestion.useMutation({
+ onSuccess: () => {
+ toast.success("已提交自动计分建议刷新");
+ utils.match.list.invalidate();
+ utils.match.get.invalidate();
+ },
+ onError: (error) => toast.error(`提交失败: ${error.message}`),
+ });
+
+ useEffect(() => {
+ if (!selectedMatchId && listQuery.data?.length) {
+ setSelectedMatchId(listQuery.data[0].id);
+ }
+ }, [listQuery.data, selectedMatchId]);
+
+ const selectedMatch = detailQuery.data;
+ const myResult = useMemo(() => getParticipantResult(selectedMatch || {}, user?.id), [selectedMatch, user?.id]);
+
+ if (statsQuery.isLoading || listQuery.isLoading) {
+ return (
+
+
+
+ {[1, 2, 3].map((item) => )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
比赛入库
+
+ 这里展示双人双摄比赛的入库记录、自动计分建议、审核状态和正式结算结果。管理员负责最终确认,普通用户只能看到与自己绑定的比赛。
+
+
+ {selectedMatch ? (
+
+ {getMatchModeLabel(selectedMatch.matchMode)}
+ {getMatchStatusLabel(selectedMatch.workflowStatus)}
+ {myResult ? {myResult} : null}
+
+ ) : null}
+
+
+
+
+ }
+ label="我的比赛"
+ value={statsQuery.data?.total || 0}
+ hint={`日常 ${statsQuery.data?.daily || 0} 场 · 竞赛 ${statsQuery.data?.competitive || 0} 场`}
+ />
+ }
+ label="已入库"
+ value={statsQuery.data?.finalized || 0}
+ hint={`待审核/待结算 ${statsQuery.data?.reviewPending || 0} 场`}
+ />
+ }
+ label="竞赛胜场"
+ value={statsQuery.data?.competitiveWins || 0}
+ hint={`已绑定机位 ${statsQuery.data?.camerasBound || 0} 个`}
+ />
+
+
+
setStatusFilter(value as StatusFilter)} className="space-y-4">
+
+ {statusOptions.map((option) => (
+ {option.label}
+ ))}
+
+
+
+
+
+
+ 比赛列表
+ 按入库流程查看自己可访问的比赛。
+
+
+ {(!listQuery.data || listQuery.data.length === 0) ? (
+
+ 当前筛选下暂无比赛记录。
+
+ ) : (
+ listQuery.data.map((match: any) => {
+ const playerA = getMatchParticipant(match, "player_a");
+ const playerB = getMatchParticipant(match, "player_b");
+ return (
+
+ );
+ })
+ )}
+
+
+
+
+
+ 比赛详情
+ 包含双人双摄绑定状态、自动计分建议、事件时间线和正式结算结果。
+
+
+ {!selectedMatchId ? (
+
+ 先从左侧选择一场比赛。
+
+ ) : detailQuery.isLoading ? (
+
+ ) : !selectedMatch ? (
+
+ 未找到该比赛详情。
+
+ ) : (
+ <>
+
+
+
+
+
{selectedMatch.title}
+ {getMatchModeLabel(selectedMatch.matchMode)}
+ {getMatchStatusLabel(selectedMatch.workflowStatus)}
+ {myResult ? {myResult} : null}
+
+
+ 场地 {selectedMatch.courtName || "未填写"} · 计划 {selectedMatch.durationMinutes || 90} 分钟 · {formatDateTimeShanghai(selectedMatch.scheduledAt || selectedMatch.createdAt)}
+
+
+
+
+
+
+
+ 当前显示比分:{formatMatchScore(selectedMatch)} · 胜者 {getWinnerName(selectedMatch)}
+
+
+
+
+ {(["player_a", "player_b"] as const).map((slot) => {
+ const participant = getMatchParticipant(selectedMatch, slot);
+ const stats = participant?.finalStats || participant?.suggestedStats || {};
+ return (
+
+
+ {participant?.userName || slot}
+
+ {slot === "player_a" ? "主位 A" : "主位 B"} · 机位 {participant?.cameraSlot || "-"}
+
+
+
+
+ 机位状态
+
+
+ {participant?.cameraStatus || "pending"}
+
+
+
+
+
局分
+
{participant?.finalGamesWon ?? participant?.suggestedGamesWon ?? 0}
+
+
+
盘分
+
{participant?.finalSetsWon ?? participant?.suggestedSetsWon ?? 0}
+
+
+
得分点
+
{participant?.finalPointsWon ?? participant?.suggestedPointsWon ?? 0}
+
+
+
一发进球率
+
{Number((stats as any).firstServePct || 0).toFixed(1)}%
+
+
+
+
ACE {(stats as any).aces || 0}
+
制胜分 {(stats as any).winners || 0}
+
失误 {(stats as any).unforcedErrors || 0}
+
+ {participant?.isWinner === 1 ? (
+
+
+ 已确认胜者
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+
+ 事件时间线
+
+ 自动计分与人工审核事件共 {selectedMatch.events?.length || 0} 条。
+
+
+
+ {(!selectedMatch.events || selectedMatch.events.length === 0) ? (
+
+ 暂无自动计分事件。
+
+ ) : (
+ selectedMatch.events.map((event: any) => (
+
+
+
+ #{event.eventIndex}
+ {event.eventType}
+ {event.source}
+ {event.winnerSlot ? 胜方 {event.winnerSlot} : null}
+
+
+ {event.matchSecond != null ? `第 ${event.matchSecond} 秒 · ` : ""}
+ 置信度 {Number(event.confidence || 0).toFixed(2)}
+
+
+
+ {JSON.stringify(event.payload ?? {}, null, 2)}
+
+
+ ))
+ )}
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/client/src/pages/Training.tsx b/client/src/pages/Training.tsx
index 4bb3df6..f2392fb 100644
--- a/client/src/pages/Training.tsx
+++ b/client/src/pages/Training.tsx
@@ -135,16 +135,16 @@ const ASSESSMENT_FIELDS = [
{
key: "enduranceScore",
label: "耐力",
- method: "按持续跑动、连续多球、间歇训练后的状态打分。",
- hint: "重点看 30-60 分钟训练后是否还能保持节奏。",
- benchmarkLabel: "12 分钟跑参考",
- benchmarkNote: "如无法跑场,可用多球或折返跑的持续能力做近似判断,距离越长越好。",
+ method: "优先做 12 分钟跑:热身后连续跑 12 分钟,记录总距离。没有跑道或计距条件时,再用 20 分钟连续多球或折返跑后半段状态做替代判断。",
+ hint: "填写时尽量写清测试类型和结果,例如“12 分钟跑 2350 米”或“20 分钟多球后最后 5 分钟明显掉速”。不要只按主观感觉随意打分。",
+ benchmarkLabel: "优先参考:12 分钟跑",
+ benchmarkNote: "分数优先按 12 分钟跑总距离判断。只有无法测距离时,才参考连续多球/折返跑在中后段是否明显掉速,手动选择最接近的档位。",
scoreGuide: [
- { score: "5", range: "≥ 2900 米", note: "长时间训练后仍有充足余量" },
- { score: "4", range: "2600 - 2899 米", note: "耐力较好" },
- { score: "3", range: "2200 - 2599 米", note: "基础达标" },
- { score: "2", range: "1800 - 2199 米", note: "训练后后程掉速明显" },
- { score: "1", range: "< 1800 米", note: "耐力偏弱,需优先提升" },
+ { score: "5", range: "≥ 2900 米", note: "末段仍能稳住配速,长时间训练后还有余量" },
+ { score: "4", range: "2600 - 2899 米", note: "整体耐力较好,后程有轻微下降但节奏稳定" },
+ { score: "3", range: "2200 - 2599 米", note: "基础达标,30-45 分钟训练后还能维持常规节奏" },
+ { score: "2", range: "1800 - 2199 米", note: "中后段掉速明显,连续多球或折返跑容易顶不住" },
+ { score: "1", range: "< 1800 米", note: "耐力偏弱,短时间内就出现明显喘和失速,需优先提升" },
],
},
{
diff --git a/client/src/pages/Videos.tsx b/client/src/pages/Videos.tsx
index 7953278..9aa13b2 100644
--- a/client/src/pages/Videos.tsx
+++ b/client/src/pages/Videos.tsx
@@ -5,8 +5,10 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { Slider } from "@/components/ui/slider";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
@@ -510,287 +512,383 @@ export default function Videos() {
)}