feat(config): 重构配置模块导航与版本切换,新增版本删除能力

This commit is contained in:
2026-05-15 15:30:52 +08:00
parent 000295ae2b
commit 8bd7cc3d73
20 changed files with 669 additions and 377 deletions

View File

@@ -72,6 +72,10 @@ export async function publishPlayConfigVersion(id: number): Promise<PlayConfigVe
return adminRequest.post(`${A}/config/play-versions/${id}/publish`); return adminRequest.post(`${A}/config/play-versions/${id}/publish`);
} }
export async function deletePlayConfigVersion(id: number): Promise<{ deleted: boolean }> {
return adminRequest.delete(`${A}/config/play-versions/${id}`);
}
export async function getOddsVersions(params?: { export async function getOddsVersions(params?: {
status?: string; status?: string;
page?: number; page?: number;
@@ -110,6 +114,10 @@ export async function publishOddsVersion(id: number): Promise<OddsVersionDetail>
return adminRequest.post(`${A}/config/odds-versions/${id}/publish`); return adminRequest.post(`${A}/config/odds-versions/${id}/publish`);
} }
export async function deleteOddsVersion(id: number): Promise<{ deleted: boolean }> {
return adminRequest.delete(`${A}/config/odds-versions/${id}`);
}
export async function getRiskCapVersions(params?: { export async function getRiskCapVersions(params?: {
status?: string; status?: string;
page?: number; page?: number;
@@ -144,3 +152,7 @@ export async function putRiskCapItems(
export async function publishRiskCapVersion(id: number): Promise<RiskCapVersionDetail> { export async function publishRiskCapVersion(id: number): Promise<RiskCapVersionDetail> {
return adminRequest.post(`${A}/config/risk-cap-versions/${id}/publish`); return adminRequest.post(`${A}/config/risk-cap-versions/${id}/publish`);
} }
export async function deleteRiskCapVersion(id: number): Promise<{ deleted: boolean }> {
return adminRequest.delete(`${A}/config/risk-cap-versions/${id}`);
}

View File

@@ -1,14 +1,7 @@
import { ConfigSubNav } from "@/modules/config/config-subnav"; import type { ReactNode } from "react";
export default function AdminConfigLayout({ import { ConfigWorkspaceShell } from "@/modules/config/config-workspace-shell";
children,
}: { export default function AdminConfigLayout({ children }: { children: ReactNode }) {
children: React.ReactNode; return <ConfigWorkspaceShell>{children}</ConfigWorkspaceShell>;
}) {
return (
<div className="w-full max-w-none px-1">
<ConfigSubNav />
{children}
</div>
);
} }

View File

@@ -1,4 +1,3 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen"; import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
import { configOddsMeta } from "@/modules/config/meta"; import { configOddsMeta } from "@/modules/config/meta";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -8,9 +7,5 @@ export const metadata: Metadata = {
}; };
export default function AdminConfigOddsPage() { export default function AdminConfigOddsPage() {
return ( return <OddsConfigDocScreen />;
<ModuleScaffold className="max-w-6xl">
<OddsConfigDocScreen />
</ModuleScaffold>
);
} }

View File

@@ -1,7 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { Layers, Shield, Wrench } from "lucide-react";
import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Card, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model";
import { configHubMeta } from "@/modules/config/meta"; import { configHubMeta } from "@/modules/config/meta";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -9,33 +11,53 @@ export const metadata: Metadata = {
title: configHubMeta.title, title: configHubMeta.title,
}; };
const SECTIONS = [ const GROUP_ICONS = {
{ href: "/admin/config/plays", title: "玩法配置" }, betting: Layers,
{ href: "/admin/config/odds", title: "赔率配置" }, risk_wallet: Shield,
{ href: "/admin/config/rebate", title: "佣金 / 回水" }, ops: Wrench,
{ href: "/admin/config/risk-cap", title: "风控封顶" }, } as const;
{ href: "/admin/config/versions", title: "配置版本历史" },
{ href: "/admin/config/wallet", title: "钱包配置" },
] as const;
export default function AdminConfigHubPage() { export default function AdminConfigHubPage() {
return ( return (
<ModuleScaffold className="max-w-4xl"> <ModuleScaffold className="max-w-4xl">
<h1 className="mb-6 text-xl font-semibold tracking-tight">{configHubMeta.title}</h1> <header className="mb-8 space-y-2">
<div className="grid gap-4 sm:grid-cols-2"> <h1 className="text-2xl font-semibold tracking-tight">{configHubMeta.title}</h1>
{SECTIONS.map((s) => ( <p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
<Link 稿 {" "}
key={s.href} <span className="font-medium text-foreground">active</span>
href={s.href} </p>
className="block rounded-lg outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring" </header>
>
<Card className="h-full transition-colors hover:bg-muted/40"> <div className="space-y-10">
<CardHeader> {CONFIG_NAV_GROUPS.map((group) => {
<CardTitle className="text-base">{s.title}</CardTitle> const Icon = GROUP_ICONS[group.id as keyof typeof GROUP_ICONS] ?? Layers;
</CardHeader> return (
</Card> <section key={group.id} className="space-y-4">
</Link> <div className="flex items-center gap-2 border-b border-border pb-2">
))} <Icon className="size-4 text-muted-foreground" aria-hidden />
<h2 className="text-sm font-semibold tracking-wide text-muted-foreground uppercase">
{group.label}
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{group.items.map((item) => (
<Link
key={item.href}
href={item.href}
className="block rounded-xl outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring"
>
<Card className="h-full transition-colors hover:border-primary/30 hover:bg-muted/30">
<CardHeader className="space-y-2">
<CardTitle className="text-base">{item.title}</CardTitle>
<CardDescription className="text-sm leading-snug">{item.description}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</section>
);
})}
</div> </div>
</ModuleScaffold> </ModuleScaffold>
); );

View File

@@ -1,4 +1,3 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { PlayConfigDocScreen } from "@/modules/config/doc/play-config-doc-screen"; import { PlayConfigDocScreen } from "@/modules/config/doc/play-config-doc-screen";
import { configPlayConfigMeta } from "@/modules/config/meta"; import { configPlayConfigMeta } from "@/modules/config/meta";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -8,9 +7,5 @@ export const metadata: Metadata = {
}; };
export default function AdminConfigPlaysPage() { export default function AdminConfigPlaysPage() {
return ( return <PlayConfigDocScreen />;
<ModuleScaffold className="max-w-6xl">
<PlayConfigDocScreen />
</ModuleScaffold>
);
} }

View File

@@ -1,4 +1,3 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen"; import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen";
import { configRebateMeta } from "@/modules/config/meta"; import { configRebateMeta } from "@/modules/config/meta";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -8,9 +7,5 @@ export const metadata: Metadata = {
}; };
export default function AdminConfigRebateDedicatedPage() { export default function AdminConfigRebateDedicatedPage() {
return ( return <RebateConfigDocScreen />;
<ModuleScaffold className="max-w-5xl">
<RebateConfigDocScreen />
</ModuleScaffold>
);
} }

View File

@@ -1,4 +1,3 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { configRiskCapMeta } from "@/modules/config/meta"; import { configRiskCapMeta } from "@/modules/config/meta";
import { RiskCapDocScreen } from "@/modules/config/doc/risk-cap-doc-screen"; import { RiskCapDocScreen } from "@/modules/config/doc/risk-cap-doc-screen";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -8,9 +7,5 @@ export const metadata: Metadata = {
}; };
export default function AdminConfigRiskCapPage() { export default function AdminConfigRiskCapPage() {
return ( return <RiskCapDocScreen />;
<ModuleScaffold className="max-w-5xl">
<RiskCapDocScreen />
</ModuleScaffold>
);
} }

View File

@@ -1,16 +0,0 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { ConfigVersionsConsole } from "@/modules/config/config-versions-console";
import { configVersionsMeta } from "@/modules/config/meta";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: configVersionsMeta.title,
};
export default function AdminConfigVersionsPage() {
return (
<ModuleScaffold className="max-w-5xl">
<ConfigVersionsConsole />
</ModuleScaffold>
);
}

View File

@@ -1,4 +1,3 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { configWalletMeta } from "@/modules/config/meta"; import { configWalletMeta } from "@/modules/config/meta";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen"; import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -8,9 +7,5 @@ export const metadata: Metadata = {
}; };
export default function AdminConfigWalletPage() { export default function AdminConfigWalletPage() {
return ( return <WalletConfigDocScreen />;
<ModuleScaffold className="max-w-3xl">
<WalletConfigDocScreen />
</ModuleScaffold>
);
} }

View File

@@ -11,6 +11,7 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav"; import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav";
import { CONFIG_ROUTE_LABELS } from "@/modules/config/config-nav-model";
import React from "react"; import React from "react";
const DRAW_ROUTE_LABELS: Record<string, string> = { const DRAW_ROUTE_LABELS: Record<string, string> = {
@@ -26,6 +27,12 @@ function titleCase(value: string): string {
.join(" "); .join(" ");
} }
type BreadcrumbCrumb = {
label: string;
href: string;
isCurrent: boolean;
};
export function AdminBreadcrumb() { export function AdminBreadcrumb() {
const pathname = usePathname(); const pathname = usePathname();
@@ -33,7 +40,7 @@ export function AdminBreadcrumb() {
const segments = pathname.split("/").filter(Boolean); const segments = pathname.split("/").filter(Boolean);
// 基础面包屑:首页/仪表盘 // 基础面包屑:首页/仪表盘
const breadcrumbs = [ const breadcrumbs: BreadcrumbCrumb[] = [
{ {
label: "首页", label: "首页",
href: ADMIN_BASE, href: ADMIN_BASE,
@@ -58,7 +65,12 @@ export function AdminBreadcrumb() {
if (segments.length > 2) { if (segments.length > 2) {
const subSegment = segments[2]; const subSegment = segments[2];
const subLabel = subSegment ? DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment) : ""; let subLabel = "";
if (businessSegment === "config" && subSegment) {
subLabel = CONFIG_ROUTE_LABELS[subSegment] ?? titleCase(subSegment);
} else {
subLabel = subSegment ? DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment) : "";
}
if (subLabel) { if (subLabel) {
breadcrumbs.push({ breadcrumbs.push({
label: subLabel, label: subLabel,

View File

@@ -27,8 +27,8 @@ import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "12rem"
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = "14rem"
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = "b"

View File

@@ -0,0 +1,77 @@
/**
* 运营配置子导航与面包屑的单一数据源。
* 新增配置页:在此追加条目,并增加 `app/admin/(shell)/config/.../page.tsx`。
*/
export type ConfigNavGroup = {
id: string;
label: string;
items: readonly {
href: string;
title: string;
description: string;
}[];
};
export const CONFIG_NAV_GROUPS: readonly ConfigNavGroup[] = [
{
id: "betting",
label: "投注与展示",
items: [
{
href: "/admin/config/plays",
title: "玩法与限额",
description: "目录开关、单玩法限额、版本发布",
},
{
href: "/admin/config/odds",
title: "赔率",
description: "按玩法与奖级维护乘数与币种",
},
{
href: "/admin/config/rebate",
title: "佣金 / 回水",
description: "从赔率草稿批量调整回水比例",
},
],
},
{
id: "risk_wallet",
label: "风控与资金",
items: [
{
href: "/admin/config/risk-cap",
title: "赔付封顶",
description: "按号码维度的封顶版本",
},
{
href: "/admin/config/wallet",
title: "钱包阈值",
description: "转入转出上下限(系统设置)",
},
],
},
] as const;
const CONFIG_ROUTE_LABEL_ENTRIES: readonly [string, string][] = [
["plays", "玩法与限额"],
["odds", "赔率"],
["rebate", "佣金 / 回水"],
["risk-cap", "赔付封顶"],
["wallet", "钱包阈值"],
];
/** 面包屑第三段 slug → 中文 */
export const CONFIG_ROUTE_LABELS: Readonly<Record<string, string>> = Object.fromEntries(
CONFIG_ROUTE_LABEL_ENTRIES,
) as Readonly<Record<string, string>>;
export function flattenConfigNavHrefs(): string[] {
const out: string[] = [];
for (const g of CONFIG_NAV_GROUPS) {
for (const it of g.items) {
out.push(it.href);
}
}
return out;
}

View File

@@ -0,0 +1,245 @@
"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import type { ConfigVersionSummary } from "@/types/api/admin-config";
function versionStatusLabel(status: string): string {
if (status === "active") {
return "生效中";
}
if (status === "draft") {
return "草稿";
}
if (status === "archived") {
return "已归档";
}
return status;
}
function versionSelectLabel(v: ConfigVersionSummary): string {
return `#${v.id} · v${v.version_no} · ${versionStatusLabel(v.status)}`;
}
export type ConfigVersionSwitcherProps = {
versions: ConfigVersionSummary[];
selectedId: string;
onSelectedIdChange: (id: string) => void;
loading?: boolean;
label?: string;
sheetTitle?: string;
sheetDescription?: string;
onDeleteVersion?: (row: ConfigVersionSummary) => Promise<void>;
onRollbackVersion?: (row: ConfigVersionSummary) => void;
rollbackBusy?: boolean;
};
export function ConfigVersionSwitcher({
versions,
selectedId,
onSelectedIdChange,
loading = false,
label = "配置版本",
sheetTitle = "切换配置版本",
sheetDescription = "选择一条版本在本页查看;草稿可编辑,生效中与已归档为只读。",
onDeleteVersion,
onRollbackVersion,
rollbackBusy = false,
}: ConfigVersionSwitcherProps) {
const formatDt = useAdminDateTimeFormatter();
const [sheetOpen, setSheetOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ConfigVersionSummary | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const sortedVersions = useMemo(
() => [...versions].sort((a, b) => b.id - a.id),
[versions],
);
function switchTo(id: number) {
onSelectedIdChange(String(id));
setSheetOpen(false);
}
async function confirmDelete() {
if (!deleteTarget || !onDeleteVersion) {
return;
}
setDeletingId(deleteTarget.id);
try {
await onDeleteVersion(deleteTarget);
if (selectedId === String(deleteTarget.id)) {
onSelectedIdChange("");
}
setDeleteTarget(null);
} finally {
setDeletingId(null);
}
}
return (
<>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-2 min-w-[260px]">
<Label>{label}</Label>
<Select
value={selectedId}
onValueChange={(v) => onSelectedIdChange(v ?? "")}
disabled={loading || sortedVersions.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={loading ? "加载中…" : "选择版本"} />
</SelectTrigger>
<SelectContent>
{sortedVersions.map((v) => (
<SelectItem key={v.id} value={String(v.id)}>
{versionSelectLabel(v)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="outline" disabled={loading} onClick={() => setSheetOpen(true)}>
</Button>
</div>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="right" className="sm:max-w-lg flex flex-col">
<SheetHeader>
<SheetTitle>{sheetTitle}</SheetTitle>
<SheetDescription>{sheetDescription}</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto rounded-md border mt-4">
{sortedVersions.length === 0 ? (
<p className="text-sm text-muted-foreground p-4"></p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[88px]">version_no</TableHead>
<TableHead className="w-[88px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right w-[180px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedVersions.map((v) => {
const isCurrent = selectedId === String(v.id);
return (
<TableRow key={v.id} data-state={isCurrent ? "selected" : undefined}>
<TableCell className="font-mono text-xs tabular-nums">v{v.version_no}</TableCell>
<TableCell>
<ConfigStatusBadge status={v.status} />
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{v.effective_at ? formatDt(v.effective_at) : "—"}
</TableCell>
<TableCell className="text-right">
<div className="inline-flex flex-wrap items-center justify-end gap-1">
<Button
type="button"
variant={isCurrent ? "secondary" : "outline"}
size="sm"
onClick={() => switchTo(v.id)}
>
{isCurrent ? "当前" : "查看"}
</Button>
{onRollbackVersion && v.status !== "draft" ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={rollbackBusy}
onClick={() => {
onRollbackVersion(v);
setSheetOpen(false);
}}
>
</Button>
) : null}
{onDeleteVersion && v.status !== "active" ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive"
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
</Button>
) : null}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</SheetContent>
</Sheet>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
ID {deleteTarget?.id}version_no {deleteTarget?.version_no}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
</Button>
<Button
type="button"
variant="destructive"
disabled={deletingId !== null}
onClick={() => void confirmDelete()}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,136 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
getOddsVersions,
getPlayConfigVersions,
getRiskCapVersions,
} from "@/api/admin-config";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors";
import type { ConfigVersionSummary } from "@/types/api/admin-config";
type ConfigVersionTableProps = {
rows: ConfigVersionSummary[];
loading: boolean;
formatDt: (iso: string) => string;
};
function ConfigVersionTable({ rows, loading, formatDt }: ConfigVersionTableProps) {
if (loading) {
return <p className="text-sm text-muted-foreground py-4"></p>;
}
if (rows.length === 0) {
return <p className="text-sm text-muted-foreground py-4"></p>;
}
return (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[72px]">ID</TableHead>
<TableHead className="w-[88px]">version_no</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((v) => (
<TableRow key={v.id}>
<TableCell className="tabular-nums">{v.id}</TableCell>
<TableCell className="tabular-nums">{v.version_no}</TableCell>
<TableCell>
<ConfigStatusBadge status={v.status} />
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{v.effective_at ? formatDt(v.effective_at) : "—"}
</TableCell>
<TableCell className="text-sm max-w-[280px] truncate" title={v.reason ?? ""}>
{v.reason ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export function ConfigVersionsConsole() {
const formatDt = useAdminDateTimeFormatter();
const [playRows, setPlayRows] = useState<ConfigVersionSummary[]>([]);
const [oddsRows, setOddsRows] = useState<ConfigVersionSummary[]>([]);
const [riskRows, setRiskRows] = useState<ConfigVersionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadAll = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [p, o, r] = await Promise.all([
getPlayConfigVersions({ per_page: 100 }),
getOddsVersions({ per_page: 100 }),
getRiskCapVersions({ per_page: 100 }),
]);
setPlayRows(p.items.sort((a, b) => b.id - a.id));
setOddsRows(o.items.sort((a, b) => b.id - a.id));
setRiskRows(r.items.sort((a, b) => b.id - a.id));
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载版本历史失败";
setError(msg);
setPlayRows([]);
setOddsRows([]);
setRiskRows([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadAll();
});
}, [loadAll]);
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Tabs defaultValue="play">
<TabsList>
<TabsTrigger value="play"></TabsTrigger>
<TabsTrigger value="odds"></TabsTrigger>
<TabsTrigger value="risk"></TabsTrigger>
</TabsList>
<TabsContent value="play" className="mt-4">
<ConfigVersionTable rows={playRows} loading={loading} formatDt={formatDt} />
</TabsContent>
<TabsContent value="odds" className="mt-4">
<ConfigVersionTable rows={oddsRows} loading={loading} formatDt={formatDt} />
</TabsContent>
<TabsContent value="risk" className="mt-4">
<ConfigVersionTable rows={riskRows} loading={loading} formatDt={formatDt} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model";
function navLinkActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
const pathname = usePathname() ?? "";
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 lg:flex-row lg:gap-8">
<aside className="shrink-0 lg:w-48 lg:border-r lg:border-border lg:pr-4">
<div className="mb-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></p>
<p className="mt-0.5 text-[11px] leading-tight text-muted-foreground">
稿
</p>
</div>
<nav className="hidden lg:block space-y-4" aria-label="运营配置子导航">
{CONFIG_NAV_GROUPS.map((group) => (
<div key={group.id}>
<p className="mb-1 text-[11px] font-semibold text-muted-foreground">{group.label}</p>
<ul className="space-y-0.5">
{group.items.map((item) => {
const active = navLinkActive(pathname, item.href);
return (
<li key={item.href}>
<Link
href={item.href}
title={item.description}
className={cn(
"block rounded-md px-2 py-1.5 text-sm transition-colors outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-primary/10 text-primary font-medium"
: "text-foreground hover:bg-muted/80",
)}
>
{item.title}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="lg:hidden overflow-x-auto pb-1 -mx-1 px-1">
<div className="flex w-max gap-2">
{CONFIG_NAV_GROUPS.flatMap((g) => g.items).map((item) => {
const active = navLinkActive(pathname, item.href);
return (
<Link
key={`m-${item.href}`}
href={item.href}
className={cn(
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors whitespace-nowrap",
active
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-foreground hover:bg-muted/60",
)}
>
{item.title}
</Link>
);
})}
</div>
</div>
</aside>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteOddsVersion,
getAdminPlayTypes, getAdminPlayTypes,
getOddsVersion, getOddsVersion,
getOddsVersions, getOddsVersions,
@@ -23,21 +24,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -91,7 +78,6 @@ export function OddsConfigDocScreen() {
const [rollbackOpen, setRollbackOpen] = useState(false); const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null); const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const refreshTypes = useCallback(async () => { const refreshTypes = useCallback(async () => {
setLoadingTypes(true); setLoadingTypes(true);
@@ -332,6 +318,22 @@ export function OddsConfigDocScreen() {
const activeHead = list.find((x) => x.status === "active"); const activeHead = list.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteOddsVersion(row.id);
toast.success("已删除该版本");
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
throw e;
}
}
function requestRollback(row: ConfigVersionSummary) {
setRollbackTarget(row);
setRollbackOpen(true);
}
const catTabs: { id: CatTab; label: string }[] = [ const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: "全部" }, { id: "all", label: "全部" },
{ id: "d4", label: "4D" }, { id: "d4", label: "4D" },
@@ -340,8 +342,6 @@ export function OddsConfigDocScreen() {
{ id: "jackpot", label: "Jackpot" }, { id: "jackpot", label: "Jackpot" },
]; ];
const sortedHistory = useMemo(() => [...list].sort((a, b) => b.id - a.id), [list]);
return ( return (
<Card> <Card>
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
@@ -354,7 +354,6 @@ export function OddsConfigDocScreen() {
<Button <Button
key={t.id} key={t.id}
type="button" type="button"
size="sm"
variant={catTab === t.id ? "default" : "outline"} variant={catTab === t.id ? "default" : "outline"}
className={cn(catTab === t.id && "shadow-sm")} className={cn(catTab === t.id && "shadow-sm")}
onClick={() => { onClick={() => {
@@ -377,7 +376,6 @@ export function OddsConfigDocScreen() {
<Button <Button
key={t.play_code} key={t.play_code}
type="button" type="button"
size="sm"
variant={resolvedPlayCode === t.play_code ? "secondary" : "outline"} variant={resolvedPlayCode === t.play_code ? "secondary" : "outline"}
onClick={() => setPlayCode(t.play_code)} onClick={() => setPlayCode(t.play_code)}
> >
@@ -388,17 +386,28 @@ export function OddsConfigDocScreen() {
</div> </div>
</div> </div>
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="赔率配置版本"
sheetDescription="选择版本在本页查看;非草稿版本可回滚为新建草稿。"
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
/>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
size="sm"
disabled={loadingList} disabled={loadingList}
onClick={() => void refreshList()} onClick={() => void refreshList()}
> >
</Button> </Button>
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}> <Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿 稿
</Button> </Button>
</div> </div>
@@ -483,59 +492,6 @@ export function OddsConfigDocScreen() {
) : null} ) : null}
<div className="flex flex-wrap gap-2 pt-2"> <div className="flex flex-wrap gap-2 pt-2">
<Button type="button" variant="outline" size="sm" onClick={() => setHistoryOpen(true)}>
</Button>
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent side="right" className="sm:max-w-lg flex flex-col">
<SheetHeader>
<SheetTitle></SheetTitle>
<SheetDescription>稿</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto rounded-md border mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedHistory.map((v) => (
<TableRow key={v.id}>
<TableCell className="font-mono text-xs">v{v.version_no}</TableCell>
<TableCell className="text-xs">
{v.status === "active" ? "生效" : v.status === "draft" ? "草稿" : "归档"}
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{v.updated_at ? formatDt(v.updated_at) : "—"}
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs"
disabled={saving || v.status === "draft"}
onClick={() => {
setRollbackTarget(v);
setRollbackOpen(true);
setHistoryOpen(false);
}}
>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SheetContent>
</Sheet>
<Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}> <Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}>
</Button> </Button>

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deletePlayConfigVersion,
getAdminPlayTypes, getAdminPlayTypes,
getPlayConfigVersion, getPlayConfigVersion,
getPlayConfigVersions, getPlayConfigVersions,
@@ -33,6 +34,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { import type {
AdminPlayTypeRow, AdminPlayTypeRow,
@@ -41,6 +43,59 @@ import type {
PlayConfigVersionDetail, PlayConfigVersionDetail,
} from "@/types/api/admin-config"; } from "@/types/api/admin-config";
const DEFAULT_PLAY_MIN_BET = 100;
const DEFAULT_PLAY_MAX_BET = 500_000_000;
type PlayConfigSaveItemPayload = {
play_code: string;
is_enabled: boolean;
min_bet_amount: number;
max_bet_amount: number;
display_order: number;
rule_text_zh: string | null;
rule_text_en: string | null;
rule_text_ne: string | null;
extra_config_json: unknown;
};
/** 与「玩法目录」对齐的完整列表,避免保存草稿时用残缺数组覆盖后端导致其它玩法配置被删。 */
function buildPlayConfigSavePayload(
typeRows: AdminPlayTypeRow[],
draftRows: PlayConfigItemRow[],
): PlayConfigSaveItemPayload[] {
const byCode = new Map(draftRows.map((r) => [r.play_code, r]));
const sorted = [...typeRows].sort(
(a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code),
);
return sorted.map((t) => {
const row = byCode.get(t.play_code);
if (row) {
return {
play_code: row.play_code,
is_enabled: row.is_enabled,
min_bet_amount: row.min_bet_amount,
max_bet_amount: row.max_bet_amount,
display_order: row.display_order,
rule_text_zh: row.rule_text_zh,
rule_text_en: row.rule_text_en,
rule_text_ne: row.rule_text_ne,
extra_config_json: row.extra_config_json,
};
}
return {
play_code: t.play_code,
is_enabled: t.is_enabled,
min_bet_amount: DEFAULT_PLAY_MIN_BET,
max_bet_amount: DEFAULT_PLAY_MAX_BET,
display_order: t.sort_order,
rule_text_zh: null,
rule_text_en: null,
rule_text_ne: null,
extra_config_json: null,
};
});
}
export function PlayConfigDocScreen() { export function PlayConfigDocScreen() {
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]); const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]); const [list, setList] = useState<ConfigVersionSummary[]>([]);
@@ -199,6 +254,7 @@ export function PlayConfigDocScreen() {
setSaving(true); setSaving(true);
try { try {
const updated = await patchAdminPlayType(play_code, { is_enabled: next }); const updated = await patchAdminPlayType(play_code, { is_enabled: next });
const typesForPayload = types.map((r) => (r.play_code === updated.play_code ? updated : r));
setTypes((prev) => setTypes((prev) =>
[...prev.map((r) => (r.play_code === updated.play_code ? updated : r))].sort( [...prev.map((r) => (r.play_code === updated.play_code ? updated : r))].sort(
(a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code), (a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code),
@@ -207,20 +263,11 @@ export function PlayConfigDocScreen() {
if (isDraft) { if (isDraft) {
updateConfigRow(play_code, { is_enabled: next }); updateConfigRow(play_code, { is_enabled: next });
const idx = draftRowIndex(play_code); const idx = draftRowIndex(play_code);
const nextRows = draftRows.map((r, i) => const rowsForMerge =
i === idx ? { ...r, is_enabled: next } : r, idx >= 0
); ? draftRows.map((r, i) => (i === idx ? { ...r, is_enabled: next } : r))
const payload = nextRows.map((r) => ({ : draftRows;
play_code: r.play_code, const payload = buildPlayConfigSavePayload(typesForPayload, rowsForMerge);
is_enabled: r.is_enabled,
min_bet_amount: r.min_bet_amount,
max_bet_amount: r.max_bet_amount,
display_order: r.display_order,
rule_text_zh: r.rule_text_zh,
rule_text_en: r.rule_text_en,
rule_text_ne: r.rule_text_ne,
extra_config_json: r.extra_config_json,
}));
const d = await putPlayConfigItems(detail.id, payload); const d = await putPlayConfigItems(detail.id, payload);
setDetail(d); setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it }))); setDraftRows(d.items.map((it) => ({ ...it })));
@@ -239,7 +286,8 @@ export function PlayConfigDocScreen() {
if (!detail || !isDraft) { if (!detail || !isDraft) {
return; return;
} }
for (const r of draftRows) { const payload = buildPlayConfigSavePayload(types, draftRows);
for (const r of payload) {
if (r.min_bet_amount > r.max_bet_amount) { if (r.min_bet_amount > r.max_bet_amount) {
toast.error(`${r.play_code}: 最小额不能大于最大额`); toast.error(`${r.play_code}: 最小额不能大于最大额`);
return; return;
@@ -247,17 +295,6 @@ export function PlayConfigDocScreen() {
} }
setSaving(true); setSaving(true);
try { try {
const payload = draftRows.map((r) => ({
play_code: r.play_code,
is_enabled: r.is_enabled,
min_bet_amount: r.min_bet_amount,
max_bet_amount: r.max_bet_amount,
display_order: r.display_order,
rule_text_zh: r.rule_text_zh,
rule_text_en: r.rule_text_en,
rule_text_ne: r.rule_text_ne,
extra_config_json: r.extra_config_json,
}));
const d = await putPlayConfigItems(detail.id, payload); const d = await putPlayConfigItems(detail.id, payload);
setDetail(d); setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it }))); setDraftRows(d.items.map((it) => ({ ...it })));
@@ -328,20 +365,50 @@ export function PlayConfigDocScreen() {
const activeHead = list.find((x) => x.status === "active"); const activeHead = list.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deletePlayConfigVersion(row.id);
toast.success("已删除该版本");
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
throw e;
}
}
return ( return (
<Card> <Card>
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle> <CardTitle className="text-lg"></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="rounded-lg border border-sky-200 bg-sky-50 px-3 py-2.5 text-sm text-sky-950 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-50">
<p className="font-medium"></p>
<p className="mt-1 text-xs leading-relaxed opacity-90">
{" "}
<span className="font-mono text-[11px]">GET /api/v1/play/effective</span>{" "}
稿稿<strong></strong>
</p>
</div>
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="玩法配置版本"
onDeleteVersion={handleDeleteVersion}
/>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshList()} disabled={loadingList}> <Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loadingList}>
</Button> </Button>
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshTypes()} disabled={loadingTypes}> <Button type="button" variant="secondary" onClick={() => void refreshTypes()} disabled={loadingTypes}>
</Button> </Button>
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}> <Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿 稿
</Button> </Button>
<Button type="button" onClick={() => void handleSaveDraft()} disabled={!isDraft || saving || loadingDetail}> <Button type="button" onClick={() => void handleSaveDraft()} disabled={!isDraft || saving || loadingDetail}>
@@ -390,7 +457,7 @@ export function PlayConfigDocScreen() {
<TableHead className="w-[100px]"></TableHead> <TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[88px] text-center"></TableHead> <TableHead className="w-[88px] text-center"></TableHead>
<TableHead className="min-w-[120px]"></TableHead> <TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[88px]"></TableHead> <TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead> <TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead> <TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[140px]"></TableHead> <TableHead className="w-[140px]"></TableHead>
@@ -426,10 +493,10 @@ export function PlayConfigDocScreen() {
}} }}
/> />
</TableCell> </TableCell>
<TableCell> <TableCell className="w-[120px]">
<Input <Input
type="number" type="number"
className="h-8 font-mono tabular-nums" className="h-8 w-full font-mono tabular-nums text-right"
defaultValue={t.sort_order} defaultValue={t.sort_order}
key={`${t.play_code}-so-${t.updated_at}`} key={`${t.play_code}-so-${t.updated_at}`}
disabled={saving} disabled={saving}
@@ -479,7 +546,6 @@ export function PlayConfigDocScreen() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm"
disabled={!item || !isDraft || saving} disabled={!item || !isDraft || saving}
onClick={() => openRuleEditor(t.play_code)} onClick={() => openRuleEditor(t.play_code)}
> >

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteOddsVersion,
getAdminPlayTypes, getAdminPlayTypes,
getOddsVersion, getOddsVersion,
getOddsVersions, getOddsVersions,
@@ -16,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { import type {
@@ -242,17 +244,38 @@ export function RebateConfigDocScreen() {
const activeHead = listRows.find((x) => x.status === "active"); const activeHead = listRows.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteOddsVersion(row.id);
toast.success("已删除该版本");
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
throw e;
}
}
return ( return (
<Card> <Card>
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-lg"> / </CardTitle> <CardTitle className="text-lg"> / </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 max-w-xl"> <CardContent className="space-y-6 max-w-xl">
<ConfigVersionSwitcher
versions={listRows}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loading}
sheetTitle="回水配置版本"
sheetDescription="回水写入赔率版本草稿;选择与赔率配置共用同一套版本。"
onDeleteVersion={handleDeleteVersion}
/>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshList()} disabled={loading}> <Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loading}>
</Button> </Button>
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}> <Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿 稿
</Button> </Button>
<Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}> <Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}>

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteRiskCapVersion,
getRiskCapVersion, getRiskCapVersion,
getRiskCapVersions, getRiskCapVersions,
postRiskCapVersion, postRiskCapVersion,
@@ -22,13 +23,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -272,8 +267,6 @@ export function RiskCapDocScreen() {
toast.message("已写入本地草稿,记得保存草稿"); toast.message("已写入本地草稿,记得保存草稿");
} }
const sortedList = useMemo(() => [...list].sort((a, b) => b.id - a.id), [list]);
const occFiltered = useMemo(() => { const occFiltered = useMemo(() => {
const q = occSearch.trim(); const q = occSearch.trim();
if (!q) { if (!q) {
@@ -282,6 +275,17 @@ export function RiskCapDocScreen() {
return draftRows.filter((r) => r.normalized_number.includes(q)); return draftRows.filter((r) => r.normalized_number.includes(q));
}, [draftRows, occSearch]); }, [draftRows, occSearch]);
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteRiskCapVersion(row.id);
toast.success("已删除该版本");
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
throw e;
}
}
return ( return (
<Card> <Card>
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
@@ -296,35 +300,20 @@ export function RiskCapDocScreen() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-8"> <CardContent className="space-y-8">
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="风控封顶版本"
onDeleteVersion={handleDeleteVersion}
/>
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div className="grid gap-2 min-w-[260px]"> <Button type="button" variant="secondary" onClick={() => void refreshList()}>
<Label></Label>
<Select
value={selectedId}
onValueChange={(v) => setSelectedId(v ?? "")}
disabled={loadingList || sortedList.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={loadingList ? "加载中…" : "选择版本"} />
</SelectTrigger>
<SelectContent>
{sortedList.map((v) => (
<SelectItem key={v.id} value={String(v.id)}>
#{v.id} · v{v.version_no} ·{" "}
{v.status === "active"
? "生效中"
: v.status === "draft"
? "草稿"
: "已归档"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshList()}>
</Button> </Button>
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}> <Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿 稿
</Button> </Button>
<Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}> <Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}>
@@ -381,7 +370,6 @@ export function RiskCapDocScreen() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm"
disabled={!isDraft || saving} disabled={!isDraft || saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])} onClick={() => setDraftRows((prev) => [...prev, newRow()])}
> >
@@ -442,7 +430,6 @@ export function RiskCapDocScreen() {
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm"
className="text-destructive" className="text-destructive"
disabled={!isDraft || saving || draftRows.length <= 1} disabled={!isDraft || saving || draftRows.length <= 1}
onClick={() => removeRow(idx)} onClick={() => removeRow(idx)}
@@ -474,13 +461,12 @@ export function RiskCapDocScreen() {
onChange={(e) => setOccSearch(e.target.value)} onChange={(e) => setOccSearch(e.target.value)}
/> />
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={() => toast.message("售罄 / 高风险筛选待接入")}> <Button type="button" variant="outline" onClick={() => toast.message("售罄 / 高风险筛选待接入")}>
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm"
onClick={() => toast.message("导出 CSV 待接入")} onClick={() => toast.message("导出 CSV 待接入")}
> >
CSV CSV
@@ -507,7 +493,7 @@ export function RiskCapDocScreen() {
<TableCell className="text-right text-muted-foreground"></TableCell> <TableCell className="text-right text-muted-foreground"></TableCell>
<TableCell className="text-center text-muted-foreground"></TableCell> <TableCell className="text-center text-muted-foreground"></TableCell>
<TableCell> <TableCell>
<Button type="button" variant="ghost" size="sm" disabled> <Button type="button" variant="ghost" disabled>
</Button> </Button>
</TableCell> </TableCell>

View File

@@ -23,11 +23,6 @@ export const configRiskCapMeta = {
description: "", description: "",
} as const; } as const;
export const configVersionsMeta = {
title: "配置版本历史",
description: "",
} as const;
export const configWalletMeta = { export const configWalletMeta = {
title: "钱包配置", title: "钱包配置",
description: "", description: "",