Add market watch and match hub workflows
这个提交包含在:
@@ -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() {
|
||||
<Route path="/reminders">
|
||||
<DashboardRoute component={Reminders} />
|
||||
</Route>
|
||||
<Route path="/market">
|
||||
<DashboardRoute component={Market} />
|
||||
</Route>
|
||||
<Route path="/matches">
|
||||
<DashboardRoute component={Matches} />
|
||||
</Route>
|
||||
<Route path="/logs">
|
||||
<DashboardRoute component={Logs} />
|
||||
</Route>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
118
client/src/lib/matches.ts
普通文件
118
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<PlayerSlot, number>;
|
||||
games?: Record<PlayerSlot, number>;
|
||||
points?: Record<PlayerSlot, number>;
|
||||
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<string, any>;
|
||||
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;
|
||||
}
|
||||
@@ -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<Record<string, string>>({});
|
||||
const [selectedMatchId, setSelectedMatchId] = useState<number | null>(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<Record<string, string>>({});
|
||||
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<string, string> = {};
|
||||
@@ -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<string, string> = {};
|
||||
(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() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -130,11 +274,23 @@ export default function AdminConsole() {
|
||||
</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-amber-700" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">比赛入库</div>
|
||||
<div className="mt-1 text-xl font-semibold">{totals.matches}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="users" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="users">用户</TabsTrigger>
|
||||
<TabsTrigger value="matches">比赛入库</TabsTrigger>
|
||||
<TabsTrigger value="tasks">任务</TabsTrigger>
|
||||
<TabsTrigger value="sessions">会话</TabsTrigger>
|
||||
<TabsTrigger value="settings">设置</TabsTrigger>
|
||||
@@ -176,6 +332,277 @@ export default function AdminConsole() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="matches">
|
||||
<div className="space-y-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="h-4 w-4 text-primary" />
|
||||
新建双人双摄比赛
|
||||
</CardTitle>
|
||||
<CardDescription>固定两位用户和两路机位归属,后续自动计分建议、审核和正式结算都围绕该记录展开。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 lg:grid-cols-6">
|
||||
<Input
|
||||
placeholder="比赛标题"
|
||||
value={createDraft.title}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
className="lg:col-span-2"
|
||||
/>
|
||||
<select
|
||||
className="h-11 rounded-2xl border border-border/70 bg-background px-3 text-sm"
|
||||
value={createDraft.matchMode}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, matchMode: event.target.value }))}
|
||||
>
|
||||
<option value="daily">日常</option>
|
||||
<option value="competitive">竞赛</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-11 rounded-2xl border border-border/70 bg-background px-3 text-sm"
|
||||
value={createDraft.playerAUserId}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, playerAUserId: event.target.value }))}
|
||||
>
|
||||
<option value="">选择 A 用户</option>
|
||||
{(usersQuery.data || []).map((item: any) => (
|
||||
<option key={`a-${item.id}`} value={item.id}>{item.name || item.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-11 rounded-2xl border border-border/70 bg-background px-3 text-sm"
|
||||
value={createDraft.playerBUserId}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, playerBUserId: event.target.value }))}
|
||||
>
|
||||
<option value="">选择 B 用户</option>
|
||||
{(usersQuery.data || []).map((item: any) => (
|
||||
<option key={`b-${item.id}`} value={item.id}>{item.name || item.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
placeholder="场地 / 场馆"
|
||||
value={createDraft.courtName}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, courtName: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="时长(分钟)"
|
||||
value={createDraft.durationMinutes}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, durationMinutes: event.target.value }))}
|
||||
/>
|
||||
<div className="lg:col-span-6">
|
||||
<Button onClick={submitCreateMatch} disabled={createMatchMutation.isPending} className="gap-2">
|
||||
<Swords className="h-4 w-4" />
|
||||
创建比赛草稿
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[360px,1fr]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">待处理比赛</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(!matchesQuery.data || matchesQuery.data.length === 0) ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
|
||||
暂无比赛记录。
|
||||
</div>
|
||||
) : (
|
||||
(matchesQuery.data || []).map((match: any) => (
|
||||
<button
|
||||
key={match.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedMatchId(match.id)}
|
||||
className={`w-full rounded-2xl border p-4 text-left transition ${selectedMatchId === match.id ? "border-primary bg-primary/5" : "border-border/60 bg-muted/20 hover:bg-muted/40"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{match.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{getMatchParticipant(match, "player_a")?.userName || "A"} vs {getMatchParticipant(match, "player_b")?.userName || "B"}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={match.workflowStatus === "finalized" ? "secondary" : "outline"}>
|
||||
{getMatchStatusLabel(match.workflowStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
{getMatchModeLabel(match.matchMode)} · {formatMatchScore(match)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">审核与结算</CardTitle>
|
||||
<CardDescription>H1 / 管理员可以在这里补机位、拉起自动计分、修正比分并提交正式结算。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selectedMatchId ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
先从左侧选择一场比赛。
|
||||
</div>
|
||||
) : matchDetailQuery.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : !matchDetailQuery.data ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
未找到比赛详情。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-lg font-semibold">{matchDetailQuery.data.title}</span>
|
||||
<Badge variant="secondary">{getMatchModeLabel(matchDetailQuery.data.matchMode)}</Badge>
|
||||
<Badge variant="outline">{getMatchStatusLabel(matchDetailQuery.data.workflowStatus)}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
胜者 {getWinnerName(matchDetailQuery.data)} · 当前比分 {formatMatchScore(matchDetailQuery.data)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requestSuggestionMutation.mutate({ matchId: matchDetailQuery.data.id })}
|
||||
disabled={requestSuggestionMutation.isPending || matchDetailQuery.data.workflowStatus === "cancelled"}
|
||||
>
|
||||
刷新自动计分
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => finalizeMatchMutation.mutate({ matchId: matchDetailQuery.data.id })}
|
||||
disabled={finalizeMatchMutation.isPending || matchDetailQuery.data.workflowStatus === "finalized" || matchDetailQuery.data.workflowStatus === "cancelled"}
|
||||
>
|
||||
提交正式结算
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["player_a", "player_b"] as const).map((slot) => {
|
||||
const participant = getMatchParticipant(matchDetailQuery.data, slot);
|
||||
if (!participant) return null;
|
||||
return (
|
||||
<Card key={slot} className="border border-border/60 shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{participant.userName || slot}</CardTitle>
|
||||
<CardDescription>{slot === "player_a" ? "A 机位" : "B 机位"} · 当前 {participant.cameraStatus}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
placeholder="机位标签"
|
||||
value={cameraLabelDrafts[slot] || ""}
|
||||
onChange={(event) => setCameraLabelDrafts((current) => ({ ...current, [slot]: event.target.value }))}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bindCameraMutation.mutate({
|
||||
matchId: matchDetailQuery.data.id,
|
||||
playerSlot: slot,
|
||||
cameraStatus: "bound",
|
||||
cameraLabel: cameraLabelDrafts[slot] || undefined,
|
||||
})}
|
||||
>
|
||||
标记已绑定
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bindCameraMutation.mutate({
|
||||
matchId: matchDetailQuery.data.id,
|
||||
playerSlot: slot,
|
||||
cameraStatus: "completed",
|
||||
cameraLabel: cameraLabelDrafts[slot] || undefined,
|
||||
})}
|
||||
>
|
||||
标记已完成
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
|
||||
建议局分 {participant.suggestedGamesWon || 0} · 正式局分 {participant.finalGamesWon || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card className="border border-border/60 shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">审核比分</CardTitle>
|
||||
<CardDescription>管理员确认后的比分会作为正式结算依据,未填写的指标继续沿用自动建议。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Input value={reviewDraft.playerASet} onChange={(event) => setReviewDraft((current) => ({ ...current, playerASet: event.target.value }))} placeholder="A 盘数" />
|
||||
<Input value={reviewDraft.playerAGame} onChange={(event) => setReviewDraft((current) => ({ ...current, playerAGame: event.target.value }))} placeholder="A 局数" />
|
||||
<Input value={reviewDraft.playerAPoint} onChange={(event) => setReviewDraft((current) => ({ ...current, playerAPoint: event.target.value }))} placeholder="A 得分点" />
|
||||
<Input value={reviewDraft.playerBSet} onChange={(event) => setReviewDraft((current) => ({ ...current, playerBSet: event.target.value }))} placeholder="B 盘数" />
|
||||
<Input value={reviewDraft.playerBGame} onChange={(event) => setReviewDraft((current) => ({ ...current, playerBGame: event.target.value }))} placeholder="B 局数" />
|
||||
<Input value={reviewDraft.playerBPoint} onChange={(event) => setReviewDraft((current) => ({ ...current, playerBPoint: event.target.value }))} placeholder="B 得分点" />
|
||||
</div>
|
||||
<Textarea
|
||||
value={reviewDraft.reviewNotes}
|
||||
onChange={(event) => setReviewDraft((current) => ({ ...current, reviewNotes: event.target.value }))}
|
||||
placeholder="审核说明"
|
||||
className="min-h-[120px] rounded-2xl"
|
||||
/>
|
||||
<Button onClick={submitReview} disabled={reviewSubmitMutation.isPending}>
|
||||
保存审核比分
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/60 shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Camera className="h-4 w-4 text-primary" />
|
||||
自动计分事件
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(!matchDetailQuery.data.events || matchDetailQuery.data.events.length === 0) ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
|
||||
当前没有自动计分事件。
|
||||
</div>
|
||||
) : (
|
||||
matchDetailQuery.data.events.map((event: any) => (
|
||||
<div key={event.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 className="flex items-center gap-2">
|
||||
<Badge variant="outline">#{event.eventIndex}</Badge>
|
||||
<span className="font-medium">{event.eventType}</span>
|
||||
<Badge variant="secondary">{event.source}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
置信度 {Number(event.confidence || 0).toFixed(2)} · {formatDateTimeShanghai(event.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="mt-3 overflow-x-auto rounded-xl bg-background/80 p-3 text-xs text-muted-foreground">
|
||||
{JSON.stringify(event.payload ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -5,18 +6,28 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Trophy, Clock, Zap, Target, Crown, Medal, Award } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Trophy, Clock, Zap, Target, Crown, Medal, Award, Swords, Percent } from "lucide-react";
|
||||
|
||||
type SortKey = "ntrpRating" | "totalMinutes" | "totalSessions" | "totalShots";
|
||||
type LeaderboardScope = "training" | "competitive";
|
||||
type TrainingSortKey = "ntrpRating" | "totalMinutes" | "totalSessions" | "totalShots";
|
||||
type CompetitiveSortKey = "wins" | "winRate" | "setsWon" | "pointsWon" | "matches";
|
||||
type SortKey = TrainingSortKey | CompetitiveSortKey;
|
||||
|
||||
const tabConfig: { key: SortKey; label: string; icon: React.ReactNode; unit: string }[] = [
|
||||
const trainingTabs: Array<{ key: TrainingSortKey; label: string; icon: React.ReactNode; unit: string }> = [
|
||||
{ key: "ntrpRating", label: "NTRP评分", icon: <Trophy className="h-4 w-4" />, unit: "" },
|
||||
{ key: "totalMinutes", label: "训练时长", icon: <Clock className="h-4 w-4" />, unit: "分钟" },
|
||||
{ key: "totalSessions", label: "训练次数", icon: <Target className="h-4 w-4" />, unit: "次" },
|
||||
{ key: "totalShots", label: "总击球数", icon: <Zap className="h-4 w-4" />, unit: "次" },
|
||||
];
|
||||
|
||||
const competitiveTabs: Array<{ key: CompetitiveSortKey; label: string; icon: React.ReactNode; unit: string }> = [
|
||||
{ key: "wins", label: "胜场", icon: <Trophy className="h-4 w-4" />, unit: "场" },
|
||||
{ key: "winRate", label: "胜率", icon: <Percent className="h-4 w-4" />, unit: "%" },
|
||||
{ key: "setsWon", label: "赢盘", icon: <Swords className="h-4 w-4" />, unit: "盘" },
|
||||
{ key: "pointsWon", label: "赢分", icon: <Zap className="h-4 w-4" />, unit: "分" },
|
||||
{ key: "matches", label: "场次", icon: <Target className="h-4 w-4" />, unit: "场" },
|
||||
];
|
||||
|
||||
const rankIcons = [
|
||||
<Crown className="h-5 w-5 text-yellow-500" />,
|
||||
<Medal className="h-5 w-5 text-gray-400" />,
|
||||
@@ -31,76 +42,107 @@ const skillLevelMap: Record<string, string> = {
|
||||
|
||||
export default function Leaderboard() {
|
||||
const { user } = useAuth();
|
||||
const [sortBy, setSortBy] = useState<SortKey>("ntrpRating");
|
||||
const { data: leaderboard, isLoading } = trpc.leaderboard.get.useQuery({ sortBy, limit: 50 });
|
||||
const [scope, setScope] = useState<LeaderboardScope>("training");
|
||||
const [trainingSortBy, setTrainingSortBy] = useState<TrainingSortKey>("ntrpRating");
|
||||
const [competitiveSortBy, setCompetitiveSortBy] = useState<CompetitiveSortKey>("wins");
|
||||
|
||||
const sortBy = scope === "training" ? trainingSortBy : competitiveSortBy;
|
||||
const tabConfig = scope === "training" ? trainingTabs : competitiveTabs;
|
||||
const { data: leaderboard, isLoading } = trpc.leaderboard.get.useQuery({ scope, sortBy, limit: 50 });
|
||||
|
||||
const myRank = useMemo(() => {
|
||||
if (!leaderboard || !user) return null;
|
||||
const idx = leaderboard.findIndex((u: any) => u.id === user.id);
|
||||
const idx = leaderboard.findIndex((item: any) => item.id === user.id);
|
||||
return idx >= 0 ? idx + 1 : null;
|
||||
}, [leaderboard, user]);
|
||||
|
||||
const myEntry = useMemo(() => {
|
||||
if (!leaderboard || !user) return null;
|
||||
return leaderboard.find((item: any) => item.id === user.id) || null;
|
||||
}, [leaderboard, user]);
|
||||
|
||||
const getValue = (item: any, key: SortKey) => {
|
||||
const v = item[key] ?? 0;
|
||||
return key === "ntrpRating" ? (v as number).toFixed(1) : v;
|
||||
const value = item?.[key] ?? 0;
|
||||
if (key === "ntrpRating") return Number(value).toFixed(1);
|
||||
if (key === "winRate") return `${Number(value).toFixed(1)}`;
|
||||
return value;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{[1, 2, 3, 4, 5].map(i => <Skeleton key={i} className="h-16 w-full" />)}
|
||||
<Skeleton className="h-24 w-full" />
|
||||
{[1, 2, 3, 4, 5].map((item) => <Skeleton key={item} className="h-16 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">
|
||||
与其他球友比较训练成果
|
||||
{myRank && <span className="ml-2 text-primary font-medium">· 您当前排名第 {myRank} 名</span>}
|
||||
</p>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.14),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-6 shadow-sm">
|
||||
<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 text-sm leading-6 text-muted-foreground">
|
||||
训练榜按训练积累排序,竞赛榜只统计已正式结算的竞赛比赛,不把未审核的自动计分直接纳入名次。
|
||||
{myRank ? <span className="ml-2 font-medium text-primary">当前排名第 {myRank} 名</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
<Tabs value={scope} onValueChange={(value) => setScope(value as LeaderboardScope)}>
|
||||
<TabsList className="grid w-full grid-cols-2 lg:w-[280px]">
|
||||
<TabsTrigger value="training">训练榜</TabsTrigger>
|
||||
<TabsTrigger value="competitive">竞赛榜</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* My rank card */}
|
||||
{myRank && user && (
|
||||
{myRank && user && myEntry ? (
|
||||
<Card className="border-primary/20 bg-primary/5 shadow-sm">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-lg">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-lg font-bold text-primary">
|
||||
#{myRank}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">我的排名</p>
|
||||
<p className="text-xs text-muted-foreground">我的当前排名</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-bold text-primary">{getValue(leaderboard?.find((u: any) => u.id === user.id) || {}, sortBy)}</p>
|
||||
<p className="text-xs text-muted-foreground">{tabConfig.find(t => t.key === sortBy)?.unit}</p>
|
||||
<p className="text-xl font-bold text-primary">{getValue(myEntry, sortBy)}</p>
|
||||
<p className="text-xs text-muted-foreground">{tabConfig.find((item) => item.key === sortBy)?.unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<Tabs value={sortBy} onValueChange={(v) => setSortBy(v as SortKey)}>
|
||||
<TabsList className="grid grid-cols-2 lg:grid-cols-4 w-full">
|
||||
{tabConfig.map(tab => (
|
||||
<Tabs value={sortBy} onValueChange={(value) => {
|
||||
if (scope === "training") {
|
||||
setTrainingSortBy(value as TrainingSortKey);
|
||||
} else {
|
||||
setCompetitiveSortBy(value as CompetitiveSortKey);
|
||||
}
|
||||
}}>
|
||||
<TabsList className={`grid w-full ${scope === "training" ? "grid-cols-2 lg:grid-cols-4" : "grid-cols-2 lg:grid-cols-5"}`}>
|
||||
{tabConfig.map((tab) => (
|
||||
<TabsTrigger key={tab.key} value={tab.key} className="gap-1.5 text-xs sm:text-sm">
|
||||
{tab.icon}{tab.label}
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabConfig.map(tab => (
|
||||
{tabConfig.map((tab) => (
|
||||
<TabsContent key={tab.key} value={tab.key}>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{tab.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{(!leaderboard || leaderboard.length === 0) ? (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Trophy className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<Trophy className="mx-auto mb-3 h-10 w-10 opacity-30" />
|
||||
<p>暂无排行数据</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -109,47 +151,48 @@ export default function Leaderboard() {
|
||||
const isMe = user && item.id === user.id;
|
||||
return (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 transition-colors ${isMe ? "bg-primary/5" : "hover:bg-muted/50"}`}>
|
||||
{/* Rank */}
|
||||
<div className="w-8 text-center shrink-0">
|
||||
{idx < 3 ? rankIcons[idx] : (
|
||||
<span className="text-sm font-medium text-muted-foreground">{idx + 1}</span>
|
||||
)}
|
||||
<div className="w-8 shrink-0 text-center">
|
||||
{idx < 3 ? rankIcons[idx] : <span className="text-sm font-medium text-muted-foreground">{idx + 1}</span>}
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
<AvatarFallback className={`text-xs font-medium ${idx < 3 ? "bg-primary/10 text-primary" : ""}`}>
|
||||
{(item.name || "U").slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium truncate ${isMe ? "text-primary" : ""}`}>
|
||||
<p className={`truncate text-sm font-medium ${isMe ? "text-primary" : ""}`}>
|
||||
{item.name || "匿名用户"}
|
||||
</p>
|
||||
{isMe && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">我</Badge>}
|
||||
{isMe ? <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">我</Badge> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>NTRP {(item.ntrpRating || 1.5).toFixed(1)}</span>
|
||||
<span>·</span>
|
||||
<span>{skillLevelMap[item.skillLevel || "beginner"] || "初级"}</span>
|
||||
{(item.currentStreak || 0) > 0 && (
|
||||
{scope === "training" ? (
|
||||
(item.currentStreak || 0) > 0 ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-orange-500">🔥{item.currentStreak}天</span>
|
||||
</>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-orange-500">🔥{item.currentStreak}天</span>
|
||||
<span>{item.wins || 0} 胜 / {item.losses || 0} 负</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div className="text-right shrink-0">
|
||||
<div className="shrink-0 text-right">
|
||||
<p className={`text-lg font-bold ${idx < 3 ? "text-primary" : ""}`}>
|
||||
{getValue(item, tab.key)}
|
||||
</p>
|
||||
{tab.unit && <p className="text-[10px] text-muted-foreground">{tab.unit}</p>}
|
||||
{tab.unit ? <p className="text-[10px] text-muted-foreground">{tab.unit}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
788
client/src/pages/Market.tsx
普通文件
788
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<RuleDraft>(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 (
|
||||
<div className="space-y-6">
|
||||
<section className="relative overflow-hidden rounded-[30px] border border-border/60 bg-[radial-gradient(circle_at_15%_20%,rgba(34,197,94,0.16),transparent_26%),radial-gradient(circle_at_85%_18%,rgba(251,146,60,0.16),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.94))] p-5 shadow-sm md:p-6">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(15,23,42,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(15,23,42,0.03)_1px,transparent_1px)] bg-[size:18px_18px]" />
|
||||
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
<Radar className="h-3.5 w-3.5" />
|
||||
球拍行情雷达
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-foreground">全网抓取、规则分级、低价监控与飞书推送</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
这块面板会异步抓取闲鱼、京东和转转来源,对球拍做品牌、型号、重量、品类和价格分级。命中你的目标价后,系统会先写入站内记录,再走默认飞书 webhook 推送。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => refreshMutation.mutate(undefined)}
|
||||
disabled={refreshMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 ${refreshMutation.isPending ? "animate-spin" : ""}`} />
|
||||
立即刷新
|
||||
</Button>
|
||||
<Dialog open={webhookDialogOpen} onOpenChange={(open) => {
|
||||
setWebhookDialogOpen(open);
|
||||
if (open) setWebhookDraft("");
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="gap-2" disabled={!pushConfigQuery.data?.canEdit}>
|
||||
<BellRing className="h-4 w-4" />
|
||||
默认推送
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>更新默认飞书 Webhook</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
当前配置: {pushConfigQuery.data?.maskedWebhookUrl || "未配置"}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="market-webhook">Webhook URL</Label>
|
||||
<Input
|
||||
id="market-webhook"
|
||||
placeholder="https://open.larksuite.com/open-apis/bot/v2/hook/..."
|
||||
value={webhookDraft}
|
||||
onChange={(event) => setWebhookDraft(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWebhookDialogOpen(false)}>取消</Button>
|
||||
<Button
|
||||
onClick={() => updateWebhookMutation.mutate({ webhookUrl: webhookDraft.trim() })}
|
||||
disabled={updateWebhookMutation.isPending || !webhookDraft.trim()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-6 grid gap-3 md:grid-cols-4">
|
||||
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Gauge className="h-5 w-5 text-emerald-700" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Active Rules</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{dashboardQuery.data?.overview.activeRuleCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<CircleDollarSign className="h-5 w-5 text-sky-700" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">24H Listings</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{dashboardQuery.data?.overview.recentListingCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Siren className="h-5 w-5 text-orange-700" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Recent Hits</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{dashboardQuery.data?.overview.hitCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-violet-700" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Push Channel</div>
|
||||
<div className="mt-1 text-sm font-semibold">
|
||||
{pushConfigQuery.data?.hasWebhookConfigured ? "飞书默认通道已启用" : "尚未配置"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.3fr_0.9fr]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">低价雷达</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
这里展示已经被分级为低价候选,或命中过规则阈值的最新球拍。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="搜品牌/型号"
|
||||
value={filters.keyword}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filters.source}
|
||||
onValueChange={(value) => setFilters((current) => ({ ...current, source: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
<SelectItem value="xianyu">闲鱼</SelectItem>
|
||||
<SelectItem value="jd">京东</SelectItem>
|
||||
<SelectItem value="zhuanzhuan">转转</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.category}
|
||||
onValueChange={(value) => setFilters((current) => ({ ...current, category: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="品类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部品类</SelectItem>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center justify-between rounded-xl border border-border/60 bg-muted/20 px-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Filter className="h-4 w-4" />
|
||||
仅低价
|
||||
</div>
|
||||
<Switch
|
||||
checked={filters.lowPriceOnly}
|
||||
onCheckedChange={(checked) => setFilters((current) => ({ ...current, lowPriceOnly: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{listingsQuery.isLoading ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
正在拉取行情数据...
|
||||
</div>
|
||||
) : listings.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
当前筛选下还没有球拍数据。先添加监控规则,或点击“立即刷新”触发后台抓取。
|
||||
</div>
|
||||
) : (
|
||||
listings.map((listing) => (
|
||||
<article key={`${listing.source}-${listing.sourceListingId}-${listing.id}`} className="rounded-[24px] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.92))] p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{SOURCE_LABELS[listing.source as keyof typeof SOURCE_LABELS]}</Badge>
|
||||
<Badge className={GRADE_TONES[(listing.gradeLevel as keyof typeof GRADE_TONES) ?? "pending_review"]}>
|
||||
{GRADE_LABELS[(listing.gradeLevel as keyof typeof GRADE_LABELS) ?? "pending_review"]}
|
||||
</Badge>
|
||||
{listing.isLowPriceCandidate === 1 ? (
|
||||
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700">低价候选</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold leading-6 text-foreground">{listing.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{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` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{listing.gradeReason || "暂无分级说明"}</div>
|
||||
</div>
|
||||
<div className="flex min-w-[220px] flex-col items-start gap-3 rounded-2xl border border-border/60 bg-muted/10 p-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Price Watch</div>
|
||||
<div className="mt-1 text-3xl font-semibold text-foreground">{formatCurrency(listing.price)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
原价 {formatCurrency(listing.originalPrice)} · 抓取于 {formatDateTimeShanghai(listing.fetchedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild className="gap-2">
|
||||
<a href={listing.listingUrl} target="_blank" rel="noreferrer">
|
||||
打开原帖
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg">监控规则</CardTitle>
|
||||
<CardDescription className="mt-1">按品牌、型号、重量和目标价管理你的关注清单。</CardDescription>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setRuleDraft(EMPTY_RULE);
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建规则
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ruleDraft.ruleId ? "编辑监控规则" : "新建监控规则"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-1 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="rule-title">规则标题</Label>
|
||||
<Input
|
||||
id="rule-title"
|
||||
placeholder="留空会自动生成,例如 Yonex Ezone ≤ ¥500"
|
||||
value={ruleDraft.title}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-brand">品牌</Label>
|
||||
<Input
|
||||
id="rule-brand"
|
||||
placeholder="如 Yonex / Wilson"
|
||||
value={ruleDraft.brand}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, brand: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-target-price">目标价格</Label>
|
||||
<Input
|
||||
id="rule-target-price"
|
||||
type="number"
|
||||
placeholder="500"
|
||||
value={ruleDraft.targetPrice}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, targetPrice: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-series">系列关键词</Label>
|
||||
<Input
|
||||
id="rule-series"
|
||||
placeholder="如 Ezone / Blade"
|
||||
value={ruleDraft.seriesKeyword}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, seriesKeyword: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-model">型号关键词</Label>
|
||||
<Input
|
||||
id="rule-model"
|
||||
placeholder="如 98 / 100L"
|
||||
value={ruleDraft.modelKeyword}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, modelKeyword: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>品类</Label>
|
||||
<Select
|
||||
value={ruleDraft.category}
|
||||
onValueChange={(value) => setRuleDraft((current) => ({ ...current, category: value as RuleDraft["category"] }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不限" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">不限</SelectItem>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-weight-min">最小重量</Label>
|
||||
<Input
|
||||
id="rule-weight-min"
|
||||
type="number"
|
||||
placeholder="285"
|
||||
value={ruleDraft.weightMinGram}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, weightMinGram: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-weight-max">最大重量</Label>
|
||||
<Input
|
||||
id="rule-weight-max"
|
||||
type="number"
|
||||
placeholder="305"
|
||||
value={ruleDraft.weightMaxGram}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, weightMaxGram: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-border/60 bg-muted/20 px-4 py-3 sm:col-span-2">
|
||||
<div>
|
||||
<div className="font-medium">命中后推送飞书</div>
|
||||
<div className="text-sm text-muted-foreground">关闭后仍保留站内命中记录,但不发默认 webhook。</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={ruleDraft.pushEnabled}
|
||||
onCheckedChange={(checked) => setRuleDraft((current) => ({ ...current, pushEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button
|
||||
onClick={handleSubmitRule}
|
||||
disabled={createRuleMutation.isPending || updateRuleMutation.isPending}
|
||||
>
|
||||
保存规则
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{rules.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
还没有监控规则。先添加你关心的品牌、型号和目标价,系统之后会自动后台更新。
|
||||
</div>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<div key={rule.id} className="rounded-[22px] border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium text-foreground">{rule.title}</div>
|
||||
<Badge variant="outline">{rule.brand}</Badge>
|
||||
<Badge variant={rule.isActive === 1 ? "secondary" : "outline"}>
|
||||
{rule.isActive === 1 ? "监控中" : "已暂停"}
|
||||
</Badge>
|
||||
{rule.pushEnabled === 1 ? (
|
||||
<Badge variant="secondary" className="bg-sky-50 text-sky-700">飞书推送</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
目标价 {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` : ""}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div>
|
||||
上次检查 {rule.lastCheckedAt ? formatDateTimeShanghai(rule.lastCheckedAt) : "尚未检查"}
|
||||
{rule.lastMatchedAt ? ` · 最近命中 ${formatDateShanghai(rule.lastMatchedAt)}` : ""}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => refreshMutation.mutate({ ruleId: rule.id })}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleEditRule(rule)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleRuleMutation.mutate({ ruleId: rule.id, isActive: rule.isActive !== 1 })}
|
||||
>
|
||||
{rule.isActive === 1 ? "暂停" : "启用"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
onClick={() => deleteRuleMutation.mutate({ ruleId: rule.id })}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">来源状态</CardTitle>
|
||||
<CardDescription className="mt-1">按最近抓取结果观察每个来源的健康度和低价产出。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sourceSummary.map((item) => (
|
||||
<div key={item.source} 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">{SOURCE_LABELS[item.source as keyof typeof SOURCE_LABELS]}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
最近抓取 {item.latestFetchedAt ? formatDateTimeShanghai(item.latestFetchedAt) : "暂无数据"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-semibold">{item.total}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.lowPriceCount} 条低价候选</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">命中记录</CardTitle>
|
||||
<CardDescription className="mt-1">命中过规则的商品会在这里保留历史,用来做去重和价格二次下降提醒。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{hits.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
还没有命中记录。等后台抓取到低于目标价的球拍后,这里会显示推送状态和命中详情。
|
||||
</div>
|
||||
) : (
|
||||
hits.map((hit) => (
|
||||
<div key={hit.id} className="rounded-[22px] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.9))] p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{SOURCE_LABELS[hit.listingSource as keyof typeof SOURCE_LABELS]}</Badge>
|
||||
<Badge className={GRADE_TONES[(hit.listingGradeLevel as keyof typeof GRADE_TONES) ?? "pending_review"]}>
|
||||
{GRADE_LABELS[(hit.listingGradeLevel as keyof typeof GRADE_LABELS) ?? "pending_review"]}
|
||||
</Badge>
|
||||
<Badge variant={hit.status === "pushed" ? "secondary" : hit.status === "push_queued" ? "outline" : "secondary"}>
|
||||
{hit.status === "matched" ? "已命中" : hit.status === "push_queued" ? "待推送" : hit.status === "pushed" ? "已推送" : "已抑制"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">{hit.ruleTitle}</div>
|
||||
<div className="text-sm text-muted-foreground">{hit.listingTitle}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
命中价 {formatCurrency(hit.matchedPrice)} · 当前价 {formatCurrency(hit.listingPrice)} · 推送 {hit.pushCount} 次
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
首次命中 {formatDateTimeShanghai(hit.firstMatchedAt)} · 最近更新 {formatDateTimeShanghai(hit.lastMatchedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild size="sm" className="gap-2">
|
||||
<a href={hit.listingUrl} target="_blank" rel="noreferrer">
|
||||
打开商品
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
最近后台动态
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">抓取、规则刷新和飞书推送都会在这里留下最近状态。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recentTasks.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
暂无市场相关后台任务。
|
||||
</div>
|
||||
) : (
|
||||
recentTasks.map((task) => (
|
||||
<div key={task.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium">{task.title}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{task.message || "无额外说明"}</div>
|
||||
</div>
|
||||
<Badge variant={task.status === "failed" ? "destructive" : task.status === "succeeded" ? "secondary" : "outline"}>
|
||||
{TASK_STATUS_LABELS[task.status as keyof typeof TASK_STATUS_LABELS] ?? task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{formatDateTimeShanghai(task.createdAt)}</span>
|
||||
<span>{task.progress}%</span>
|
||||
</div>
|
||||
{task.error ? (
|
||||
<div className="mt-3 rounded-xl bg-red-50 px-3 py-2 text-xs text-red-700">{task.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
327
client/src/pages/Matches.tsx
普通文件
327
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 (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
{props.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{props.label}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{props.value}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{props.hint}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Matches() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [selectedMatchId, setSelectedMatchId] = useState<number | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[1, 2, 3].map((item) => <Skeleton key={item} className="h-28 w-full" />)}
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(245,158,11,0.12),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-6 shadow-sm">
|
||||
<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-3xl text-sm leading-6 text-muted-foreground">
|
||||
这里展示双人双摄比赛的入库记录、自动计分建议、审核状态和正式结算结果。管理员负责最终确认,普通用户只能看到与自己绑定的比赛。
|
||||
</p>
|
||||
</div>
|
||||
{selectedMatch ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">{getMatchModeLabel(selectedMatch.matchMode)}</Badge>
|
||||
<Badge variant="outline">{getMatchStatusLabel(selectedMatch.workflowStatus)}</Badge>
|
||||
{myResult ? <Badge variant="outline">{myResult}</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StatCard
|
||||
icon={<Swords className="h-5 w-5" />}
|
||||
label="我的比赛"
|
||||
value={statsQuery.data?.total || 0}
|
||||
hint={`日常 ${statsQuery.data?.daily || 0} 场 · 竞赛 ${statsQuery.data?.competitive || 0} 场`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ClipboardCheck className="h-5 w-5" />}
|
||||
label="已入库"
|
||||
value={statsQuery.data?.finalized || 0}
|
||||
hint={`待审核/待结算 ${statsQuery.data?.reviewPending || 0} 场`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Trophy className="h-5 w-5" />}
|
||||
label="竞赛胜场"
|
||||
value={statsQuery.data?.competitiveWins || 0}
|
||||
hint={`已绑定机位 ${statsQuery.data?.camerasBound || 0} 个`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as StatusFilter)} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-4 lg:w-auto">
|
||||
{statusOptions.map((option) => (
|
||||
<TabsTrigger key={option.key} value={option.key}>{option.label}</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">比赛列表</CardTitle>
|
||||
<CardDescription>按入库流程查看自己可访问的比赛。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(!listQuery.data || listQuery.data.length === 0) ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
|
||||
当前筛选下暂无比赛记录。
|
||||
</div>
|
||||
) : (
|
||||
listQuery.data.map((match: any) => {
|
||||
const playerA = getMatchParticipant(match, "player_a");
|
||||
const playerB = getMatchParticipant(match, "player_b");
|
||||
return (
|
||||
<button
|
||||
key={match.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedMatchId(match.id)}
|
||||
className={`w-full rounded-2xl border p-4 text-left transition ${selectedMatchId === match.id ? "border-primary bg-primary/5" : "border-border/60 bg-muted/20 hover:bg-muted/40"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{match.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{playerA?.userName || "A"} vs {playerB?.userName || "B"}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={match.workflowStatus === "finalized" ? "secondary" : "outline"}>
|
||||
{getMatchStatusLabel(match.workflowStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{getMatchModeLabel(match.matchMode)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateShanghai(match.scheduledAt || match.createdAt)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatMatchScore(match)}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">比赛详情</CardTitle>
|
||||
<CardDescription>包含双人双摄绑定状态、自动计分建议、事件时间线和正式结算结果。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{!selectedMatchId ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
先从左侧选择一场比赛。
|
||||
</div>
|
||||
) : detailQuery.isLoading ? (
|
||||
<Skeleton className="h-80 w-full" />
|
||||
) : !selectedMatch ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
未找到该比赛详情。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{selectedMatch.title}</h2>
|
||||
<Badge variant="secondary">{getMatchModeLabel(selectedMatch.matchMode)}</Badge>
|
||||
<Badge variant="outline">{getMatchStatusLabel(selectedMatch.workflowStatus)}</Badge>
|
||||
{myResult ? <Badge variant="outline">{myResult}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
场地 {selectedMatch.courtName || "未填写"} · 计划 {selectedMatch.durationMinutes || 90} 分钟 · {formatDateTimeShanghai(selectedMatch.scheduledAt || selectedMatch.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requestSuggestionMutation.mutate({ matchId: selectedMatch.id })}
|
||||
disabled={requestSuggestionMutation.isPending || selectedMatch.workflowStatus === "finalized" || selectedMatch.workflowStatus === "cancelled"}
|
||||
>
|
||||
刷新自动计分建议
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
当前显示比分:{formatMatchScore(selectedMatch)} · 胜者 {getWinnerName(selectedMatch)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["player_a", "player_b"] as const).map((slot) => {
|
||||
const participant = getMatchParticipant(selectedMatch, slot);
|
||||
const stats = participant?.finalStats || participant?.suggestedStats || {};
|
||||
return (
|
||||
<Card key={slot} className="border border-border/60 shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{participant?.userName || slot}</CardTitle>
|
||||
<CardDescription>
|
||||
{slot === "player_a" ? "主位 A" : "主位 B"} · 机位 {participant?.cameraSlot || "-"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between rounded-2xl bg-muted/30 px-3 py-2">
|
||||
<span className="text-muted-foreground">机位状态</span>
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<Camera className="h-4 w-4 text-primary" />
|
||||
{participant?.cameraStatus || "pending"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl bg-muted/30 p-3">
|
||||
<div className="text-xs text-muted-foreground">局分</div>
|
||||
<div className="mt-1 text-lg font-semibold">{participant?.finalGamesWon ?? participant?.suggestedGamesWon ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/30 p-3">
|
||||
<div className="text-xs text-muted-foreground">盘分</div>
|
||||
<div className="mt-1 text-lg font-semibold">{participant?.finalSetsWon ?? participant?.suggestedSetsWon ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/30 p-3">
|
||||
<div className="text-xs text-muted-foreground">得分点</div>
|
||||
<div className="mt-1 text-lg font-semibold">{participant?.finalPointsWon ?? participant?.suggestedPointsWon ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/30 p-3">
|
||||
<div className="text-xs text-muted-foreground">一发进球率</div>
|
||||
<div className="mt-1 text-lg font-semibold">{Number((stats as any).firstServePct || 0).toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<div className="rounded-xl bg-muted/20 p-2">ACE {(stats as any).aces || 0}</div>
|
||||
<div className="rounded-xl bg-muted/20 p-2">制胜分 {(stats as any).winners || 0}</div>
|
||||
<div className="rounded-xl bg-muted/20 p-2">失误 {(stats as any).unforcedErrors || 0}</div>
|
||||
</div>
|
||||
{participant?.isWinner === 1 ? (
|
||||
<div className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
已确认胜者
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card className="border border-border/60 shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">事件时间线</CardTitle>
|
||||
<CardDescription>
|
||||
自动计分与人工审核事件共 {selectedMatch.events?.length || 0} 条。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(!selectedMatch.events || selectedMatch.events.length === 0) ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
|
||||
暂无自动计分事件。
|
||||
</div>
|
||||
) : (
|
||||
selectedMatch.events.map((event: any) => (
|
||||
<div key={event.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 className="flex items-center gap-2">
|
||||
<Badge variant="outline">#{event.eventIndex}</Badge>
|
||||
<span className="font-medium">{event.eventType}</span>
|
||||
<Badge variant="secondary">{event.source}</Badge>
|
||||
{event.winnerSlot ? <Badge variant="outline">胜方 {event.winnerSlot}</Badge> : null}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{event.matchSecond != null ? `第 ${event.matchSecond} 秒 · ` : ""}
|
||||
置信度 {Number(event.confidence || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="mt-3 overflow-x-auto rounded-xl bg-background/80 p-3 text-xs text-muted-foreground">
|
||||
{JSON.stringify(event.payload ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: "耐力偏弱,短时间内就出现明显喘和失速,需优先提升" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
|
||||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||
<DialogContent className="max-h-[92vh] max-w-5xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-h-[92vh] max-w-6xl overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border/60 bg-background/95 px-6 py-5">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Scissors className="h-5 w-5 text-primary" />
|
||||
PC 轻剪辑工作台
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
支持手动设置入点/出点、按分析关键时刻生成建议片段,并把剪辑草稿导出为 JSON。
|
||||
<DialogDescription className="max-w-2xl">
|
||||
手动微调入点和出点,配合分析关键时刻整理出一份轻量剪辑草稿,再导出给后续后台任务。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedVideo ? (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.9fr)]">
|
||||
<section className="space-y-4">
|
||||
<div className="overflow-hidden rounded-3xl border border-border/60 bg-black">
|
||||
<video
|
||||
ref={previewRef}
|
||||
src={selectedVideo.url}
|
||||
className="aspect-video w-full object-contain"
|
||||
controls
|
||||
playsInline
|
||||
onLoadedMetadata={(event) => {
|
||||
const duration = event.currentTarget.duration || 0;
|
||||
setVideoDurationSec(duration);
|
||||
setClipRange([0, Math.min(duration, 5)]);
|
||||
}}
|
||||
onTimeUpdate={(event) => setPlaybackSec(event.currentTarget.currentTime || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">片段设置</CardTitle>
|
||||
<CardDescription>建议先在播放器中定位,再设置入点和出点。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">草稿片段</div>
|
||||
<div className="mt-2 text-lg font-semibold">{clipDrafts.length}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">总剪辑时长</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(totalClipDurationSec)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">建议片段</div>
|
||||
<div className="mt-2 text-lg font-semibold">{suggestedClips.length}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前区间时长</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(Math.max(0, clipRange[1] - clipRange[0]))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前播放</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(playbackSec)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">入点</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(clipRange[0])}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">出点</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(clipRange[1])}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timelineDurationSec > 0 ? (
|
||||
<Slider
|
||||
value={clipRange}
|
||||
min={0}
|
||||
max={timelineDurationSec}
|
||||
step={0.1}
|
||||
onValueChange={(value) => {
|
||||
if (value.length === 2) {
|
||||
setClipRange([value[0] || 0, value[1] || Math.max(0.5, timelineDurationSec)]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setClipRange(([_, end]) => [clamp(playbackSec, 0, Math.max(0, end - 0.5)), end])}
|
||||
>
|
||||
设为入点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setClipRange(([start]) => [start, clamp(playbackSec, start + 0.5, timelineDurationSec || playbackSec + 0.5)])}
|
||||
>
|
||||
设为出点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (previewRef.current) previewRef.current.currentTime = clipRange[0];
|
||||
}}
|
||||
>
|
||||
跳到入点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!previewRef.current) return;
|
||||
setActivePreviewRange([clipRange[0], clipRange[1]]);
|
||||
previewRef.current.currentTime = clipRange[0];
|
||||
await previewRef.current.play().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
循环预览当前区间
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setActivePreviewRange(null);
|
||||
previewRef.current?.pause();
|
||||
}}
|
||||
>
|
||||
停止循环
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
value={clipLabel}
|
||||
onChange={(event) => setClipLabel(event.target.value)}
|
||||
placeholder="片段名称,例如:正手节奏稳定段"
|
||||
className="h-11 rounded-2xl"
|
||||
/>
|
||||
<Button onClick={() => addClip("manual")} className="h-11 rounded-2xl gap-2">
|
||||
<Scissors className="h-4 w-4" />
|
||||
加入剪辑草稿
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={clipNotes}
|
||||
onChange={(event) => setClipNotes(event.target.value)}
|
||||
placeholder="记录这个片段为什么要保留,或后续想怎么讲解"
|
||||
className="min-h-24 rounded-2xl"
|
||||
<div className="overflow-y-auto px-6 py-5">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.45fr)_360px]">
|
||||
<section className="space-y-5">
|
||||
<div className="overflow-hidden rounded-[28px] border border-border/60 bg-[#05070b] shadow-[0_24px_60px_rgba(5,7,11,0.18)]">
|
||||
<video
|
||||
ref={previewRef}
|
||||
src={selectedVideo.url}
|
||||
className="aspect-video w-full object-contain"
|
||||
controls
|
||||
playsInline
|
||||
onLoadedMetadata={(event) => {
|
||||
const duration = event.currentTarget.duration || 0;
|
||||
setVideoDurationSec(duration);
|
||||
setClipRange([0, Math.min(duration, 5)]);
|
||||
}}
|
||||
onTimeUpdate={(event) => setPlaybackSec(event.currentTarget.currentTime || 0)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">建议片段</CardTitle>
|
||||
<CardDescription>来自视频分析关键时刻,可一键加入剪辑草稿。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{suggestedClips.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
当前视频暂无自动建议片段。
|
||||
<div className="grid gap-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(14,22,32,0.95),rgba(8,12,18,0.98))] px-5 py-4 text-white sm:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.24em] text-white/55">当前区间</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Badge className="rounded-full bg-white/12 px-3 py-1 text-white hover:bg-white/12">
|
||||
{formatSeconds(clipRange[0])} - {formatSeconds(clipRange[1])}
|
||||
</Badge>
|
||||
<Badge className="rounded-full bg-emerald-400/15 px-3 py-1 text-emerald-200 hover:bg-emerald-400/15">
|
||||
时长 {formatSeconds(Math.max(0, clipRange[1] - clipRange[0]))}
|
||||
</Badge>
|
||||
{activePreviewRange ? (
|
||||
<Badge className="rounded-full bg-amber-400/15 px-3 py-1 text-amber-200 hover:bg-amber-400/15">
|
||||
循环预览中
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-white/65">
|
||||
先在播放器里找到动作节奏,再用滑杆微调区间。建议片段可直接载入后继续修剪。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
suggestedClips.map((clip: ClipDraft) => (
|
||||
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="font-medium">{clip.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45">当前播放</div>
|
||||
<div className="mt-2 text-lg font-semibold tabular-nums">{formatSeconds(playbackSec)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45">入点</div>
|
||||
<div className="mt-2 text-lg font-semibold tabular-nums">{formatSeconds(clipRange[0])}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45">出点</div>
|
||||
<div className="mt-2 text-lg font-semibold tabular-nums">{formatSeconds(clipRange[1])}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45">草稿片段</div>
|
||||
<div className="mt-2 text-lg font-semibold tabular-nums">{clipDrafts.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-border/60 shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">区间控制</CardTitle>
|
||||
<CardDescription>把时间线控制、预览和命名放在一处,减少来回扫视。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="rounded-[24px] border border-border/60 bg-muted/[0.16] px-4 py-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">时间线微调</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
全片时长 {formatSeconds(timelineDurationSec || videoDurationSec)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">{clip.notes}</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
</div>
|
||||
{timelineDurationSec > 0 ? (
|
||||
<Slider
|
||||
value={clipRange}
|
||||
min={0}
|
||||
max={timelineDurationSec}
|
||||
step={0.1}
|
||||
onValueChange={(value) => {
|
||||
if (value.length === 2) {
|
||||
setClipRange([value[0] || 0, value[1] || Math.max(0.5, timelineDurationSec)]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-border/60 bg-muted/[0.16] p-4">
|
||||
<div className="mb-3 text-sm font-medium">定位当前时间点</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start rounded-xl"
|
||||
onClick={() => setClipRange(([_, end]) => [clamp(playbackSec, 0, Math.max(0, end - 0.5)), end])}
|
||||
>
|
||||
设为入点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start rounded-xl"
|
||||
onClick={() => setClipRange(([start]) => [start, clamp(playbackSec, start + 0.5, timelineDurationSec || playbackSec + 0.5)])}
|
||||
>
|
||||
设为出点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start rounded-xl"
|
||||
onClick={() => {
|
||||
setClipRange([clip.startSec, clip.endSec]);
|
||||
if (previewRef.current) previewRef.current.currentTime = clip.startSec;
|
||||
if (previewRef.current) previewRef.current.currentTime = clipRange[0];
|
||||
}}
|
||||
>
|
||||
载入区间
|
||||
跳到入点
|
||||
</Button>
|
||||
<div className="flex items-center rounded-xl border border-dashed border-border/60 px-3 text-xs text-muted-foreground">
|
||||
先定位,再按入点和出点微调。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-border/60 bg-muted/[0.16] p-4">
|
||||
<div className="mb-3 text-sm font-medium">区间预览</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start rounded-xl"
|
||||
onClick={async () => {
|
||||
if (!previewRef.current) return;
|
||||
setActivePreviewRange([clipRange[0], clipRange[1]]);
|
||||
previewRef.current.currentTime = clipRange[0];
|
||||
await previewRef.current.play().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
循环预览当前区间
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setActivePreviewRange([clip.startSec, clip.endSec]);
|
||||
if (previewRef.current) {
|
||||
previewRef.current.currentTime = clip.startSec;
|
||||
await previewRef.current.play().catch(() => undefined);
|
||||
}
|
||||
className="justify-start rounded-xl"
|
||||
onClick={() => {
|
||||
setActivePreviewRange(null);
|
||||
previewRef.current?.pause();
|
||||
}}
|
||||
>
|
||||
循环预览
|
||||
停止循环
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => addClip("suggested", clip)}>加入草稿</Button>
|
||||
<div className="col-span-full rounded-xl border border-dashed border-border/60 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
如果建议片段不够准,先载入建议区间,再继续用滑杆和入点/出点微调。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">剪辑草稿</CardTitle>
|
||||
<CardDescription>草稿保存在浏览器本地,可随时导出给后续后台剪辑任务使用。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{clipDrafts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
还没有片段草稿。
|
||||
</div>
|
||||
) : (
|
||||
clipDrafts.map((clip: ClipDraft) => (
|
||||
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{clip.label}</span>
|
||||
<Badge variant="outline">{clip.source === "manual" ? "手动" : "建议"}</Badge>
|
||||
<Badge variant="secondary">{formatSeconds(Math.max(0, clip.endSec - clip.startSec))}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setClipRange([clip.startSec, clip.endSec]);
|
||||
setClipLabel(clip.label);
|
||||
setClipNotes(clip.notes);
|
||||
if (previewRef.current) {
|
||||
previewRef.current.currentTime = clip.startSec;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const value = `${clip.label} ${formatSeconds(clip.startSec)}-${formatSeconds(clip.endSec)} ${clip.notes || ""}`.trim();
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("当前浏览器不支持剪贴板复制");
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(value).then(
|
||||
() => toast.success("片段信息已复制"),
|
||||
() => toast.error("片段复制失败"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setClipDrafts((current) => current.filter((item) => item.id !== clip.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{clip.notes ? <div className="mt-2 text-sm text-muted-foreground">{clip.notes}</div> : null}
|
||||
|
||||
<div className="space-y-3 rounded-[24px] border border-border/60 bg-background p-4">
|
||||
<div className="text-sm font-medium">加入草稿</div>
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<Input
|
||||
value={clipLabel}
|
||||
onChange={(event) => setClipLabel(event.target.value)}
|
||||
placeholder="片段名称,例如:正手节奏稳定段"
|
||||
className="h-11 rounded-2xl"
|
||||
/>
|
||||
<Button onClick={() => addClip("manual")} className="h-11 rounded-2xl gap-2">
|
||||
<Scissors className="h-4 w-4" />
|
||||
加入剪辑草稿
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
<Textarea
|
||||
value={clipNotes}
|
||||
onChange={(event) => setClipNotes(event.target.value)}
|
||||
placeholder="记录这个片段为什么要保留,或后续想怎么讲解"
|
||||
className="min-h-24 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<Card className="overflow-hidden border border-border/60 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(244,247,250,0.96))] shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">工作概览</CardTitle>
|
||||
<CardDescription>右侧只保留一个片段面板,减少同时展开的内容块。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="rounded-full">本地草稿</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">草稿片段</div>
|
||||
<div className="mt-2 text-2xl font-semibold tabular-nums">{clipDrafts.length}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">总剪辑时长</div>
|
||||
<div className="mt-2 text-2xl font-semibold tabular-nums">{formatSeconds(totalClipDurationSec)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">建议片段</div>
|
||||
<div className="mt-2 text-2xl font-semibold tabular-nums">{suggestedClips.length}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="suggested" className="overflow-hidden rounded-[28px] border border-border/60 bg-background shadow-sm">
|
||||
<div className="flex flex-col gap-4 border-b border-border/60 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold">片段面板</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
建议片段和剪辑草稿切换查看,避免两列长列表同时压在屏幕上。
|
||||
</p>
|
||||
</div>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2 rounded-2xl bg-muted/70 p-1">
|
||||
<TabsTrigger value="suggested" className="rounded-xl py-2">
|
||||
建议片段 {suggestedClips.length}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drafts" className="rounded-xl py-2">
|
||||
剪辑草稿 {clipDrafts.length}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="suggested" className="m-0">
|
||||
<ScrollArea className="h-[420px]">
|
||||
<div className="space-y-3 p-5">
|
||||
{suggestedClips.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
当前视频暂无自动建议片段。
|
||||
</div>
|
||||
) : (
|
||||
suggestedClips.map((clip: ClipDraft) => (
|
||||
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/[0.16] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{clip.label}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}</span>
|
||||
<Badge variant="secondary" className="rounded-full">
|
||||
{formatSeconds(Math.max(0, clip.endSec - clip.startSec))}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="rounded-full">分析</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-muted-foreground">{clip.notes}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
onClick={() => {
|
||||
setClipRange([clip.startSec, clip.endSec]);
|
||||
if (previewRef.current) previewRef.current.currentTime = clip.startSec;
|
||||
}}
|
||||
>
|
||||
载入区间
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
onClick={async () => {
|
||||
setActivePreviewRange([clip.startSec, clip.endSec]);
|
||||
if (previewRef.current) {
|
||||
previewRef.current.currentTime = clip.startSec;
|
||||
await previewRef.current.play().catch(() => undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="mr-1 h-4 w-4" />
|
||||
预览
|
||||
</Button>
|
||||
<Button size="sm" className="rounded-xl" onClick={() => addClip("suggested", clip)}>
|
||||
加入草稿
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="m-0">
|
||||
<ScrollArea className="h-[420px]">
|
||||
<div className="space-y-3 p-5">
|
||||
{clipDrafts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
还没有片段草稿。
|
||||
</div>
|
||||
) : (
|
||||
clipDrafts.map((clip: ClipDraft) => (
|
||||
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/[0.16] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{clip.label}</span>
|
||||
<Badge variant="outline" className="rounded-full">{clip.source === "manual" ? "手动" : "建议"}</Badge>
|
||||
<Badge variant="secondary" className="rounded-full">
|
||||
{formatSeconds(Math.max(0, clip.endSec - clip.startSec))}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setClipRange([clip.startSec, clip.endSec]);
|
||||
setClipLabel(clip.label);
|
||||
setClipNotes(clip.notes);
|
||||
if (previewRef.current) {
|
||||
previewRef.current.currentTime = clip.startSec;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const value = `${clip.label} ${formatSeconds(clip.startSec)}-${formatSeconds(clip.endSec)} ${clip.notes || ""}`.trim();
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("当前浏览器不支持剪贴板复制");
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(value).then(
|
||||
() => toast.success("片段信息已复制"),
|
||||
() => toast.error("片段复制失败"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setClipDrafts((current) => current.filter((item) => item.id !== clip.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{clip.notes ? <div className="mt-2 text-sm leading-6 text-muted-foreground">{clip.notes}</div> : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<DialogFooter className="border-t border-border/60 bg-background/95 px-6 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户