feat(config): 重构配置中心导航与版本展示,支持全量版本加载

This commit is contained in:
2026-05-16 10:28:00 +08:00
parent 8bd7cc3d73
commit 1578c7e214
15 changed files with 375 additions and 471 deletions

View File

@@ -13,6 +13,12 @@ import type {
const A = `${API_V1_PREFIX}/admin`; const A = `${API_V1_PREFIX}/admin`;
type ConfigVersionListParams = {
status?: string;
page?: number;
per_page?: number;
};
export async function getAdminPlayTypes(): Promise<AdminPlayTypesData> { export async function getAdminPlayTypes(): Promise<AdminPlayTypesData> {
return adminRequest.get<AdminPlayTypesData>(`${A}/play-types`); return adminRequest.get<AdminPlayTypesData>(`${A}/play-types`);
} }
@@ -55,10 +61,18 @@ export async function putPlayConfigItems(
id: number, id: number,
items: Array<{ items: Array<{
play_code: string; play_code: string;
category: string | null;
dimension: number | null;
bet_mode: string | null;
display_name_zh: string;
display_name_en?: string | null;
display_name_ne?: string | null;
is_enabled?: boolean; is_enabled?: boolean;
min_bet_amount: number; min_bet_amount: number;
max_bet_amount: number; max_bet_amount: number;
display_order?: number; display_order?: number;
supports_multi_number?: boolean;
reserved_rule_json?: unknown;
rule_text_zh?: string | null; rule_text_zh?: string | null;
rule_text_en?: string | null; rule_text_en?: string | null;
rule_text_ne?: string | null; rule_text_ne?: string | null;
@@ -126,6 +140,33 @@ export async function getRiskCapVersions(params?: {
return adminRequest.get(`${A}/config/risk-cap-versions`, { params }); return adminRequest.get(`${A}/config/risk-cap-versions`, { params });
} }
export async function getAllConfigVersions(
fetchPage: (params?: ConfigVersionListParams) => Promise<ConfigVersionListData>,
params?: Omit<ConfigVersionListParams, "page" | "per_page">,
): Promise<ConfigVersionListData> {
const items: ConfigVersionListData["items"] = [];
let meta: ConfigVersionListData["meta"] | null = null;
for (let page = 1; ; page += 1) {
const data = await fetchPage({ ...params, page, per_page: 100 });
items.push(...data.items);
meta = data.meta;
if (page >= data.meta.last_page) {
break;
}
}
return {
items,
meta: meta ?? {
current_page: 1,
per_page: 0,
total: 0,
last_page: 1,
},
};
}
export async function getRiskCapVersion(id: number): Promise<RiskCapVersionDetail> { export async function getRiskCapVersion(id: number): Promise<RiskCapVersionDetail> {
return adminRequest.get(`${A}/config/risk-cap-versions/${id}`); return adminRequest.get(`${A}/config/risk-cap-versions/${id}`);
} }

View File

@@ -1,64 +1,11 @@
import Link from "next/link";
import { Layers, Shield, Wrench } from "lucide-react";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
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 { redirect } from "next/navigation";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: configHubMeta.title, title: configHubMeta.title,
}; };
const GROUP_ICONS = {
betting: Layers,
risk_wallet: Shield,
ops: Wrench,
} as const;
export default function AdminConfigHubPage() { export default function AdminConfigHubPage() {
return ( redirect("/admin/config/plays");
<ModuleScaffold className="max-w-4xl">
<header className="mb-8 space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{configHubMeta.title}</h1>
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
稿 {" "}
<span className="font-medium text-foreground">active</span>
</p>
</header>
<div className="space-y-10">
{CONFIG_NAV_GROUPS.map((group) => {
const Icon = GROUP_ICONS[group.id as keyof typeof GROUP_ICONS] ?? Layers;
return (
<section key={group.id} className="space-y-4">
<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>
</ModuleScaffold>
);
} }

View File

@@ -84,7 +84,7 @@ export function AdminBreadcrumb() {
return ( return (
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList className="gap-1">
{breadcrumbs.map((crumb, index) => { {breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1; const isLast = index === breadcrumbs.length - 1;
const itemKey = `${crumb.href}-${index}`; const itemKey = `${crumb.href}-${index}`;

View File

@@ -17,15 +17,15 @@ export function AdminShell({ children }: { children: ReactNode }) {
<SidebarProvider defaultOpen> <SidebarProvider defaultOpen>
<AdminAppSidebar /> <AdminAppSidebar />
<SidebarInset className="max-md:overflow-x-hidden"> <SidebarInset className="max-md:overflow-x-hidden">
<header className="sticky top-0 z-30 flex min-h-12 items-center gap-3 border-b border-border bg-background/80 px-4 py-2 backdrop-blur-md"> <header className="sticky top-0 z-30 flex min-h-14 items-center gap-3 border-b border-border bg-background/80 pl-4 pr-4 py-2 backdrop-blur-md">
<SidebarTrigger /> <SidebarTrigger />
<Separator orientation="vertical" className="mr-2 h-4" /> <Separator orientation="vertical" className="mr-1.5 h-4" />
<AdminBreadcrumb /> <AdminBreadcrumb />
<div className="ml-auto flex shrink-0 items-center"> <div className="ml-auto flex shrink-0 items-center">
<ShellToolbar /> <ShellToolbar />
</div> </div>
</header> </header>
<div className="flex flex-1 flex-col px-6 py-6 md:px-8 md:py-8"> <div className="flex flex-1 flex-col px-6 pt-4 pb-6 md:px-8 md:pt-4 md:pb-8">
{children} {children}
</div> </div>
</SidebarInset> </SidebarInset>

View File

@@ -45,19 +45,19 @@ export function AdminAppSidebar() {
return ( return (
<Sidebar collapsible="icon" variant="inset"> <Sidebar collapsible="icon" variant="inset">
<SidebarHeader className="border-b border-sidebar-border"> <SidebarHeader className="border-b border-sidebar-border px-2 py-1.5">
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
render={<Link href={ADMIN_BASE} />} render={<Link href={ADMIN_BASE} />}
className="gap-3 px-0 hover:bg-transparent" className="gap-2 px-0 hover:bg-transparent"
> >
<SparklesIcon data-icon="inline-start" aria-hidden /> <SparklesIcon data-icon="inline-start" aria-hidden />
<div className="flex flex-col items-start gap-0.5 group-data-[collapsible=icon]:hidden"> <div className="flex flex-col items-start gap-0 group-data-[collapsible=icon]:hidden">
<span className="font-semibold tracking-tight text-sidebar-foreground"> <span className="font-semibold tracking-tight text-sidebar-foreground">
</span> </span>
<span className="text-xs text-sidebar-foreground/70"> <span className="text-[11px] leading-tight text-sidebar-foreground/70">
Lottery Admin Lottery Admin
</span> </span>
</div> </div>

View File

@@ -307,7 +307,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className className
)} )}
{...props} {...props}

View File

@@ -8,12 +8,12 @@ const LABELS: Record<string, string> = {
export function ConfigStatusBadge({ status }: { status: string }) { export function ConfigStatusBadge({ status }: { status: string }) {
const label = LABELS[status] ?? status; const label = LABELS[status] ?? status;
const variant = const className =
status === "active" ? "default" : status === "draft" ? "secondary" : "outline"; status === "active"
? "border-emerald-500/20 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300"
: status === "draft"
? "border-amber-500/20 bg-amber-500/12 text-amber-700 dark:text-amber-300"
: "border-slate-300 bg-slate-100 text-slate-600 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300";
return ( return <Badge variant="outline" className={`font-normal tabular-nums ${className}`}>{label}</Badge>;
<Badge variant={variant} className="font-normal tabular-nums">
{label}
</Badge>
);
} }

View File

@@ -3,6 +3,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -26,14 +27,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { import { cn } from "@/lib/utils";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfigStatusBadge } from "@/modules/config/config-status-badge"; import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import type { ConfigVersionSummary } from "@/types/api/admin-config"; import type { ConfigVersionSummary } from "@/types/api/admin-config";
@@ -55,6 +49,8 @@ function versionSelectLabel(v: ConfigVersionSummary): string {
return `#${v.id} · v${v.version_no} · ${versionStatusLabel(v.status)}`; return `#${v.id} · v${v.version_no} · ${versionStatusLabel(v.status)}`;
} }
const STATUS_ORDER = ["draft", "active", "archived"] as const;
export type ConfigVersionSwitcherProps = { export type ConfigVersionSwitcherProps = {
versions: ConfigVersionSummary[]; versions: ConfigVersionSummary[];
selectedId: string; selectedId: string;
@@ -90,6 +86,29 @@ export function ConfigVersionSwitcher({
[versions], [versions],
); );
const groupedVersions = useMemo(() => {
const groups = new Map<string, ConfigVersionSummary[]>();
for (const status of STATUS_ORDER) {
groups.set(status, []);
}
for (const v of sortedVersions) {
const list = groups.get(v.status) ?? [];
list.push(v);
groups.set(v.status, list);
}
return groups;
}, [sortedVersions]);
const statusCounts = useMemo(
() =>
STATUS_ORDER.map((status) => ({
status,
label: versionStatusLabel(status),
count: groupedVersions.get(status)?.length ?? 0,
})),
[groupedVersions],
);
function switchTo(id: number) { function switchTo(id: number) {
onSelectedIdChange(String(id)); onSelectedIdChange(String(id));
setSheetOpen(false); setSheetOpen(false);
@@ -139,45 +158,68 @@ export function ConfigVersionSwitcher({
</div> </div>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}> <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="right" className="sm:max-w-lg flex flex-col"> <SheetContent side="right" className="sm:max-w-2xl flex flex-col">
<SheetHeader> <SheetHeader>
<SheetTitle>{sheetTitle}</SheetTitle> <SheetTitle>{sheetTitle}</SheetTitle>
<SheetDescription>{sheetDescription}</SheetDescription> <SheetDescription>{sheetDescription}</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-auto rounded-md border mt-4"> <div className="mt-4 flex gap-2 flex-wrap">
{statusCounts.map((s) => (
<div key={s.status} className="rounded-full border border-border bg-muted/40 px-3 py-1 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{s.label}</span>
<span className="ml-1 tabular-nums">{s.count}</span>
</div>
))}
</div>
<div className="flex-1 overflow-auto mt-4 space-y-4">
{sortedVersions.length === 0 ? ( {sortedVersions.length === 0 ? (
<p className="text-sm text-muted-foreground p-4"></p> <Card className="p-4 text-sm text-muted-foreground"></Card>
) : ( ) : (
<Table> STATUS_ORDER.map((status) => {
<TableHeader> const rows = groupedVersions.get(status) ?? [];
<TableRow> if (rows.length === 0) {
<TableHead className="w-[88px]">version_no</TableHead> return null;
<TableHead className="w-[88px]"></TableHead> }
<TableHead></TableHead> return (
<TableHead className="text-right w-[180px]"></TableHead> <section key={status} className="space-y-2">
</TableRow> <div className="flex items-center justify-between px-1">
</TableHeader> <div className="flex items-center gap-2">
<TableBody> <ConfigStatusBadge status={status} />
{sortedVersions.map((v) => { <p className="text-base font-medium text-foreground">{versionStatusLabel(status)}</p>
</div>
<p className="text-sm text-muted-foreground tabular-nums">{rows.length} </p>
</div>
<div className="space-y-2">
{rows.map((v) => {
const isCurrent = selectedId === String(v.id); const isCurrent = selectedId === String(v.id);
return ( return (
<TableRow key={v.id} data-state={isCurrent ? "selected" : undefined}> <Card
<TableCell className="font-mono text-xs tabular-nums">v{v.version_no}</TableCell> key={v.id}
<TableCell> className={cn(
"border-border/70 bg-card/90 p-3 transition-colors",
isCurrent && "border-primary/30 bg-primary/5",
)}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-base tabular-nums text-foreground">v{v.version_no}</span>
<ConfigStatusBadge status={v.status} /> <ConfigStatusBadge status={v.status} />
</TableCell> <span className="text-sm text-muted-foreground">#{v.id}</span>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap"> </div>
{v.effective_at ? formatDt(v.effective_at) : "—"} <p className="text-sm text-muted-foreground">
</TableCell> {v.effective_at ? formatDt(v.effective_at) : "—"}
<TableCell className="text-right"> {v.reason ? ` · 备注:${v.reason}` : ""}
<div className="inline-flex flex-wrap items-center justify-end gap-1"> </p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button <Button
type="button" type="button"
variant={isCurrent ? "secondary" : "outline"} variant={isCurrent ? "secondary" : "outline"}
size="sm" size="sm"
onClick={() => switchTo(v.id)} onClick={() => switchTo(v.id)}
> >
{isCurrent ? "当前" : "查看"} {isCurrent ? "当前查看" : "查看"}
</Button> </Button>
{onRollbackVersion && v.status !== "draft" ? ( {onRollbackVersion && v.status !== "draft" ? (
<Button <Button
@@ -206,12 +248,14 @@ export function ConfigVersionSwitcher({
</Button> </Button>
) : null} ) : null}
</div> </div>
</TableCell> </div>
</TableRow> </Card>
); );
})} })}
</TableBody> </div>
</Table> </section>
);
})
)} )}
</div> </div>
</SheetContent> </SheetContent>

View File

@@ -1,11 +1,13 @@
"use client"; "use client";
import { useMemo } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model"; import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model";
import { configHubMeta } from "@/modules/config/meta";
function navLinkActive(pathname: string, href: string): boolean { function navLinkActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`); return pathname === href || pathname.startsWith(`${href}/`);
@@ -13,37 +15,53 @@ function navLinkActive(pathname: string, href: string): boolean {
export function ConfigWorkspaceShell({ children }: { children: ReactNode }) { export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const activeNav = useMemo(() => {
for (const group of CONFIG_NAV_GROUPS) {
for (const item of group.items) {
if (navLinkActive(pathname, item.href)) {
return { group, item };
}
}
}
return null;
}, [pathname]);
const title = activeNav?.item.title ?? configHubMeta.title;
const description = activeNav?.item.description || configHubMeta.description;
const groupLabel = activeNav?.group.label ?? "总览";
return ( return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 lg:flex-row lg:gap-8"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<aside className="shrink-0 lg:w-48 lg:border-r lg:border-border lg:pr-4"> <div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<div className="mb-2"> <aside className="shrink-0 lg:sticky lg:top-4 lg:w-56 lg:self-start">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></p> <div className="rounded-2xl border border-border/70 bg-card/80 p-3 shadow-sm backdrop-blur lg:max-h-[calc(100vh-2rem)] lg:overflow-auto">
<p className="mt-0.5 text-[11px] leading-tight text-muted-foreground"> <div className="mb-3 px-1">
稿 <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
</p> </p>
</div> </div>
<nav className="hidden lg:block space-y-4" aria-label="运营配置子导航"> <nav className="hidden space-y-3 lg:block" aria-label="运营配置子导航">
{CONFIG_NAV_GROUPS.map((group) => ( {CONFIG_NAV_GROUPS.map((group) => (
<div key={group.id}> <div key={group.id} className="space-y-1.5">
<p className="mb-1 text-[11px] font-semibold text-muted-foreground">{group.label}</p> <p className="px-2 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
<ul className="space-y-0.5"> {group.label}
</p>
<ul className="space-y-1">
{group.items.map((item) => { {group.items.map((item) => {
const active = navLinkActive(pathname, item.href); const active = navLinkActive(pathname, item.href);
return ( return (
<li key={item.href}> <li key={item.href}>
<Link <Link
href={item.href} href={item.href}
title={item.description}
className={cn( 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", "block rounded-xl border px-3 py-2.5 text-sm transition-all outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring",
active active
? "bg-primary/10 text-primary font-medium" ? "border-primary/20 bg-primary/10 text-primary shadow-sm"
: "text-foreground hover:bg-muted/80", : "border-transparent bg-transparent text-foreground hover:border-border hover:bg-muted/60",
)} )}
> >
{item.title} <div className="font-medium">{item.title}</div>
</Link> </Link>
</li> </li>
); );
@@ -74,9 +92,11 @@ export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
})} })}
</div> </div>
</div> </div>
</div>
</aside> </aside>
<div className="min-w-0 flex-1">{children}</div> <div className="min-w-0 flex-1">{children}</div>
</div> </div>
</div>
); );
} }

View File

@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { import {
deleteOddsVersion, deleteOddsVersion,
getAdminPlayTypes, getAdminPlayTypes,
getAllConfigVersions,
getOddsVersion, getOddsVersion,
getOddsVersions, getOddsVersions,
postOddsVersion, postOddsVersion,
@@ -96,7 +97,7 @@ export function OddsConfigDocScreen() {
setLoadingList(true); setLoadingList(true);
setError(null); setError(null);
try { try {
const d = await getOddsVersions({ per_page: 50 }); const d = await getAllConfigVersions(getOddsVersions);
setList(d.items); setList(d.items);
} catch (e) { } catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败"; const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
@@ -349,28 +350,25 @@ export function OddsConfigDocScreen() {
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<span className="text-sm text-muted-foreground self-center mr-2"></span> <span className="text-base text-muted-foreground self-center mr-2"></span>
{catTabs.map((t) => ( {catTabs.map((t) => (
<Button <Button
key={t.id} key={t.id}
type="button" type="button"
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={() => setCatTab(t.id)}
setCatTab(t.id);
setPlayCode("");
}}
> >
{t.label} {t.label}
</Button> </Button>
))} ))}
</div> </div>
<div className="space-y-2"> <div className="space-y-2 min-h-[96px]">
<p className="text-sm text-muted-foreground"></p> <p className="text-base text-muted-foreground"></p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2 min-h-[44px]">
{filteredTypes.length === 0 ? ( {filteredTypes.length === 0 ? (
<span className="text-sm text-muted-foreground"></span> <span className="text-base text-muted-foreground"></span>
) : ( ) : (
filteredTypes.map((t) => ( filteredTypes.map((t) => (
<Button <Button
@@ -438,9 +436,11 @@ export function OddsConfigDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null} {error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail || loadingTypes ? ( {loadingDetail || loadingTypes ? (
<p className="text-sm text-muted-foreground"></p> <div className="flex min-h-[420px] items-center">
<p className="text-base text-muted-foreground"></p>
</div>
) : resolvedPlayCode ? ( ) : resolvedPlayCode ? (
<div className="grid gap-4 max-w-md"> <div className="grid min-h-[420px] gap-4 max-w-md">
{PRIZE_SCOPE_ORDER.map((scope) => { {PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope]; const row = scopeRows[scope];
const hint = PRIZE_SCOPE_MULTIPLIER_HINT[scope]; const hint = PRIZE_SCOPE_MULTIPLIER_HINT[scope];
@@ -449,7 +449,7 @@ export function OddsConfigDocScreen() {
<div key={scope} className="grid gap-1"> <div key={scope} className="grid gap-1">
<Label className="flex items-baseline gap-2"> <Label className="flex items-baseline gap-2">
{PRIZE_SCOPE_LABELS[scope]} {PRIZE_SCOPE_LABELS[scope]}
{hint ? <span className="text-xs text-muted-foreground font-normal">{hint}</span> : null} {hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null}
</Label> </Label>
{row && idx >= 0 ? ( {row && idx >= 0 ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -465,12 +465,12 @@ export function OddsConfigDocScreen() {
}) })
} }
/> />
<span className="text-xs text-muted-foreground tabular-nums"> <span className="text-sm text-muted-foreground tabular-nums">
×{oddsMultiplierLabel(row.odds_value)} · {row.currency_code} ×{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
</span> </span>
</div> </div>
) : ( ) : (
<p className="text-xs text-destructive"> {scope} </p> <p className="text-sm text-destructive"> {scope} </p>
)} )}
</div> </div>
); );
@@ -486,7 +486,7 @@ export function OddsConfigDocScreen() {
value={rebatePercentUi} value={rebatePercentUi}
onChange={(e) => setRebateForPlayPercent(e.target.value)} onChange={(e) => setRebateForPlayPercent(e.target.value)}
/> />
<p className="text-xs text-muted-foreground"> rebate_rate</p> <p className="text-sm text-muted-foreground"> rebate_rate</p>
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -5,10 +5,9 @@ import { toast } from "sonner";
import { import {
deletePlayConfigVersion, deletePlayConfigVersion,
getAdminPlayTypes, getAllConfigVersions,
getPlayConfigVersion, getPlayConfigVersion,
getPlayConfigVersions, getPlayConfigVersions,
patchAdminPlayType,
postPlayConfigVersion, postPlayConfigVersion,
publishPlayConfigVersion, publishPlayConfigVersion,
putPlayConfigItems, putPlayConfigItems,
@@ -37,105 +36,77 @@ import {
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"; 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,
ConfigVersionSummary, ConfigVersionSummary,
PlayConfigItemRow, PlayConfigItemRow,
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 = { type PlayConfigSaveItemPayload = {
play_code: string; play_code: string;
category: string;
dimension: number | null;
bet_mode: string | null;
display_name_zh: string;
display_name_en: string | null;
display_name_ne: string | null;
is_enabled: boolean; is_enabled: boolean;
min_bet_amount: number; min_bet_amount: number;
max_bet_amount: number; max_bet_amount: number;
display_order: number; display_order: number;
supports_multi_number: boolean;
reserved_rule_json: unknown;
rule_text_zh: string | null; rule_text_zh: string | null;
rule_text_en: string | null; rule_text_en: string | null;
rule_text_ne: string | null; rule_text_ne: string | null;
extra_config_json: unknown; extra_config_json: unknown;
}; };
/** 与「玩法目录」对齐的完整列表,避免保存草稿时用残缺数组覆盖后端导致其它玩法配置被删。 */ /** 版本草稿保存 payload直接按当前草稿快照落库。 */
function buildPlayConfigSavePayload( function buildPlayConfigSavePayload(
typeRows: AdminPlayTypeRow[],
draftRows: PlayConfigItemRow[], draftRows: PlayConfigItemRow[],
): PlayConfigSaveItemPayload[] { ): PlayConfigSaveItemPayload[] {
const byCode = new Map(draftRows.map((r) => [r.play_code, r])); return [...draftRows]
const sorted = [...typeRows].sort( .sort((a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code))
(a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code), .map((row) => ({
);
return sorted.map((t) => {
const row = byCode.get(t.play_code);
if (row) {
return {
play_code: row.play_code, play_code: row.play_code,
category: row.category ?? "",
dimension: row.dimension,
bet_mode: row.bet_mode,
display_name_zh: row.display_name_zh ?? row.play_code,
display_name_en: row.display_name_en ?? null,
display_name_ne: row.display_name_ne ?? null,
is_enabled: row.is_enabled, is_enabled: row.is_enabled,
min_bet_amount: row.min_bet_amount, min_bet_amount: row.min_bet_amount,
max_bet_amount: row.max_bet_amount, max_bet_amount: row.max_bet_amount,
display_order: row.display_order, display_order: row.display_order,
supports_multi_number: row.supports_multi_number,
reserved_rule_json: row.reserved_rule_json,
rule_text_zh: row.rule_text_zh, rule_text_zh: row.rule_text_zh,
rule_text_en: row.rule_text_en, rule_text_en: row.rule_text_en,
rule_text_ne: row.rule_text_ne, rule_text_ne: row.rule_text_ne,
extra_config_json: row.extra_config_json, 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 [list, setList] = useState<ConfigVersionSummary[]>([]); const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState(""); const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null); const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<PlayConfigItemRow[]>([]); const [draftRows, setDraftRows] = useState<PlayConfigItemRow[]>([]);
const [loadingTypes, setLoadingTypes] = useState(true);
const [loadingList, setLoadingList] = useState(true); const [loadingList, setLoadingList] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingToggle, setPendingToggle] = useState<{
play_code: string;
next: boolean;
} | null>(null);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [rulePlayCode, setRulePlayCode] = useState<string | null>(null); const [rulePlayCode, setRulePlayCode] = useState<string | null>(null);
const [ruleDraftZh, setRuleDraftZh] = useState(""); const [ruleDraftZh, setRuleDraftZh] = useState("");
const refreshTypes = useCallback(async () => {
setLoadingTypes(true);
try {
const d = await getAdminPlayTypes();
setTypes([...d.items].sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载玩法目录失败");
setTypes([]);
} finally {
setLoadingTypes(false);
}
}, []);
const refreshList = useCallback(async () => { const refreshList = useCallback(async () => {
setLoadingList(true); setLoadingList(true);
setError(null); setError(null);
try { try {
const d = await getPlayConfigVersions({ per_page: 50 }); const d = await getAllConfigVersions(getPlayConfigVersions);
setList(d.items); setList(d.items);
} catch (e) { } catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败"; const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
@@ -148,10 +119,9 @@ export function PlayConfigDocScreen() {
useEffect(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {
void refreshTypes();
void refreshList(); void refreshList();
}); });
}, [refreshTypes, refreshList]); }, [refreshList]);
const loadDetail = useCallback(async (id: number) => { const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true); setLoadingDetail(true);
@@ -197,96 +167,23 @@ export function PlayConfigDocScreen() {
const isDraft = detail?.status === "draft"; const isDraft = detail?.status === "draft";
const itemsByCode = useMemo(() => { const orderedRows = useMemo(
const m = new Map<string, PlayConfigItemRow>(); () =>
for (const r of draftRows) { [...draftRows].sort(
m.set(r.play_code, r); (a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code),
} ),
return m; [draftRows],
}, [draftRows]); );
const mergedRows = useMemo(() => {
return types.map((t) => ({
type: t,
item: itemsByCode.get(t.play_code) ?? null,
}));
}, [types, itemsByCode]);
function draftRowIndex(playCode: string): number {
return draftRows.findIndex((r) => r.play_code === playCode);
}
function updateConfigRow(playCode: string, patch: Partial<PlayConfigItemRow>) { function updateConfigRow(playCode: string, patch: Partial<PlayConfigItemRow>) {
const idx = draftRowIndex(playCode); setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
if (idx < 0) {
return;
}
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
async function patchTypeField(
playCode: string,
body: Partial<{ display_name_zh: string | null; sort_order: number }>,
) {
try {
const updated = await patchAdminPlayType(playCode, body);
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),
),
);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "更新玩法目录失败");
void refreshTypes();
}
}
function openToggleConfirm(play_code: string, next: boolean) {
setPendingToggle({ play_code, next });
setConfirmOpen(true);
}
async function applyToggle() {
if (!pendingToggle || !detail) {
return;
}
const { play_code, next } = pendingToggle;
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),
),
);
if (isDraft) {
updateConfigRow(play_code, { is_enabled: next });
const idx = draftRowIndex(play_code);
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 })));
}
toast.success(`${next ? "启用" : "禁用"}${play_code}`);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "更新失败");
} finally {
setSaving(false);
setConfirmOpen(false);
setPendingToggle(null);
}
} }
async function handleSaveDraft() { async function handleSaveDraft() {
if (!detail || !isDraft) { if (!detail || !isDraft) {
return; return;
} }
const payload = buildPlayConfigSavePayload(types, draftRows); const payload = buildPlayConfigSavePayload(draftRows);
for (const r of payload) { 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}: 最小额不能大于最大额`);
@@ -347,7 +244,7 @@ export function PlayConfigDocScreen() {
} }
function openRuleEditor(play_code: string) { function openRuleEditor(play_code: string) {
const item = itemsByCode.get(play_code); const item = draftRows.find((row) => row.play_code === play_code);
setRulePlayCode(play_code); setRulePlayCode(play_code);
setRuleDraftZh(item?.rule_text_zh ?? ""); setRuleDraftZh(item?.rule_text_zh ?? "");
setRuleDialogOpen(true); setRuleDialogOpen(true);
@@ -382,16 +279,6 @@ export function PlayConfigDocScreen() {
<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 <ConfigVersionSwitcher
versions={list} versions={list}
selectedId={selectedId} selectedId={selectedId}
@@ -405,9 +292,6 @@ export function PlayConfigDocScreen() {
<Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loadingList}> <Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loadingList}>
</Button> </Button>
<Button type="button" variant="secondary" onClick={() => void refreshTypes()} disabled={loadingTypes}>
</Button>
<Button type="button" onClick={() => void handleNewDraft()} disabled={saving}> <Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿 稿
</Button> </Button>
@@ -446,7 +330,7 @@ export function PlayConfigDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null} {error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail || loadingTypes ? ( {loadingDetail ? (
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground"></p>
) : ( ) : (
<div className="overflow-x-auto rounded-md border"> <div className="overflow-x-auto rounded-md border">
@@ -464,32 +348,28 @@ export function PlayConfigDocScreen() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{mergedRows.map(({ type: t, item }) => ( {orderedRows.map((row) => (
<TableRow key={t.play_code}> <TableRow key={row.play_code}>
<TableCell className="font-mono text-sm">{t.play_code}</TableCell> <TableCell className="font-mono text-sm">{row.play_code}</TableCell>
<TableCell className="text-muted-foreground text-xs">{t.category}</TableCell> <TableCell className="text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Checkbox <Checkbox
checked={t.is_enabled} checked={row.is_enabled}
disabled={saving} disabled={saving}
onCheckedChange={(v) => { onCheckedChange={(v) => {
openToggleConfirm(t.play_code, v === true); updateConfigRow(row.play_code, { is_enabled: v === true });
}} }}
aria-label={`启用 ${t.play_code}`} aria-label={`启用 ${row.play_code}`}
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Input <Input
className="h-8 text-sm" className="h-8 text-sm"
defaultValue={t.display_name_zh ?? ""} value={row.display_name_zh ?? ""}
key={`${t.play_code}-dn-${t.updated_at}`}
disabled={saving} disabled={saving}
onBlur={(e) => { onChange={(e) => {
const v = e.target.value.trim(); const next = e.target.value === "" ? null : e.target.value;
const next = v || null; updateConfigRow(row.play_code, { display_name_zh: next });
if (next !== (t.display_name_zh ?? null)) {
void patchTypeField(t.play_code, { display_name_zh: next });
}
}} }}
/> />
</TableCell> </TableCell>
@@ -497,57 +377,50 @@ export function PlayConfigDocScreen() {
<Input <Input
type="number" type="number"
className="h-8 w-full font-mono tabular-nums text-right" className="h-8 w-full font-mono tabular-nums text-right"
defaultValue={t.sort_order} value={row.display_order}
key={`${t.play_code}-so-${t.updated_at}`}
disabled={saving} disabled={saving}
onBlur={(e) => { onChange={(e) => {
const n = Number.parseInt(e.target.value, 10); const n = Number.parseInt(e.target.value, 10);
if (Number.isFinite(n) && n !== t.sort_order) { if (Number.isFinite(n)) {
void patchTypeField(t.play_code, { sort_order: n }); updateConfigRow(row.play_code, { display_order: n });
} }
}} }}
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
{item ? (
<Input <Input
type="number" type="number"
min={0} min={0}
className="h-8 font-mono tabular-nums" className="h-8 font-mono tabular-nums"
disabled={!isDraft || saving} disabled={!isDraft || saving}
value={item.min_bet_amount} value={row.min_bet_amount}
onChange={(e) => onChange={(e) =>
updateConfigRow(t.play_code, { updateConfigRow(row.play_code, {
min_bet_amount: Number.parseInt(e.target.value, 10) || 0, min_bet_amount: Number.parseInt(e.target.value, 10) || 0,
}) })
} }
/> />
) : (
<span className="text-xs text-destructive"></span>
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{item ? (
<Input <Input
type="number" type="number"
min={0} min={0}
className="h-8 font-mono tabular-nums" className="h-8 font-mono tabular-nums"
disabled={!isDraft || saving} disabled={!isDraft || saving}
value={item.max_bet_amount} value={row.max_bet_amount}
onChange={(e) => onChange={(e) =>
updateConfigRow(t.play_code, { updateConfigRow(row.play_code, {
max_bet_amount: Number.parseInt(e.target.value, 10) || 0, max_bet_amount: Number.parseInt(e.target.value, 10) || 0,
}) })
} }
/> />
) : null}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={!item || !isDraft || saving} disabled={!isDraft || saving}
onClick={() => openRuleEditor(t.play_code)} onClick={() => openRuleEditor(row.play_code)}
> >
</Button> </Button>
@@ -560,37 +433,6 @@ export function PlayConfigDocScreen() {
)} )}
</CardContent> </CardContent>
<Dialog
open={confirmOpen}
onOpenChange={(open) => {
setConfirmOpen(open);
if (!open) {
setPendingToggle(null);
}
}}
>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{pendingToggle
? `确定要${pendingToggle.next ? "启用" : "禁用"}玩法「${pendingToggle.play_code}」吗?将同步更新玩法目录与${
isDraft ? "当前草稿" : "(非草稿时仅更新目录,配置明细请在草稿中维护)"
}`
: null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setConfirmOpen(false)}>
</Button>
<Button type="button" onClick={() => void applyToggle()} disabled={!pendingToggle || saving}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}> <Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
<DialogContent showCloseButton className="sm:max-w-lg"> <DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader> <DialogHeader>

View File

@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { import {
deleteOddsVersion, deleteOddsVersion,
getAdminPlayTypes, getAdminPlayTypes,
getAllConfigVersions,
getOddsVersion, getOddsVersion,
getOddsVersions, getOddsVersions,
postOddsVersion, postOddsVersion,
@@ -72,7 +73,7 @@ export function RebateConfigDocScreen() {
const refreshList = useCallback(async () => { const refreshList = useCallback(async () => {
try { try {
const d = await getOddsVersions({ per_page: 50 }); const d = await getAllConfigVersions(getOddsVersions);
setListRows(d.items); setListRows(d.items);
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本失败"); toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本失败");
@@ -345,7 +346,7 @@ export function RebateConfigDocScreen() {
<Label htmlFor="win-enjoy" className="font-medium leading-snug"> <Label htmlFor="win-enjoy" className="font-medium leading-snug">
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-sm text-muted-foreground">
/ /
</p> </p>
</div> </div>
@@ -353,7 +354,7 @@ export function RebateConfigDocScreen() {
<div className="grid gap-1 text-sm"> <div className="grid gap-1 text-sm">
<span className="text-muted-foreground">线</span> <span className="text-muted-foreground">线</span>
<span className="font-mono text-xs"> <span className="font-mono text-sm">
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"} {activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
</span> </span>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { import {
deleteRiskCapVersion, deleteRiskCapVersion,
getAllConfigVersions,
getRiskCapVersion, getRiskCapVersion,
getRiskCapVersions, getRiskCapVersions,
postRiskCapVersion, postRiskCapVersion,
@@ -72,7 +73,7 @@ export function RiskCapDocScreen() {
setLoadingList(true); setLoadingList(true);
setError(null); setError(null);
try { try {
const d = await getRiskCapVersions({ per_page: 50 }); const d = await getAllConfigVersions(getRiskCapVersions);
setList(d.items); setList(d.items);
} catch (e) { } catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败"; const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
@@ -342,7 +343,7 @@ export function RiskCapDocScreen() {
<section className="space-y-3 rounded-lg border bg-muted/20 p-4"> <section className="space-y-3 rounded-lg border bg-muted/20 p-4">
<h3 className="text-sm font-medium"></h3> <h3 className="text-sm font-medium"></h3>
<p className="text-xs text-muted-foreground"> <p className="text-sm text-muted-foreground">
稿<strong></strong> 稿<strong></strong>
</p> </p>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
@@ -447,7 +448,7 @@ export function RiskCapDocScreen() {
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-sm font-medium"></h3> <h3 className="text-sm font-medium"></h3>
<p className="text-xs text-muted-foreground"> <p className="text-sm text-muted-foreground">
稿 稿
</p> </p>
<div className="flex flex-wrap gap-3 items-end"> <div className="flex flex-wrap gap-3 items-end">

View File

@@ -1,29 +1,29 @@
export const configHubMeta = { export const configHubMeta = {
title: "配置中心", title: "配置中心",
description: "", description: "统一管理玩法目录、赔率、回水和风险封顶,先草稿、后发布、再生效。",
} as const; } as const;
export const configPlayConfigMeta = { export const configPlayConfigMeta = {
title: "玩法配置", title: "玩法配置",
description: "", description: "维护玩法开关、限额和规则文案,目录变更会直接影响下注入口。",
} as const; } as const;
export const configOddsMeta = { export const configOddsMeta = {
title: "赔率配置", title: "赔率配置",
description: "", description: "维护赔率、返水和佣金,发布前请重点核对数值范围与币种。",
} as const; } as const;
export const configRebateMeta = { export const configRebateMeta = {
title: "佣金 / 回水", title: "佣金 / 回水",
description: "", description: "从赔率草稿中批量调整回水比例,适合按玩法维度统一修正。",
} as const; } as const;
export const configRiskCapMeta = { export const configRiskCapMeta = {
title: "风控封顶", title: "风控封顶",
description: "", description: "管理号码封顶版本和风险池阈值,发布前先确认号码与期号。",
} as const; } as const;
export const configWalletMeta = { export const configWalletMeta = {
title: "钱包配置", title: "钱包配置",
description: "", description: "维护钱包相关阈值与转账策略。",
} as const; } as const;

View File

@@ -40,10 +40,18 @@ export type ConfigVersionSummary = {
export type PlayConfigItemRow = { export type PlayConfigItemRow = {
id: number; id: number;
play_code: string; play_code: string;
category: string | null;
dimension: number | null;
bet_mode: string | null;
display_name_zh: string | null;
display_name_en: string | null;
display_name_ne: string | null;
is_enabled: boolean; is_enabled: boolean;
min_bet_amount: number; min_bet_amount: number;
max_bet_amount: number; max_bet_amount: number;
display_order: number; display_order: number;
supports_multi_number: boolean;
reserved_rule_json: unknown;
rule_text_zh: string | null; rule_text_zh: string | null;
rule_text_en: string | null; rule_text_en: string | null;
rule_text_ne: string | null; rule_text_ne: string | null;