feat(config): 重构配置中心导航与版本展示,支持全量版本加载
This commit is contained in:
@@ -13,6 +13,12 @@ import type {
|
||||
|
||||
const A = `${API_V1_PREFIX}/admin`;
|
||||
|
||||
type ConfigVersionListParams = {
|
||||
status?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
};
|
||||
|
||||
export async function getAdminPlayTypes(): Promise<AdminPlayTypesData> {
|
||||
return adminRequest.get<AdminPlayTypesData>(`${A}/play-types`);
|
||||
}
|
||||
@@ -55,10 +61,18 @@ export async function putPlayConfigItems(
|
||||
id: number,
|
||||
items: Array<{
|
||||
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;
|
||||
min_bet_amount: number;
|
||||
max_bet_amount: number;
|
||||
display_order?: number;
|
||||
supports_multi_number?: boolean;
|
||||
reserved_rule_json?: unknown;
|
||||
rule_text_zh?: string | null;
|
||||
rule_text_en?: 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 });
|
||||
}
|
||||
|
||||
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> {
|
||||
return adminRequest.get(`${A}/config/risk-cap-versions/${id}`);
|
||||
}
|
||||
|
||||
@@ -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 { redirect } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configHubMeta.title,
|
||||
};
|
||||
|
||||
const GROUP_ICONS = {
|
||||
betting: Layers,
|
||||
risk_wallet: Shield,
|
||||
ops: Wrench,
|
||||
} as const;
|
||||
|
||||
export default function AdminConfigHubPage() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
redirect("/admin/config/plays");
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function AdminBreadcrumb() {
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbList className="gap-1">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
const itemKey = `${crumb.href}-${index}`;
|
||||
|
||||
@@ -17,15 +17,15 @@ export function AdminShell({ children }: { children: ReactNode }) {
|
||||
<SidebarProvider defaultOpen>
|
||||
<AdminAppSidebar />
|
||||
<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 />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Separator orientation="vertical" className="mr-1.5 h-4" />
|
||||
<AdminBreadcrumb />
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<ShellToolbar />
|
||||
</div>
|
||||
</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}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
||||
@@ -45,19 +45,19 @@ export function AdminAppSidebar() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
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 />
|
||||
<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>
|
||||
<span className="text-xs text-sidebar-foreground/70">
|
||||
<span className="text-[11px] leading-tight text-sidebar-foreground/70">
|
||||
Lottery Admin
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -307,7 +307,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,12 +8,12 @@ const LABELS: Record<string, string> = {
|
||||
|
||||
export function ConfigStatusBadge({ status }: { status: string }) {
|
||||
const label = LABELS[status] ?? status;
|
||||
const variant =
|
||||
status === "active" ? "default" : status === "draft" ? "secondary" : "outline";
|
||||
const className =
|
||||
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 (
|
||||
<Badge variant={variant} className="font-normal tabular-nums">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
return <Badge variant="outline" className={`font-normal tabular-nums ${className}`}>{label}</Badge>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,14 +27,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
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)}`;
|
||||
}
|
||||
|
||||
const STATUS_ORDER = ["draft", "active", "archived"] as const;
|
||||
|
||||
export type ConfigVersionSwitcherProps = {
|
||||
versions: ConfigVersionSummary[];
|
||||
selectedId: string;
|
||||
@@ -90,6 +86,29 @@ export function ConfigVersionSwitcher({
|
||||
[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) {
|
||||
onSelectedIdChange(String(id));
|
||||
setSheetOpen(false);
|
||||
@@ -139,79 +158,104 @@ export function ConfigVersionSwitcher({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<SheetTitle>{sheetTitle}</SheetTitle>
|
||||
<SheetDescription>{sheetDescription}</SheetDescription>
|
||||
</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 ? (
|
||||
<p className="text-sm text-muted-foreground p-4">暂无版本记录。</p>
|
||||
<Card className="p-4 text-sm text-muted-foreground">暂无版本记录。</Card>
|
||||
) : (
|
||||
<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>
|
||||
STATUS_ORDER.map((status) => {
|
||||
const rows = groupedVersions.get(status) ?? [];
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section key={status} className="space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ConfigStatusBadge status={status} />
|
||||
<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);
|
||||
return (
|
||||
<Card
|
||||
key={v.id}
|
||||
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} />
|
||||
<span className="text-sm text-muted-foreground">#{v.id}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
生效时间:{v.effective_at ? formatDt(v.effective_at) : "—"}
|
||||
{v.reason ? ` · 备注:${v.reason}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
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";
|
||||
import { configHubMeta } from "@/modules/config/meta";
|
||||
|
||||
function navLinkActive(pathname: string, href: string): boolean {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
@@ -13,70 +15,88 @@ function navLinkActive(pathname: string, href: string): boolean {
|
||||
|
||||
export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
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 (
|
||||
<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>
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<aside className="shrink-0 lg:sticky lg:top-4 lg:w-56 lg:self-start">
|
||||
<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">
|
||||
<div className="mb-3 px-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] 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) => {
|
||||
<nav className="hidden space-y-3 lg:block" aria-label="运营配置子导航">
|
||||
{CONFIG_NAV_GROUPS.map((group) => (
|
||||
<div key={group.id} className="space-y-1.5">
|
||||
<p className="px-2 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const active = navLinkActive(pathname, item.href);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"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
|
||||
? "border-primary/20 bg-primary/10 text-primary shadow-sm"
|
||||
: "border-transparent bg-transparent text-foreground hover:border-border hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">{item.title}</div>
|
||||
</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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import {
|
||||
deleteOddsVersion,
|
||||
getAdminPlayTypes,
|
||||
getAllConfigVersions,
|
||||
getOddsVersion,
|
||||
getOddsVersions,
|
||||
postOddsVersion,
|
||||
@@ -96,7 +97,7 @@ export function OddsConfigDocScreen() {
|
||||
setLoadingList(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await getOddsVersions({ per_page: 50 });
|
||||
const d = await getAllConfigVersions(getOddsVersions);
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
|
||||
@@ -349,28 +350,25 @@ export function OddsConfigDocScreen() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<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) => (
|
||||
<Button
|
||||
key={t.id}
|
||||
type="button"
|
||||
variant={catTab === t.id ? "default" : "outline"}
|
||||
className={cn(catTab === t.id && "shadow-sm")}
|
||||
onClick={() => {
|
||||
setCatTab(t.id);
|
||||
setPlayCode("");
|
||||
}}
|
||||
onClick={() => setCatTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">玩法</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-y-2 min-h-[96px]">
|
||||
<p className="text-base text-muted-foreground">玩法</p>
|
||||
<div className="flex flex-wrap gap-2 min-h-[44px]">
|
||||
{filteredTypes.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">该分类下暂无玩法。</span>
|
||||
<span className="text-base text-muted-foreground">该分类下暂无玩法。</span>
|
||||
) : (
|
||||
filteredTypes.map((t) => (
|
||||
<Button
|
||||
@@ -438,9 +436,11 @@ export function OddsConfigDocScreen() {
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{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 ? (
|
||||
<div className="grid gap-4 max-w-md">
|
||||
<div className="grid min-h-[420px] gap-4 max-w-md">
|
||||
{PRIZE_SCOPE_ORDER.map((scope) => {
|
||||
const row = scopeRows[scope];
|
||||
const hint = PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||
@@ -449,7 +449,7 @@ export function OddsConfigDocScreen() {
|
||||
<div key={scope} className="grid gap-1">
|
||||
<Label className="flex items-baseline gap-2">
|
||||
{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>
|
||||
{row && idx >= 0 ? (
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">缺少 {scope} 行,请检查种子或版本数据。</p>
|
||||
<p className="text-sm text-destructive">缺少 {scope} 行,请检查种子或版本数据。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -486,7 +486,7 @@ export function OddsConfigDocScreen() {
|
||||
value={rebatePercentUi}
|
||||
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>
|
||||
) : null}
|
||||
|
||||
@@ -5,10 +5,9 @@ import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deletePlayConfigVersion,
|
||||
getAdminPlayTypes,
|
||||
getAllConfigVersions,
|
||||
getPlayConfigVersion,
|
||||
getPlayConfigVersions,
|
||||
patchAdminPlayType,
|
||||
postPlayConfigVersion,
|
||||
publishPlayConfigVersion,
|
||||
putPlayConfigItems,
|
||||
@@ -37,105 +36,77 @@ import {
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminPlayTypeRow,
|
||||
ConfigVersionSummary,
|
||||
PlayConfigItemRow,
|
||||
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;
|
||||
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;
|
||||
min_bet_amount: number;
|
||||
max_bet_amount: number;
|
||||
display_order: number;
|
||||
supports_multi_number: boolean;
|
||||
reserved_rule_json: unknown;
|
||||
rule_text_zh: string | null;
|
||||
rule_text_en: string | null;
|
||||
rule_text_ne: string | null;
|
||||
extra_config_json: unknown;
|
||||
};
|
||||
|
||||
/** 与「玩法目录」对齐的完整列表,避免保存草稿时用残缺数组覆盖后端导致其它玩法配置被删。 */
|
||||
/** 版本草稿保存 payload:直接按当前草稿快照落库。 */
|
||||
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,
|
||||
};
|
||||
});
|
||||
return [...draftRows]
|
||||
.sort((a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code))
|
||||
.map((row) => ({
|
||||
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,
|
||||
min_bet_amount: row.min_bet_amount,
|
||||
max_bet_amount: row.max_bet_amount,
|
||||
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_en: row.rule_text_en,
|
||||
rule_text_ne: row.rule_text_ne,
|
||||
extra_config_json: row.extra_config_json,
|
||||
}));
|
||||
}
|
||||
|
||||
export function PlayConfigDocScreen() {
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
|
||||
const [draftRows, setDraftRows] = useState<PlayConfigItemRow[]>([]);
|
||||
const [loadingTypes, setLoadingTypes] = useState(true);
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
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 [rulePlayCode, setRulePlayCode] = useState<string | null>(null);
|
||||
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 () => {
|
||||
setLoadingList(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await getPlayConfigVersions({ per_page: 50 });
|
||||
const d = await getAllConfigVersions(getPlayConfigVersions);
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
|
||||
@@ -148,10 +119,9 @@ export function PlayConfigDocScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void refreshTypes();
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshTypes, refreshList]);
|
||||
}, [refreshList]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
@@ -197,96 +167,23 @@ export function PlayConfigDocScreen() {
|
||||
|
||||
const isDraft = detail?.status === "draft";
|
||||
|
||||
const itemsByCode = useMemo(() => {
|
||||
const m = new Map<string, PlayConfigItemRow>();
|
||||
for (const r of draftRows) {
|
||||
m.set(r.play_code, r);
|
||||
}
|
||||
return m;
|
||||
}, [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);
|
||||
}
|
||||
const orderedRows = useMemo(
|
||||
() =>
|
||||
[...draftRows].sort(
|
||||
(a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code),
|
||||
),
|
||||
[draftRows],
|
||||
);
|
||||
|
||||
function updateConfigRow(playCode: string, patch: Partial<PlayConfigItemRow>) {
|
||||
const idx = draftRowIndex(playCode);
|
||||
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);
|
||||
}
|
||||
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
const payload = buildPlayConfigSavePayload(types, draftRows);
|
||||
const payload = buildPlayConfigSavePayload(draftRows);
|
||||
for (const r of payload) {
|
||||
if (r.min_bet_amount > r.max_bet_amount) {
|
||||
toast.error(`${r.play_code}: 最小额不能大于最大额`);
|
||||
@@ -347,7 +244,7 @@ export function PlayConfigDocScreen() {
|
||||
}
|
||||
|
||||
function openRuleEditor(play_code: string) {
|
||||
const item = itemsByCode.get(play_code);
|
||||
const item = draftRows.find((row) => row.play_code === play_code);
|
||||
setRulePlayCode(play_code);
|
||||
setRuleDraftZh(item?.rule_text_zh ?? "");
|
||||
setRuleDialogOpen(true);
|
||||
@@ -382,16 +279,6 @@ export function PlayConfigDocScreen() {
|
||||
<CardTitle className="text-lg">玩法配置</CardTitle>
|
||||
</CardHeader>
|
||||
<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}
|
||||
@@ -405,9 +292,6 @@ export function PlayConfigDocScreen() {
|
||||
<Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loadingList}>
|
||||
刷新版本
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void refreshTypes()} disabled={loadingTypes}>
|
||||
刷新目录
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
|
||||
新建草稿
|
||||
</Button>
|
||||
@@ -446,7 +330,7 @@ export function PlayConfigDocScreen() {
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{loadingDetail || loadingTypes ? (
|
||||
{loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
@@ -464,32 +348,28 @@ export function PlayConfigDocScreen() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mergedRows.map(({ type: t, item }) => (
|
||||
<TableRow key={t.play_code}>
|
||||
<TableCell className="font-mono text-sm">{t.play_code}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{t.category}</TableCell>
|
||||
{orderedRows.map((row) => (
|
||||
<TableRow key={row.play_code}>
|
||||
<TableCell className="font-mono text-sm">{row.play_code}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={t.is_enabled}
|
||||
checked={row.is_enabled}
|
||||
disabled={saving}
|
||||
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>
|
||||
<Input
|
||||
className="h-8 text-sm"
|
||||
defaultValue={t.display_name_zh ?? ""}
|
||||
key={`${t.play_code}-dn-${t.updated_at}`}
|
||||
value={row.display_name_zh ?? ""}
|
||||
disabled={saving}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
const next = v || null;
|
||||
if (next !== (t.display_name_zh ?? null)) {
|
||||
void patchTypeField(t.play_code, { display_name_zh: next });
|
||||
}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value === "" ? null : e.target.value;
|
||||
updateConfigRow(row.play_code, { display_name_zh: next });
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -497,57 +377,50 @@ export function PlayConfigDocScreen() {
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8 w-full font-mono tabular-nums text-right"
|
||||
defaultValue={t.sort_order}
|
||||
key={`${t.play_code}-so-${t.updated_at}`}
|
||||
value={row.display_order}
|
||||
disabled={saving}
|
||||
onBlur={(e) => {
|
||||
onChange={(e) => {
|
||||
const n = Number.parseInt(e.target.value, 10);
|
||||
if (Number.isFinite(n) && n !== t.sort_order) {
|
||||
void patchTypeField(t.play_code, { sort_order: n });
|
||||
if (Number.isFinite(n)) {
|
||||
updateConfigRow(row.play_code, { display_order: n });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item ? (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 font-mono tabular-nums"
|
||||
disabled={!isDraft || saving}
|
||||
value={item.min_bet_amount}
|
||||
onChange={(e) =>
|
||||
updateConfigRow(t.play_code, {
|
||||
min_bet_amount: Number.parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-destructive">无配置行</span>
|
||||
)}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 font-mono tabular-nums"
|
||||
disabled={!isDraft || saving}
|
||||
value={row.min_bet_amount}
|
||||
onChange={(e) =>
|
||||
updateConfigRow(row.play_code, {
|
||||
min_bet_amount: Number.parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item ? (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 font-mono tabular-nums"
|
||||
disabled={!isDraft || saving}
|
||||
value={item.max_bet_amount}
|
||||
onChange={(e) =>
|
||||
updateConfigRow(t.play_code, {
|
||||
max_bet_amount: Number.parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 font-mono tabular-nums"
|
||||
disabled={!isDraft || saving}
|
||||
value={row.max_bet_amount}
|
||||
onChange={(e) =>
|
||||
updateConfigRow(row.play_code, {
|
||||
max_bet_amount: Number.parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!item || !isDraft || saving}
|
||||
onClick={() => openRuleEditor(t.play_code)}
|
||||
disabled={!isDraft || saving}
|
||||
onClick={() => openRuleEditor(row.play_code)}
|
||||
>
|
||||
规则说明
|
||||
</Button>
|
||||
@@ -560,37 +433,6 @@ export function PlayConfigDocScreen() {
|
||||
)}
|
||||
</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}>
|
||||
<DialogContent showCloseButton className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import {
|
||||
deleteOddsVersion,
|
||||
getAdminPlayTypes,
|
||||
getAllConfigVersions,
|
||||
getOddsVersion,
|
||||
getOddsVersions,
|
||||
postOddsVersion,
|
||||
@@ -72,7 +73,7 @@ export function RebateConfigDocScreen() {
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
try {
|
||||
const d = await getOddsVersions({ per_page: 50 });
|
||||
const d = await getAllConfigVersions(getOddsVersions);
|
||||
setListRows(d.items);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本失败");
|
||||
@@ -345,7 +346,7 @@ export function RebateConfigDocScreen() {
|
||||
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
|
||||
中奖是否享受回水
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
界面占位:后续可与风控 / 结算规则字段对齐并持久化。
|
||||
</p>
|
||||
</div>
|
||||
@@ -353,7 +354,7 @@ export function RebateConfigDocScreen() {
|
||||
|
||||
<div className="grid gap-1 text-sm">
|
||||
<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) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteRiskCapVersion,
|
||||
getAllConfigVersions,
|
||||
getRiskCapVersion,
|
||||
getRiskCapVersions,
|
||||
postRiskCapVersion,
|
||||
@@ -72,7 +73,7 @@ export function RiskCapDocScreen() {
|
||||
setLoadingList(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await getRiskCapVersions({ per_page: 50 });
|
||||
const d = await getAllConfigVersions(getRiskCapVersions);
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
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">
|
||||
<h3 className="text-sm font-medium">默认封顶</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
将下列金额同步到当前草稿中的<strong>全部号码行</strong>(适用于统一基数快速调整)。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
@@ -447,7 +448,7 @@ export function RiskCapDocScreen() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">全部号码占用情况</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
占位界面:筛选与导出待接入注单汇总;下列数据仍来源于当前草稿号码列表。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
export const configHubMeta = {
|
||||
title: "配置中心",
|
||||
description: "",
|
||||
description: "统一管理玩法目录、赔率、回水和风险封顶,先草稿、后发布、再生效。",
|
||||
} as const;
|
||||
|
||||
export const configPlayConfigMeta = {
|
||||
title: "玩法配置",
|
||||
description: "",
|
||||
description: "维护玩法开关、限额和规则文案,目录变更会直接影响下注入口。",
|
||||
} as const;
|
||||
|
||||
export const configOddsMeta = {
|
||||
title: "赔率配置",
|
||||
description: "",
|
||||
description: "维护赔率、返水和佣金,发布前请重点核对数值范围与币种。",
|
||||
} as const;
|
||||
|
||||
export const configRebateMeta = {
|
||||
title: "佣金 / 回水",
|
||||
description: "",
|
||||
description: "从赔率草稿中批量调整回水比例,适合按玩法维度统一修正。",
|
||||
} as const;
|
||||
|
||||
export const configRiskCapMeta = {
|
||||
title: "风控封顶",
|
||||
description: "",
|
||||
description: "管理号码封顶版本和风险池阈值,发布前先确认号码与期号。",
|
||||
} as const;
|
||||
|
||||
export const configWalletMeta = {
|
||||
title: "钱包配置",
|
||||
description: "",
|
||||
description: "维护钱包相关阈值与转账策略。",
|
||||
} as const;
|
||||
|
||||
@@ -40,10 +40,18 @@ export type ConfigVersionSummary = {
|
||||
export type PlayConfigItemRow = {
|
||||
id: number;
|
||||
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;
|
||||
min_bet_amount: number;
|
||||
max_bet_amount: number;
|
||||
display_order: number;
|
||||
supports_multi_number: boolean;
|
||||
reserved_rule_json: unknown;
|
||||
rule_text_zh: string | null;
|
||||
rule_text_en: string | null;
|
||||
rule_text_ne: string | null;
|
||||
|
||||
Reference in New Issue
Block a user