feat(config): 重构配置模块导航与版本切换,新增版本删除能力
This commit is contained in:
@@ -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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
77
src/modules/config/config-nav-model.ts
Normal file
77
src/modules/config/config-nav-model.ts
Normal 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;
|
||||||
|
}
|
||||||
245
src/modules/config/config-version-switcher.tsx
Normal file
245
src/modules/config/config-version-switcher.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
82
src/modules/config/config-workspace-shell.tsx
Normal file
82
src/modules/config/config-workspace-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
Reference in New Issue
Block a user