diff --git a/src/api/admin-config.ts b/src/api/admin-config.ts index c3544db..426912d 100644 --- a/src/api/admin-config.ts +++ b/src/api/admin-config.ts @@ -72,6 +72,10 @@ export async function publishPlayConfigVersion(id: number): Promise { + return adminRequest.delete(`${A}/config/play-versions/${id}`); +} + export async function getOddsVersions(params?: { status?: string; page?: number; @@ -110,6 +114,10 @@ export async function publishOddsVersion(id: number): Promise 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?: { status?: string; page?: number; @@ -144,3 +152,7 @@ export async function putRiskCapItems( export async function publishRiskCapVersion(id: number): Promise { 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}`); +} diff --git a/src/app/admin/(shell)/config/layout.tsx b/src/app/admin/(shell)/config/layout.tsx index 086a4c7..9e0762b 100644 --- a/src/app/admin/(shell)/config/layout.tsx +++ b/src/app/admin/(shell)/config/layout.tsx @@ -1,14 +1,7 @@ -import { ConfigSubNav } from "@/modules/config/config-subnav"; +import type { ReactNode } from "react"; -export default function AdminConfigLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
- - {children} -
- ); +import { ConfigWorkspaceShell } from "@/modules/config/config-workspace-shell"; + +export default function AdminConfigLayout({ children }: { children: ReactNode }) { + return {children}; } diff --git a/src/app/admin/(shell)/config/odds/page.tsx b/src/app/admin/(shell)/config/odds/page.tsx index 532ac54..830b2d0 100644 --- a/src/app/admin/(shell)/config/odds/page.tsx +++ b/src/app/admin/(shell)/config/odds/page.tsx @@ -1,4 +1,3 @@ -import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen"; import { configOddsMeta } from "@/modules/config/meta"; import type { Metadata } from "next"; @@ -8,9 +7,5 @@ export const metadata: Metadata = { }; export default function AdminConfigOddsPage() { - return ( - - - - ); + return ; } diff --git a/src/app/admin/(shell)/config/page.tsx b/src/app/admin/(shell)/config/page.tsx index 641ae64..af95f80 100644 --- a/src/app/admin/(shell)/config/page.tsx +++ b/src/app/admin/(shell)/config/page.tsx @@ -1,7 +1,9 @@ import Link from "next/link"; +import { Layers, Shield, Wrench } from "lucide-react"; 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 type { Metadata } from "next"; @@ -9,33 +11,53 @@ export const metadata: Metadata = { title: configHubMeta.title, }; -const SECTIONS = [ - { href: "/admin/config/plays", title: "玩法配置" }, - { href: "/admin/config/odds", title: "赔率配置" }, - { href: "/admin/config/rebate", title: "佣金 / 回水" }, - { href: "/admin/config/risk-cap", title: "风控封顶" }, - { href: "/admin/config/versions", title: "配置版本历史" }, - { href: "/admin/config/wallet", title: "钱包配置" }, -] as const; +const GROUP_ICONS = { + betting: Layers, + risk_wallet: Shield, + ops: Wrench, +} as const; export default function AdminConfigHubPage() { return ( -

{configHubMeta.title}

-
- {SECTIONS.map((s) => ( - - - - {s.title} - - - - ))} +
+

{configHubMeta.title}

+

+ 玩法限额、赔率、封顶三套配置各自有「草稿 → 发布」生命周期;玩家端与大厅接口只读取当前{" "} + active 版本。请先在本区左侧选择模块,改完后务必在对应页点击「启用为当前版本」。 +

+
+ +
+ {CONFIG_NAV_GROUPS.map((group) => { + const Icon = GROUP_ICONS[group.id as keyof typeof GROUP_ICONS] ?? Layers; + return ( +
+
+ +

+ {group.label} +

+
+
+ {group.items.map((item) => ( + + + + {item.title} + {item.description} + + + + ))} +
+
+ ); + })}
); diff --git a/src/app/admin/(shell)/config/plays/page.tsx b/src/app/admin/(shell)/config/plays/page.tsx index c266ee9..e2d9627 100644 --- a/src/app/admin/(shell)/config/plays/page.tsx +++ b/src/app/admin/(shell)/config/plays/page.tsx @@ -1,4 +1,3 @@ -import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { PlayConfigDocScreen } from "@/modules/config/doc/play-config-doc-screen"; import { configPlayConfigMeta } from "@/modules/config/meta"; import type { Metadata } from "next"; @@ -8,9 +7,5 @@ export const metadata: Metadata = { }; export default function AdminConfigPlaysPage() { - return ( - - - - ); + return ; } diff --git a/src/app/admin/(shell)/config/rebate/page.tsx b/src/app/admin/(shell)/config/rebate/page.tsx index a7c0b74..7bfe5f3 100644 --- a/src/app/admin/(shell)/config/rebate/page.tsx +++ b/src/app/admin/(shell)/config/rebate/page.tsx @@ -1,4 +1,3 @@ -import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen"; import { configRebateMeta } from "@/modules/config/meta"; import type { Metadata } from "next"; @@ -8,9 +7,5 @@ export const metadata: Metadata = { }; export default function AdminConfigRebateDedicatedPage() { - return ( - - - - ); + return ; } diff --git a/src/app/admin/(shell)/config/risk-cap/page.tsx b/src/app/admin/(shell)/config/risk-cap/page.tsx index 10cb08c..d1520f2 100644 --- a/src/app/admin/(shell)/config/risk-cap/page.tsx +++ b/src/app/admin/(shell)/config/risk-cap/page.tsx @@ -1,4 +1,3 @@ -import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { configRiskCapMeta } from "@/modules/config/meta"; import { RiskCapDocScreen } from "@/modules/config/doc/risk-cap-doc-screen"; import type { Metadata } from "next"; @@ -8,9 +7,5 @@ export const metadata: Metadata = { }; export default function AdminConfigRiskCapPage() { - return ( - - - - ); + return ; } diff --git a/src/app/admin/(shell)/config/versions/page.tsx b/src/app/admin/(shell)/config/versions/page.tsx deleted file mode 100644 index a7fcc0c..0000000 --- a/src/app/admin/(shell)/config/versions/page.tsx +++ /dev/null @@ -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 ( - - - - ); -} diff --git a/src/app/admin/(shell)/config/wallet/page.tsx b/src/app/admin/(shell)/config/wallet/page.tsx index 32d3e3c..50b516a 100644 --- a/src/app/admin/(shell)/config/wallet/page.tsx +++ b/src/app/admin/(shell)/config/wallet/page.tsx @@ -1,4 +1,3 @@ -import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { configWalletMeta } from "@/modules/config/meta"; import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen"; import type { Metadata } from "next"; @@ -8,9 +7,5 @@ export const metadata: Metadata = { }; export default function AdminConfigWalletPage() { - return ( - - - - ); + return ; } diff --git a/src/components/admin/admin-breadcrumb.tsx b/src/components/admin/admin-breadcrumb.tsx index 356d5a9..0a7c8e9 100644 --- a/src/components/admin/admin-breadcrumb.tsx +++ b/src/components/admin/admin-breadcrumb.tsx @@ -11,6 +11,7 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav"; +import { CONFIG_ROUTE_LABELS } from "@/modules/config/config-nav-model"; import React from "react"; const DRAW_ROUTE_LABELS: Record = { @@ -26,6 +27,12 @@ function titleCase(value: string): string { .join(" "); } +type BreadcrumbCrumb = { + label: string; + href: string; + isCurrent: boolean; +}; + export function AdminBreadcrumb() { const pathname = usePathname(); @@ -33,7 +40,7 @@ export function AdminBreadcrumb() { const segments = pathname.split("/").filter(Boolean); // 基础面包屑:首页/仪表盘 - const breadcrumbs = [ + const breadcrumbs: BreadcrumbCrumb[] = [ { label: "首页", href: ADMIN_BASE, @@ -58,7 +65,12 @@ export function AdminBreadcrumb() { if (segments.length > 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) { breadcrumbs.push({ label: subLabel, diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index e2a75a6..04f358e 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -27,8 +27,8 @@ import { PanelLeftIcon } from "lucide-react" const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = "16rem" -const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH = "12rem" +const SIDEBAR_WIDTH_MOBILE = "14rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" diff --git a/src/modules/config/config-nav-model.ts b/src/modules/config/config-nav-model.ts new file mode 100644 index 0000000..e65d116 --- /dev/null +++ b/src/modules/config/config-nav-model.ts @@ -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> = Object.fromEntries( + CONFIG_ROUTE_LABEL_ENTRIES, +) as Readonly>; + +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; +} diff --git a/src/modules/config/config-version-switcher.tsx b/src/modules/config/config-version-switcher.tsx new file mode 100644 index 0000000..0524fae --- /dev/null +++ b/src/modules/config/config-version-switcher.tsx @@ -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; + 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(null); + const [deletingId, setDeletingId] = useState(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 ( + <> +
+
+ + +
+ +
+ + + + + {sheetTitle} + {sheetDescription} + +
+ {sortedVersions.length === 0 ? ( +

暂无版本记录。

+ ) : ( + + + + version_no + 状态 + 生效时间 + 操作 + + + + {sortedVersions.map((v) => { + const isCurrent = selectedId === String(v.id); + return ( + + v{v.version_no} + + + + + {v.effective_at ? formatDt(v.effective_at) : "—"} + + +
+ + {onRollbackVersion && v.status !== "draft" ? ( + + ) : null} + {onDeleteVersion && v.status !== "active" ? ( + + ) : null} +
+
+
+ ); + })} +
+
+ )} +
+
+
+ + !open && setDeleteTarget(null)}> + + + 确认删除版本? + + 将永久删除版本 ID {deleteTarget?.id}(version_no {deleteTarget?.version_no})。生效中的版本不可删除。 + + + + + + + + + + ); +} diff --git a/src/modules/config/config-versions-console.tsx b/src/modules/config/config-versions-console.tsx deleted file mode 100644 index 9ec0105..0000000 --- a/src/modules/config/config-versions-console.tsx +++ /dev/null @@ -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

加载中…

; - } - if (rows.length === 0) { - return

暂无版本记录。

; - } - return ( -
- - - - ID - version_no - 状态 - 生效时间 - 备注 - - - - {rows.map((v) => ( - - {v.id} - {v.version_no} - - - - - {v.effective_at ? formatDt(v.effective_at) : "—"} - - - {v.reason ?? "—"} - - - ))} - -
-
- ); -} - -export function ConfigVersionsConsole() { - const formatDt = useAdminDateTimeFormatter(); - const [playRows, setPlayRows] = useState([]); - const [oddsRows, setOddsRows] = useState([]); - const [riskRows, setRiskRows] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( - - - 配置版本历史 - - - {error ?

{error}

: null} - - - 玩法配置 - 赔率 - 风控封顶 - - - - - - - - - - - -
-
- ); -} diff --git a/src/modules/config/config-workspace-shell.tsx b/src/modules/config/config-workspace-shell.tsx new file mode 100644 index 0000000..2de8d13 --- /dev/null +++ b/src/modules/config/config-workspace-shell.tsx @@ -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 ( +
+ + +
{children}
+
+ ); +} diff --git a/src/modules/config/doc/odds-config-doc-screen.tsx b/src/modules/config/doc/odds-config-doc-screen.tsx index 3cff569..6dc6add 100644 --- a/src/modules/config/doc/odds-config-doc-screen.tsx +++ b/src/modules/config/doc/odds-config-doc-screen.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { + deleteOddsVersion, getAdminPlayTypes, getOddsVersion, getOddsVersions, @@ -23,21 +24,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"; import { cn } from "@/lib/utils"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -91,7 +78,6 @@ export function OddsConfigDocScreen() { const [rollbackOpen, setRollbackOpen] = useState(false); const [rollbackTarget, setRollbackTarget] = useState(null); - const [historyOpen, setHistoryOpen] = useState(false); const refreshTypes = useCallback(async () => { setLoadingTypes(true); @@ -332,6 +318,22 @@ export function OddsConfigDocScreen() { 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 }[] = [ { id: "all", label: "全部" }, { id: "d4", label: "4D" }, @@ -340,8 +342,6 @@ export function OddsConfigDocScreen() { { id: "jackpot", label: "Jackpot" }, ]; - const sortedHistory = useMemo(() => [...list].sort((a, b) => b.id - a.id), [list]); - return ( @@ -354,7 +354,6 @@ export function OddsConfigDocScreen() {
+ +
-
@@ -483,59 +492,6 @@ export function OddsConfigDocScreen() { ) : null}
- - - - - 赔率版本历史 - 选择一条历史版本执行回滚(克隆为新草稿)。 - -
- - - - 版本 - 状态 - 时间 - 操作 - - - - {sortedHistory.map((v) => ( - - v{v.version_no} - - {v.status === "active" ? "生效" : v.status === "draft" ? "草稿" : "归档"} - - - {v.updated_at ? formatDt(v.updated_at) : "—"} - - - - - - ))} - -
-
-
-
- diff --git a/src/modules/config/doc/play-config-doc-screen.tsx b/src/modules/config/doc/play-config-doc-screen.tsx index 85cd417..e101de5 100644 --- a/src/modules/config/doc/play-config-doc-screen.tsx +++ b/src/modules/config/doc/play-config-doc-screen.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { + deletePlayConfigVersion, getAdminPlayTypes, getPlayConfigVersion, getPlayConfigVersions, @@ -33,6 +34,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminPlayTypeRow, @@ -41,6 +43,59 @@ import type { PlayConfigVersionDetail, } 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() { const [types, setTypes] = useState([]); const [list, setList] = useState([]); @@ -199,6 +254,7 @@ export function PlayConfigDocScreen() { setSaving(true); try { const updated = await patchAdminPlayType(play_code, { is_enabled: next }); + const typesForPayload = types.map((r) => (r.play_code === updated.play_code ? updated : r)); setTypes((prev) => [...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), @@ -207,20 +263,11 @@ export function PlayConfigDocScreen() { if (isDraft) { updateConfigRow(play_code, { is_enabled: next }); const idx = draftRowIndex(play_code); - const nextRows = draftRows.map((r, i) => - i === idx ? { ...r, is_enabled: next } : r, - ); - const payload = nextRows.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 rowsForMerge = + idx >= 0 + ? draftRows.map((r, i) => (i === idx ? { ...r, is_enabled: next } : r)) + : draftRows; + const payload = buildPlayConfigSavePayload(typesForPayload, rowsForMerge); const d = await putPlayConfigItems(detail.id, payload); setDetail(d); setDraftRows(d.items.map((it) => ({ ...it }))); @@ -239,7 +286,8 @@ export function PlayConfigDocScreen() { if (!detail || !isDraft) { 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) { toast.error(`${r.play_code}: 最小额不能大于最大额`); return; @@ -247,17 +295,6 @@ export function PlayConfigDocScreen() { } setSaving(true); 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); setDetail(d); setDraftRows(d.items.map((it) => ({ ...it }))); @@ -328,20 +365,50 @@ export function PlayConfigDocScreen() { 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 ( 玩法配置 +
+

玩家端如何生效

+

+ 只有状态为「生效中」的版本会进入{" "} + GET /api/v1/play/effective{" "} + ;草稿需先「保存草稿」再点「启用为当前版本」。保存时会按左侧玩法目录自动补全 + 缺失的配置行,避免误删其它玩法。 +

+
+ + +
- - - - -
- diff --git a/src/modules/config/meta.ts b/src/modules/config/meta.ts index ba33d21..66b8730 100644 --- a/src/modules/config/meta.ts +++ b/src/modules/config/meta.ts @@ -23,11 +23,6 @@ export const configRiskCapMeta = { description: "", } as const; -export const configVersionsMeta = { - title: "配置版本历史", - description: "", -} as const; - export const configWalletMeta = { title: "钱包配置", description: "",