diff --git a/client/src/App.tsx b/client/src/App.tsx index 497067b..08f51d7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,6 +19,8 @@ import LiveCamera from "./pages/LiveCamera"; import Recorder from "./pages/Recorder"; import Tutorials from "./pages/Tutorials"; import Reminders from "./pages/Reminders"; +import Market from "./pages/Market"; +import Matches from "./pages/Matches"; import VisionLab from "./pages/VisionLab"; import Logs from "./pages/Logs"; import AdminConsole from "./pages/AdminConsole"; @@ -76,6 +78,12 @@ function Router() { + + + + + + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index b5a3afb..064f9e7 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -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, Shield + BookOpen, Bell, Microscope, ScrollText, Shield, Radar, Swords } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation, Redirect } from "wouter"; @@ -47,6 +47,8 @@ const menuItems: MenuItem[] = [ { icon: Video, label: "视频分析", path: "/analysis", group: "analysis" }, { icon: FileVideo, label: "视频库", path: "/videos", group: "analysis" }, { icon: Activity, label: "训练进度", path: "/progress", group: "stats" }, + { icon: Radar, label: "球拍行情", path: "/market", group: "stats" }, + { icon: Swords, label: "比赛入库", path: "/matches", group: "stats" }, { icon: Award, label: "NTRP评分", path: "/rating", group: "stats" }, { icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" }, { icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" }, diff --git a/client/src/lib/changelog.ts b/client/src/lib/changelog.ts index 22b82f1..74fd8a9 100644 --- a/client/src/lib/changelog.ts +++ b/client/src/lib/changelog.ts @@ -8,6 +8,46 @@ export type ChangeLogEntry = { }; export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [ + { + version: "2026.03.27-match-review-hub", + releaseDate: "2026-03-27", + repoVersion: "495da60", + summary: + "新增比赛入库板块,支持双人双摄比赛建档、自动计分建议、H1 管理员审核确认、正式结算以及竞赛排行榜。", + features: [ + "新增 `/matches` 页面和侧边栏入口,用户可以查看自己绑定的比赛、双机位状态、自动计分事件时间线以及正式入库结果", + "服务端新增 `match_sessions`、`match_participants`、`match_score_events` 数据表,并把自动计分建议和正式结算接入 `background_tasks` 异步 worker 链路", + "补齐 `0012_market_watch` 与 `0013_match_hub` 的 Drizzle statement breakpoint,让 MySQL 生产迁移可以顺序执行球拍行情与比赛入库表结构并完成上线", + "管理后台新增“比赛入库”工作台,H1 / 管理员可以创建日常或竞赛双人比赛、固定两位用户与对应双机位、刷新自动计分建议、提交审核比分并发起正式结算", + "排行榜新增训练榜 / 竞赛榜双视图;竞赛榜只统计已正式结算的竞赛比赛,避免未审核自动计分直接进入正式名次", + "正式结算后会把比赛写入用户训练记录;日常比赛同步累计训练型指标,竞赛比赛单独进入正式比赛统计与排行榜,不直接用未审核结果污染 NTRP", + ], + tests: [ + "pnpm check", + "pnpm exec vitest run server/match.test.ts server/features.test.ts", + "pnpm build", + "docker compose up -d --build migrate app app-worker", + "线上 smoke: 公开站点部署前仍停留在 2026-03-17 的旧 revision;重新部署后已切到包含比赛入库与更新日志修正的新构建,Playwright 真实登录 `H1` 验证 `/matches`、`/admin` 的“比赛入库”工作台、`/leaderboard` 的“训练榜 / 竞赛榜”以及 `/changelog` 最新条目均可访问;当前仅剩 `/favicon.ico` 404,不影响功能使用", + ], + }, + { + version: "2026.03.23-racket-market-watch", + releaseDate: "2026-03-23", + repoVersion: "495da60", + summary: + "新增球拍行情板块,支持闲鱼/京东/转转异步抓取、球拍结构化分级、用户监控规则和默认飞书 webhook 推送。", + features: [ + "新增 `/market` 页面和侧边栏入口,集中展示低价雷达、监控规则、命中记录和市场后台任务状态", + "服务端新增球拍行情数据表、监控规则与命中记录,并把市场刷新、来源抓取、飞书推送接入现有 `background_tasks` worker 链路", + "抓取结果会对球拍做品牌、型号、系列、品类、重量、成色与价格分级;命中用户目标价后写入站内记录,并按去重规则推送到默认飞书 webhook", + "全局设置新增默认飞书 webhook、抓取超时、重试次数、闲鱼/京东/转转抓取 UA/Cookie 与转转搜索模板配置,管理员可直接在后台调整", + ], + tests: [ + "pnpm check", + "pnpm exec vitest run server/market.test.ts server/market.routes.test.ts", + "pnpm build", + ], + }, { version: "2026.03.17-live-camera-relay-mp4-hardening", releaseDate: "2026-03-17", diff --git a/client/src/lib/matches.ts b/client/src/lib/matches.ts new file mode 100644 index 0000000..9a1ca9a --- /dev/null +++ b/client/src/lib/matches.ts @@ -0,0 +1,118 @@ +type PlayerSlot = "player_a" | "player_b"; + +type PlayerLike = { + playerSlot: PlayerSlot; + userId: number; + userName?: string | null; + isWinner?: number; + cameraSlot?: string | null; + cameraStatus?: string | null; + suggestedGamesWon?: number | null; + finalGamesWon?: number | null; + suggestedSetsWon?: number | null; + finalSetsWon?: number | null; + suggestedPointsWon?: number | null; + finalPointsWon?: number | null; + suggestedStats?: unknown; + finalStats?: unknown; +}; + +export type ScoreLike = { + sets?: Record; + games?: Record; + points?: Record; + winnerSlot?: PlayerSlot | null; +}; + +export type MatchLike = { + matchMode?: "daily" | "competitive" | string | null; + workflowStatus?: string | null; + suggestedScore?: unknown; + finalScore?: unknown; + participants?: PlayerLike[] | null; +}; + +function toScore(raw: unknown): ScoreLike { + if (!raw || typeof raw !== "object") { + return { + sets: { player_a: 0, player_b: 0 }, + games: { player_a: 0, player_b: 0 }, + points: { player_a: 0, player_b: 0 }, + winnerSlot: null, + }; + } + const value = raw as Record; + return { + sets: { + player_a: Number(value.sets?.player_a ?? 0), + player_b: Number(value.sets?.player_b ?? 0), + }, + games: { + player_a: Number(value.games?.player_a ?? 0), + player_b: Number(value.games?.player_b ?? 0), + }, + points: { + player_a: Number(value.points?.player_a ?? 0), + player_b: Number(value.points?.player_b ?? 0), + }, + winnerSlot: value.winnerSlot === "player_a" || value.winnerSlot === "player_b" ? value.winnerSlot : null, + }; +} + +export function getMatchModeLabel(mode?: MatchLike["matchMode"]) { + return mode === "competitive" ? "竞赛" : "日常"; +} + +export function getMatchStatusLabel(status?: string | null) { + switch (status) { + case "draft": + return "待入库"; + case "recording": + return "采集中"; + case "review_pending": + return "待审核"; + case "reviewed": + return "待结算"; + case "finalizing": + return "结算中"; + case "finalized": + return "已入库"; + case "cancelled": + return "已取消"; + default: + return "未知"; + } +} + +export function getMatchDisplayScore(match: MatchLike) { + return toScore(match.finalScore ?? match.suggestedScore); +} + +export function formatMatchScore(match: MatchLike) { + const score = getMatchDisplayScore(match); + return `盘 ${score.sets?.player_a ?? 0}:${score.sets?.player_b ?? 0} · 局 ${score.games?.player_a ?? 0}:${score.games?.player_b ?? 0} · 分 ${score.points?.player_a ?? 0}:${score.points?.player_b ?? 0}`; +} + +export function getMatchParticipant(match: MatchLike, slot: PlayerSlot) { + return (match.participants || []).find((participant) => participant.playerSlot === slot) ?? null; +} + +export function getParticipantResult(match: MatchLike, userId?: number | null) { + if (!userId) return null; + const participant = (match.participants || []).find((item) => item.userId === userId); + if (!participant) return null; + + const score = getMatchDisplayScore(match); + const winnerSlot = score.winnerSlot ?? ((match.participants || []).find((item) => item.isWinner === 1)?.playerSlot ?? null); + if (!winnerSlot) { + return "待确认"; + } + return participant.playerSlot === winnerSlot ? "获胜" : "失利"; +} + +export function getWinnerName(match: MatchLike) { + const score = getMatchDisplayScore(match); + const winnerSlot = score.winnerSlot ?? ((match.participants || []).find((item) => item.isWinner === 1)?.playerSlot ?? null); + if (!winnerSlot) return "待确认"; + return getMatchParticipant(match, winnerSlot)?.userName || winnerSlot; +} diff --git a/client/src/pages/AdminConsole.tsx b/client/src/pages/AdminConsole.tsx index 8779559..c038cdb 100644 --- a/client/src/pages/AdminConsole.tsx +++ b/client/src/pages/AdminConsole.tsx @@ -6,10 +6,13 @@ 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 { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { formatDateTimeShanghai } from "@/lib/time"; +import { formatMatchScore, getMatchModeLabel, getMatchParticipant, getMatchStatusLabel, getWinnerName } from "@/lib/matches"; import { toast } from "sonner"; -import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react"; +import { Activity, Camera, ClipboardCheck, Database, RefreshCw, Settings2, Shield, Sparkles, Swords, Users } from "lucide-react"; export default function AdminConsole() { const { user } = useAuth(); @@ -19,8 +22,32 @@ export default function AdminConsole() { 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 matchesQuery = trpc.match.list.useQuery({ limit: 50 }, { enabled: user?.role === "admin" }); const [settingsDrafts, setSettingsDrafts] = useState>({}); + const [selectedMatchId, setSelectedMatchId] = useState(null); + const [createDraft, setCreateDraft] = useState({ + title: "", + matchMode: "daily", + playerAUserId: "", + playerBUserId: "", + courtName: "", + durationMinutes: "90", + }); + const [reviewDraft, setReviewDraft] = useState({ + playerASet: "0", + playerBSet: "0", + playerAGame: "0", + playerBGame: "0", + playerAPoint: "0", + playerBPoint: "0", + reviewNotes: "", + }); + const [cameraLabelDrafts, setCameraLabelDrafts] = useState>({}); + const matchDetailQuery = trpc.match.get.useQuery( + { matchId: selectedMatchId ?? 0 }, + { enabled: user?.role === "admin" && selectedMatchId != null }, + ); const refreshAllMutation = trpc.admin.refreshAllNtrp.useMutation({ onSuccess: () => { @@ -52,6 +79,52 @@ export default function AdminConsole() { }, onError: (error) => toast.error(`设置更新失败: ${error.message}`), }); + const createMatchMutation = trpc.match.create.useMutation({ + onSuccess: (detail) => { + toast.success("比赛入库草稿已创建"); + utils.match.list.invalidate(); + if (detail?.id) { + setSelectedMatchId(detail.id); + } + }, + onError: (error) => toast.error(`创建失败: ${error.message}`), + }); + const bindCameraMutation = trpc.match.bindCamera.useMutation({ + onSuccess: () => { + toast.success("机位状态已更新"); + utils.match.list.invalidate(); + utils.match.get.invalidate(); + }, + onError: (error) => toast.error(`更新失败: ${error.message}`), + }); + const requestSuggestionMutation = trpc.match.requestSuggestion.useMutation({ + onSuccess: () => { + toast.success("自动计分建议已加入后台队列"); + utils.match.list.invalidate(); + utils.match.get.invalidate(); + utils.admin.tasks.invalidate(); + }, + onError: (error) => toast.error(`提交失败: ${error.message}`), + }); + const reviewSubmitMutation = trpc.match.reviewSubmit.useMutation({ + onSuccess: () => { + toast.success("审核比分已保存"); + utils.match.list.invalidate(); + utils.match.get.invalidate(); + utils.admin.auditLogs.invalidate(); + }, + onError: (error) => toast.error(`审核保存失败: ${error.message}`), + }); + const finalizeMatchMutation = trpc.match.finalize.useMutation({ + onSuccess: () => { + toast.success("正式结算已加入后台队列"); + utils.match.list.invalidate(); + utils.match.get.invalidate(); + utils.admin.tasks.invalidate(); + utils.admin.auditLogs.invalidate(); + }, + onError: (error) => toast.error(`结算失败: ${error.message}`), + }); useEffect(() => { const drafts: Record = {}; @@ -61,11 +134,82 @@ export default function AdminConsole() { setSettingsDrafts(drafts); }, [settingsQuery.data]); + useEffect(() => { + if (!selectedMatchId && matchesQuery.data?.length) { + setSelectedMatchId(matchesQuery.data[0].id); + } + }, [matchesQuery.data, selectedMatchId]); + + useEffect(() => { + const detail = matchDetailQuery.data; + if (!detail) return; + const score: any = detail.finalScore || detail.suggestedScore || {}; + setReviewDraft({ + playerASet: String(score?.sets?.player_a ?? 0), + playerBSet: String(score?.sets?.player_b ?? 0), + playerAGame: String(score?.games?.player_a ?? 0), + playerBGame: String(score?.games?.player_b ?? 0), + playerAPoint: String(score?.points?.player_a ?? 0), + playerBPoint: String(score?.points?.player_b ?? 0), + reviewNotes: detail.reviewNotes || "", + }); + const nextLabels: Record = {}; + (detail.participants || []).forEach((item: any) => { + nextLabels[item.playerSlot] = item.cameraLabel || ""; + }); + setCameraLabelDrafts(nextLabels); + }, [matchDetailQuery.data]); + const totals = useMemo(() => ({ users: (usersQuery.data || []).length, tasks: (tasksQuery.data || []).length, sessions: (liveSessionsQuery.data || []).length, - }), [liveSessionsQuery.data, tasksQuery.data, usersQuery.data]); + matches: (matchesQuery.data || []).length, + }), [liveSessionsQuery.data, matchesQuery.data, tasksQuery.data, usersQuery.data]); + + const submitCreateMatch = () => { + const playerAUserId = Number(createDraft.playerAUserId); + const playerBUserId = Number(createDraft.playerBUserId); + const durationMinutes = Number(createDraft.durationMinutes || 90); + if (!createDraft.title.trim()) { + toast.error("请填写比赛标题"); + return; + } + if (!Number.isFinite(playerAUserId) || !Number.isFinite(playerBUserId)) { + toast.error("请选择两位参赛用户"); + return; + } + createMatchMutation.mutate({ + title: createDraft.title.trim(), + matchMode: createDraft.matchMode as "daily" | "competitive", + playerAUserId, + playerBUserId, + courtName: createDraft.courtName.trim() || undefined, + durationMinutes, + }); + }; + + const submitReview = () => { + if (!selectedMatchId) return; + reviewSubmitMutation.mutate({ + matchId: selectedMatchId, + reviewNotes: reviewDraft.reviewNotes.trim() || undefined, + finalScore: { + sets: { + player_a: Number(reviewDraft.playerASet || 0), + player_b: Number(reviewDraft.playerBSet || 0), + }, + games: { + player_a: Number(reviewDraft.playerAGame || 0), + player_b: Number(reviewDraft.playerBGame || 0), + }, + points: { + player_a: Number(reviewDraft.playerAPoint || 0), + player_b: Number(reviewDraft.playerBPoint || 0), + }, + }, + }); + }; if (user?.role !== "admin") { return ( @@ -96,7 +240,7 @@ export default function AdminConsole() { -
+
@@ -130,11 +274,23 @@ export default function AdminConsole() {
+ + +
+ +
+
比赛入库
+
{totals.matches}
+
+
+
+
- + 用户 + 比赛入库 任务 会话 设置 @@ -176,6 +332,277 @@ export default function AdminConsole() { + +
+ + + + + 新建双人双摄比赛 + + 固定两位用户和两路机位归属,后续自动计分建议、审核和正式结算都围绕该记录展开。 + + + setCreateDraft((current) => ({ ...current, title: event.target.value }))} + className="lg:col-span-2" + /> + + + + setCreateDraft((current) => ({ ...current, courtName: event.target.value }))} + /> + setCreateDraft((current) => ({ ...current, durationMinutes: event.target.value }))} + /> +
+ +
+
+
+ +
+ + + 待处理比赛 + + + {(!matchesQuery.data || matchesQuery.data.length === 0) ? ( +
+ 暂无比赛记录。 +
+ ) : ( + (matchesQuery.data || []).map((match: any) => ( + + )) + )} +
+
+ + + + 审核与结算 + H1 / 管理员可以在这里补机位、拉起自动计分、修正比分并提交正式结算。 + + + {!selectedMatchId ? ( +
+ 先从左侧选择一场比赛。 +
+ ) : matchDetailQuery.isLoading ? ( +
+ + +
+ ) : !matchDetailQuery.data ? ( +
+ 未找到比赛详情。 +
+ ) : ( + <> +
+
+
+
+ {matchDetailQuery.data.title} + {getMatchModeLabel(matchDetailQuery.data.matchMode)} + {getMatchStatusLabel(matchDetailQuery.data.workflowStatus)} +
+
+ 胜者 {getWinnerName(matchDetailQuery.data)} · 当前比分 {formatMatchScore(matchDetailQuery.data)} +
+
+
+ + +
+
+
+ +
+ {(["player_a", "player_b"] as const).map((slot) => { + const participant = getMatchParticipant(matchDetailQuery.data, slot); + if (!participant) return null; + return ( + + + {participant.userName || slot} + {slot === "player_a" ? "A 机位" : "B 机位"} · 当前 {participant.cameraStatus} + + + setCameraLabelDrafts((current) => ({ ...current, [slot]: event.target.value }))} + /> +
+ + +
+
+ 建议局分 {participant.suggestedGamesWon || 0} · 正式局分 {participant.finalGamesWon || 0} +
+
+
+ ); + })} +
+ + + + 审核比分 + 管理员确认后的比分会作为正式结算依据,未填写的指标继续沿用自动建议。 + + +
+ setReviewDraft((current) => ({ ...current, playerASet: event.target.value }))} placeholder="A 盘数" /> + setReviewDraft((current) => ({ ...current, playerAGame: event.target.value }))} placeholder="A 局数" /> + setReviewDraft((current) => ({ ...current, playerAPoint: event.target.value }))} placeholder="A 得分点" /> + setReviewDraft((current) => ({ ...current, playerBSet: event.target.value }))} placeholder="B 盘数" /> + setReviewDraft((current) => ({ ...current, playerBGame: event.target.value }))} placeholder="B 局数" /> + setReviewDraft((current) => ({ ...current, playerBPoint: event.target.value }))} placeholder="B 得分点" /> +
+