feat(config): 重构配置中心导航与版本展示,支持全量版本加载
This commit is contained in:
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user