Add market watch and match hub workflows

这个提交包含在:
cryptocommuniums-afk
2026-04-07 11:00:03 +08:00
父节点 495da60212
当前提交 32ffad1545
修改 39 个文件,包含 6974 行新增330 行删除

查看文件

@@ -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 普通文件
查看文件

@@ -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 普通文件
查看文件

@@ -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 普通文件
查看文件

@@ -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={() => {