feat: 增加管理端多语言与风控/报表/奖池操作能力

This commit is contained in:
2026-05-18 15:08:34 +08:00
parent afffa4e508
commit 49a4caf01e
31 changed files with 918 additions and 115 deletions

View File

@@ -81,6 +81,8 @@ export function OddsConfigDocScreen() {
const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
const [activeCompareRows, setActiveCompareRows] = useState<OddsItemRow[]>([]);
const refreshTypes = useCallback(async () => {
setLoadingTypes(true);
@@ -281,6 +283,24 @@ export function OddsConfigDocScreen() {
}
}
async function requestPublishConfirm() {
if (!detail || !isDraft) {
return;
}
const active = list.find((x) => x.status === "active");
if (active && active.id !== detail.id) {
try {
const d = await getOddsVersion(active.id);
setActiveCompareRows(d.items);
} catch {
setActiveCompareRows([]);
}
} else {
setActiveCompareRows([]);
}
setPublishConfirmOpen(true);
}
async function handleNewDraft() {
setSaving(true);
try {
@@ -343,6 +363,25 @@ export function OddsConfigDocScreen() {
setRollbackOpen(true);
}
const publishDiffRows = useMemo(() => {
if (!detail) {
return [];
}
const selectedPlay = resolvedPlayCode;
return PRIZE_SCOPE_ORDER.map((scope) => {
const next = draftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
return {
scope,
label: PRIZE_SCOPE_LABELS[scope],
oldValue: old?.odds_value ?? null,
newValue: next?.odds_value ?? null,
};
});
}, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "d4", label: "4D" },
@@ -421,7 +460,7 @@ export function OddsConfigDocScreen() {
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void handlePublish()}
onPublish={() => void requestPublishConfirm()}
/>
</div>
</div>
@@ -532,6 +571,48 @@ export function OddsConfigDocScreen() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border">
<div className="grid grid-cols-3 border-b bg-muted/40 px-3 py-2 text-sm font-medium">
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
</div>
{publishDiffRows.map((row) => (
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
<span>{row.label}</span>
<span className="text-right font-mono tabular-nums">
{row.oldValue === null ? "—" : row.oldValue}
</span>
<span className="text-right font-mono tabular-nums">{row.newValue ?? "—"}</span>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPublishConfirmOpen(false)}>
</Button>
<Button
type="button"
disabled={saving}
onClick={() => {
setPublishConfirmOpen(false);
void handlePublish();
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -63,6 +63,50 @@ type PlayConfigSaveItemPayload = {
extra_config_json: unknown;
};
type PlayBatchSwitchGroup = {
key: string;
label: string;
match: (row: PlayConfigItemRow) => boolean;
};
const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [
{
key: "d2",
label: "2D 全局",
match: (row) => row.dimension === 2,
},
{
key: "d3",
label: "3D 全局",
match: (row) => row.dimension === 3,
},
{
key: "d4",
label: "4D 全局",
match: (row) => row.dimension === 4,
},
{
key: "big-small",
label: "Big / Small",
match: (row) => row.play_code === "big" || row.play_code === "small",
},
{
key: "position",
label: "位置类玩法",
match: (row) => row.category === "position",
},
{
key: "box",
label: "包号类玩法",
match: (row) => row.category === "box",
},
{
key: "jackpot",
label: "Jackpot",
match: (row) => row.category === "jackpot" || row.play_code.includes("jackpot"),
},
];
/** 版本草稿保存 payload直接按当前草稿快照落库。 */
function buildPlayConfigSavePayload(
draftRows: PlayConfigItemRow[],
@@ -217,6 +261,27 @@ export function PlayConfigDocScreen() {
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
}
function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
setDraftRows((prev) =>
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
);
}
const batchSwitchStates = useMemo(
() =>
PLAY_BATCH_SWITCH_GROUPS.map((group) => {
const rows = draftRows.filter(group.match);
const enabledCount = rows.filter((row) => row.is_enabled).length;
return {
...group,
total: rows.length,
enabledCount,
allEnabled: rows.length > 0 && enabledCount === rows.length,
};
}),
[draftRows],
);
async function handleSaveDraft() {
if (!detail || !isDraft) {
return;
@@ -360,6 +425,48 @@ export function PlayConfigDocScreen() {
</p>
) : null}
{detail ? (
<div className="rounded-xl border bg-muted/20 p-3">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">
稿
</p>
</div>
{!isDraft ? (
<span className="text-xs text-amber-600 dark:text-amber-400">
稿
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{batchSwitchStates.map((group) => (
<div
key={group.key}
className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2"
>
<div className="min-w-[92px]">
<p className="text-sm font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">
{group.total > 0 ? `${group.enabledCount}/${group.total} 启用` : "暂无玩法"}
</p>
</div>
<Button
type="button"
size="sm"
variant={group.allEnabled ? "secondary" : "outline"}
disabled={!isDraft || saving || group.total === 0}
onClick={() => applyBatchSwitch(group, !group.allEnabled)}
>
{group.allEnabled ? "关闭" : "开启"}
</Button>
</div>
))}
</div>
</div>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail ? (

View File

@@ -55,6 +55,20 @@ function newRow(): DraftRiskRow {
};
}
function isDefaultRiskRow(row: DraftRiskRow): boolean {
return row.cap_type === "default";
}
function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
return {
clientKey: `default-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
draw_id: null,
normalized_number: "0000",
cap_amount: amount,
cap_type: "default",
};
}
export function RiskCapDocScreen() {
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
@@ -93,12 +107,12 @@ export function RiskCapDocScreen() {
}, [refreshList]);
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
if (rows.length === 0) {
const defaultRow = rows.find(isDefaultRiskRow);
if (!defaultRow) {
setDefaultCapStr("");
return;
}
const amounts = [...new Set(rows.map((r) => r.cap_amount))];
setDefaultCapStr(amounts.length === 1 ? String(amounts[0]) : "");
setDefaultCapStr(String(defaultRow.cap_amount));
}
const loadDetail = useCallback(async (id: number) => {
@@ -177,6 +191,13 @@ export function RiskCapDocScreen() {
return;
}
for (const r of draftRows) {
if (isDefaultRiskRow(r)) {
if (r.cap_amount <= 0) {
toast.error("默认封顶金额必须大于 0");
return;
}
continue;
}
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
toast.error(`号码须为 4 位数字:${r.normalized_number}`);
return;
@@ -265,13 +286,16 @@ export function RiskCapDocScreen() {
}
}
function applyDefaultCapToAll() {
function applyDefaultCap() {
const n = Number.parseInt(defaultCapStr, 10);
if (!Number.isFinite(n) || n < 0) {
if (!Number.isFinite(n) || n <= 0) {
toast.error("请输入有效的封顶金额");
return;
}
setDraftRows((prev) => prev.map((r) => ({ ...r, cap_amount: n })));
setDraftRows((prev) => {
const next = prev.filter((row) => !isDefaultRiskRow(row));
return [defaultRiskRowFromAmount(n), ...next];
});
setSyncOpen(false);
toast.message("已写入本地草稿,记得保存草稿");
}
@@ -279,11 +303,16 @@ export function RiskCapDocScreen() {
const occFiltered = useMemo(() => {
const q = occSearch.trim();
if (!q) {
return draftRows;
return draftRows.filter((row) => !isDefaultRiskRow(row));
}
return draftRows.filter((r) => r.normalized_number.includes(q));
return draftRows.filter((r) => !isDefaultRiskRow(r) && r.normalized_number.includes(q));
}, [draftRows, occSearch]);
const specialRows = useMemo(
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
[draftRows],
);
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteRiskCapVersion(row.id);
@@ -346,7 +375,7 @@ export function RiskCapDocScreen() {
<section className="space-y-3 rounded-lg border bg-muted/20 p-4">
<h3 className="text-sm font-medium"></h3>
<p className="text-sm text-muted-foreground">
稿<strong></strong>
使
</p>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
@@ -391,7 +420,7 @@ export function RiskCapDocScreen() {
</div>
{loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
) : draftRows.length === 0 ? (
) : specialRows.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="overflow-x-auto rounded-md border">
@@ -407,7 +436,7 @@ export function RiskCapDocScreen() {
</TableRow>
</TableHeader>
<TableBody>
{draftRows.map((r, idx) => (
{specialRows.map(({ row: r, index: idx }) => (
<TableRow key={r.clientKey}>
<TableCell>
{isDraft ? (
@@ -453,7 +482,7 @@ export function RiskCapDocScreen() {
type="button"
variant="ghost"
className="text-destructive"
disabled={saving || draftRows.length <= 1}
disabled={saving}
onClick={() => removeRow(idx)}
>
@@ -535,14 +564,14 @@ export function RiskCapDocScreen() {
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{defaultCapStr || "(空)"}稿稿
{defaultCapStr || "(空)"}稿稿
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
</Button>
<Button type="button" onClick={applyDefaultCapToAll}>
<Button type="button" onClick={applyDefaultCap}>
</Button>
</DialogFooter>

View File

@@ -2,7 +2,11 @@
import { useCallback, useEffect, useState } from "react";
import { getAdminJackpotPools, putAdminJackpotPool } from "@/api/admin-jackpot";
import {
getAdminJackpotPools,
postAdminJackpotManualBurst,
putAdminJackpotPool,
} from "@/api/admin-jackpot";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -27,7 +31,10 @@ type Draft = {
payout_rate: string;
force_trigger_draw_gap: string;
min_bet_amount: string;
combo_trigger_play_codes: string;
status: string;
manual_burst_draw_id: string;
manual_burst_amount: string;
};
function toDraft(p: AdminJackpotPoolRow): Draft {
@@ -38,7 +45,10 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
payout_rate: String(p.payout_rate),
force_trigger_draw_gap: String(p.force_trigger_draw_gap),
min_bet_amount: String(p.min_bet_amount),
combo_trigger_play_codes: p.combo_trigger_play_codes.join(","),
status: String(p.status),
manual_burst_draw_id: "",
manual_burst_amount: "",
};
}
@@ -47,6 +57,7 @@ export function JackpotPoolsConsole() {
const [drafts, setDrafts] = useState<Record<number, Draft>>({});
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<number | null>(null);
const [burstingId, setBurstingId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -90,6 +101,10 @@ export function JackpotPoolsConsole() {
payout_rate: Number(d.payout_rate),
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
min_bet_amount: Number.parseInt(d.min_bet_amount, 10),
combo_trigger_play_codes: d.combo_trigger_play_codes
.split(",")
.map((v) => v.trim().toLowerCase())
.filter(Boolean),
status: Number.parseInt(d.status, 10),
});
toast.success("已保存");
@@ -101,6 +116,34 @@ export function JackpotPoolsConsole() {
}
};
const manualBurst = async (p: AdminJackpotPoolRow) => {
const d = drafts[p.id];
if (!d) return;
const drawId = Number.parseInt(d.manual_burst_draw_id, 10);
if (!Number.isFinite(drawId) || drawId <= 0) {
toast.error("请填写有效的期号 ID");
return;
}
const amount = d.manual_burst_amount.trim()
? Number.parseInt(d.manual_burst_amount, 10)
: undefined;
setBurstingId(p.id);
try {
await postAdminJackpotManualBurst(p.id, {
draw_id: drawId,
amount: amount !== undefined && Number.isFinite(amount) ? amount : undefined,
});
toast.success("已手动触发爆池");
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "手动爆池失败");
} finally {
setBurstingId(null);
}
};
return (
<ModuleScaffold>
<Card>
@@ -177,6 +220,16 @@ export function JackpotPoolsConsole() {
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`combo-${p.id}`}></Label>
<Input
id={`combo-${p.id}`}
className="font-mono"
value={d.combo_trigger_play_codes}
placeholder="straight,ibox"
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Select
@@ -198,6 +251,36 @@ export function JackpotPoolsConsole() {
{savingId === p.id ? "保存中…" : "保存"}
</Button>
</div>
<div className="rounded-md border border-amber-200 bg-amber-50 p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
<div className="space-y-1.5">
<Label htmlFor={`burst-draw-${p.id}`}> ID</Label>
<Input
id={`burst-draw-${p.id}`}
className="font-mono"
value={d.manual_burst_draw_id}
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`burst-amount-${p.id}`}></Label>
<Input
id={`burst-amount-${p.id}`}
className="font-mono"
value={d.manual_burst_amount}
onChange={(e) => updateDraft(p.id, { manual_burst_amount: e.target.value })}
/>
</div>
<Button
type="button"
variant="destructive"
disabled={burstingId === p.id}
onClick={() => void manualBurst(p)}
>
{burstingId === p.id ? "处理中…" : "手动爆池"}
</Button>
</div>
</div>
</div>
);
})}

View File

@@ -3,7 +3,11 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { getAdminReportJobs, postAdminReportJob } from "@/api/admin-reports";
import {
downloadAdminReportJob,
getAdminReportJobs,
postAdminReportJob,
} from "@/api/admin-reports";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -30,6 +34,15 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminReportJobListData } from "@/types/api/admin-reports";
const REPORT_TYPES = [
{ value: "draw_profit_summary", label: "期号盈亏" },
{ value: "daily_profit_summary", label: "每日盈亏汇总" },
{ value: "player_win_loss", label: "玩家输赢报表" },
{ value: "wallet_transfer_report", label: "玩家转入转出报表" },
{ value: "hot_number_risk_report", label: "热门号码风险报表" },
{ value: "play_dimension_report", label: "玩法维度报表" },
{ value: "sold_out_number_report", label: "售罄号码报表" },
{ value: "rebate_commission_report", label: "佣金回水报表" },
{ value: "audit_operation_report", label: "后台操作审计报表" },
{ value: "wallet_txns_daily", label: "钱包流水日报" },
{ value: "transfer_orders_daily", label: "转账单日报" },
] as const;
@@ -83,6 +96,7 @@ export function ReportsConsole(): React.ReactElement {
await postAdminReportJob({
report_type: reportType,
export_format: exportFormat,
parameters: filter_json,
filter_json,
});
toast.success("已创建导出任务");
@@ -95,6 +109,20 @@ export function ReportsConsole(): React.ReactElement {
}
}
async function onDownload(rowId: number): Promise<void> {
try {
const blob = await downloadAdminReportJob(rowId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("下载失败");
}
}
const meta = data?.meta;
const lastPage = meta
? Math.max(1, meta.last_page)
@@ -194,13 +222,14 @@ export function ReportsConsole(): React.ReactElement {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
<TableCell colSpan={8} className="text-muted-foreground">
</TableCell>
</TableRow>
@@ -217,6 +246,16 @@ export function ReportsConsole(): React.ReactElement {
<TableCell className="max-w-[12rem] truncate text-xs text-muted-foreground">
{row.output_path ?? "—"}
</TableCell>
<TableCell>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void onDownload(row.id)}
>
</Button>
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>

View File

@@ -2,11 +2,18 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { getAdminRiskPools } from "@/api/admin-risk";
import {
getAdminRiskPools,
postAdminRiskPoolManualClose,
postAdminRiskPoolRecover,
} from "@/api/admin-risk";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Button } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -36,6 +43,8 @@ const SORT_OPTIONS: { value: "usage_desc" | "locked_desc" | "remaining_asc" | "n
{ value: "number_asc", label: "号码 ↑" },
];
type RiskFilter = "all" | "sold_out" | "high_risk";
type RiskPoolsConsoleProps = {
drawId: number;
title: string;
@@ -52,10 +61,13 @@ export function RiskPoolsConsole({
allowSortChange = false,
}: RiskPoolsConsoleProps) {
const [sort, setSort] = useState(defaultSort);
const [filter, setFilter] = useState<RiskFilter>(soldOutOnly ? "sold_out" : "all");
const [number, setNumber] = useState("");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [data, setData] = useState<AdminRiskPoolListData | null>(null);
const [loading, setLoading] = useState(true);
const [actingNumber, setActingNumber] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
@@ -65,7 +77,9 @@ export function RiskPoolsConsole({
const d = await getAdminRiskPools(drawId, {
page,
per_page: perPage,
sold_out_only: soldOutOnly,
sold_out_only: filter === "sold_out",
high_risk_only: filter === "high_risk",
normalized_number: number.trim(),
sort,
});
setData(d);
@@ -77,7 +91,7 @@ export function RiskPoolsConsole({
} finally {
setLoading(false);
}
}, [drawId, page, perPage, soldOutOnly, sort]);
}, [drawId, filter, number, page, perPage, sort]);
useEffect(() => {
queueMicrotask(() => {
@@ -85,12 +99,77 @@ export function RiskPoolsConsole({
});
}, [load]);
const handleManualStatus = useCallback(
async (row: AdminRiskPoolRow) => {
setActingNumber(row.normalized_number);
try {
const updated = row.is_sold_out
? await postAdminRiskPoolRecover(drawId, row.normalized_number)
: await postAdminRiskPoolManualClose(drawId, row.normalized_number);
setData((current) => {
if (!current) return current;
return {
...current,
items: current.items.map((item) =>
item.normalized_number === updated.normalized_number ? updated : item,
),
};
});
toast.success(row.is_sold_out ? "已恢复号码下注" : "已手动关闭号码下注");
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "操作失败");
} finally {
setActingNumber(null);
}
},
[drawId],
);
return (
<Card>
<CardHeader className="space-y-2">
<CardTitle className="text-lg">{title}</CardTitle>
{allowSortChange ? (
<div className="flex max-w-xs flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<Label htmlFor="risk-pool-number"></Label>
<Input
id="risk-pool-number"
value={number}
maxLength={4}
placeholder="如 8888"
className="h-9 w-32 font-mono"
onChange={(event) => {
setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
setPage(1);
}}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<div className="flex flex-wrap gap-2">
{[
["all", "全部"],
["sold_out", "售罄"],
["high_risk", ">80%"],
].map(([value, label]) => (
<Button
key={value}
type="button"
size="sm"
variant={filter === value ? "default" : "outline"}
onClick={() => {
setFilter(value as RiskFilter);
setPage(1);
}}
>
{label}
</Button>
))}
</div>
</div>
{allowSortChange ? (
<div className="space-y-1.5">
<Label htmlFor="risk-pool-sort"></Label>
<Select
@@ -114,8 +193,8 @@ export function RiskPoolsConsole({
</SelectContent>
</Select>
</div>
</div>
) : null}
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
@@ -132,40 +211,78 @@ export function RiskPoolsConsole({
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).map((row: AdminRiskPoolRow) => (
<TableRow key={row.normalized_number}>
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatAdminMinorUnits(row.total_cap_amount)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatAdminMinorUnits(row.locked_amount)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatAdminMinorUnits(row.remaining_amount)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
</TableCell>
<TableCell>{row.is_sold_out ? "是" : "否"}</TableCell>
<TableCell className="text-right">
<Link
href={`/admin/risk/draws/${drawId}/pools/${row.normalized_number}`}
className={cn(
buttonVariants({ variant: "link", size: "sm" }),
"h-auto p-0",
)}
>
</Link>
</TableCell>
</TableRow>
))}
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
const acting = actingNumber === row.normalized_number;
return (
<TableRow
key={row.normalized_number}
className={cn(
row.is_sold_out
? "bg-red-50/90 hover:bg-red-50 dark:bg-red-950/25 dark:hover:bg-red-950/35"
: highRisk
? "bg-orange-50/90 hover:bg-orange-50 dark:bg-orange-950/25 dark:hover:bg-orange-950/35"
: null,
)}
>
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatAdminMinorUnits(row.total_cap_amount)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatAdminMinorUnits(row.locked_amount)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatAdminMinorUnits(row.remaining_amount)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
</TableCell>
<TableCell>
<span
className={cn(
"inline-flex h-6 items-center rounded px-2 text-xs font-medium",
row.is_sold_out
? "bg-red-600 text-white"
: highRisk
? "bg-orange-500 text-white"
: "bg-muted text-muted-foreground",
)}
>
{row.is_sold_out ? "售罄" : highRisk ? "预警" : "正常"}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant={row.is_sold_out ? "outline" : "destructive"}
disabled={acting}
onClick={() => void handleManualStatus(row)}
>
{row.is_sold_out ? "恢复" : "关闭"}
</Button>
<Link
href={`/admin/risk/draws/${drawId}/pools/${row.normalized_number}`}
className={cn(
buttonVariants({ variant: "link", size: "sm" }),
"h-8 px-0",
)}
>
</Link>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>