feat: 添加配置模块和更新管理员导航,优化钱包控制台加载逻辑
This commit is contained in:
146
src/api/admin-config.ts
Normal file
146
src/api/admin-config.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { adminRequest } from "@/lib/admin-http";
|
||||
|
||||
import { API_V1_PREFIX } from "./paths";
|
||||
|
||||
import type {
|
||||
AdminPlayTypeRow,
|
||||
AdminPlayTypesData,
|
||||
ConfigVersionListData,
|
||||
OddsVersionDetail,
|
||||
PlayConfigVersionDetail,
|
||||
RiskCapVersionDetail,
|
||||
} from "@/types/api/admin-config";
|
||||
|
||||
const A = `${API_V1_PREFIX}/admin`;
|
||||
|
||||
export async function getAdminPlayTypes(): Promise<AdminPlayTypesData> {
|
||||
return adminRequest.get<AdminPlayTypesData>(`${A}/play-types`);
|
||||
}
|
||||
|
||||
export async function patchAdminPlayType(
|
||||
playCode: string,
|
||||
body: Partial<{
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
display_name_zh: string | null;
|
||||
display_name_en: string | null;
|
||||
display_name_ne: string | null;
|
||||
supports_multi_number: boolean;
|
||||
reserved_rule_json: unknown;
|
||||
}>,
|
||||
): Promise<AdminPlayTypeRow> {
|
||||
return adminRequest.patch(`${A}/play-types/${encodeURIComponent(playCode)}`, body);
|
||||
}
|
||||
|
||||
export async function getPlayConfigVersions(params?: {
|
||||
status?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<ConfigVersionListData> {
|
||||
return adminRequest.get(`${A}/config/play-versions`, { params });
|
||||
}
|
||||
|
||||
export async function getPlayConfigVersion(id: number): Promise<PlayConfigVersionDetail> {
|
||||
return adminRequest.get(`${A}/config/play-versions/${id}`);
|
||||
}
|
||||
|
||||
export async function postPlayConfigVersion(body?: {
|
||||
reason?: string | null;
|
||||
clone_from_version_id?: number | null;
|
||||
}): Promise<PlayConfigVersionDetail> {
|
||||
return adminRequest.post(`${A}/config/play-versions`, body ?? {});
|
||||
}
|
||||
|
||||
export async function putPlayConfigItems(
|
||||
id: number,
|
||||
items: Array<{
|
||||
play_code: string;
|
||||
is_enabled?: boolean;
|
||||
min_bet_amount: number;
|
||||
max_bet_amount: number;
|
||||
display_order?: number;
|
||||
rule_text_zh?: string | null;
|
||||
rule_text_en?: string | null;
|
||||
rule_text_ne?: string | null;
|
||||
extra_config_json?: unknown;
|
||||
}>,
|
||||
): Promise<PlayConfigVersionDetail> {
|
||||
return adminRequest.put(`${A}/config/play-versions/${id}/items`, { items });
|
||||
}
|
||||
|
||||
export async function publishPlayConfigVersion(id: number): Promise<PlayConfigVersionDetail> {
|
||||
return adminRequest.post(`${A}/config/play-versions/${id}/publish`);
|
||||
}
|
||||
|
||||
export async function getOddsVersions(params?: {
|
||||
status?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<ConfigVersionListData> {
|
||||
return adminRequest.get(`${A}/config/odds-versions`, { params });
|
||||
}
|
||||
|
||||
export async function getOddsVersion(id: number): Promise<OddsVersionDetail> {
|
||||
return adminRequest.get(`${A}/config/odds-versions/${id}`);
|
||||
}
|
||||
|
||||
export async function postOddsVersion(body?: {
|
||||
reason?: string | null;
|
||||
clone_from_version_id?: number | null;
|
||||
}): Promise<OddsVersionDetail> {
|
||||
return adminRequest.post(`${A}/config/odds-versions`, body ?? {});
|
||||
}
|
||||
|
||||
export async function putOddsItems(
|
||||
id: number,
|
||||
items: Array<{
|
||||
play_code: string;
|
||||
prize_scope: string;
|
||||
odds_value: number;
|
||||
rebate_rate?: number;
|
||||
commission_rate?: number;
|
||||
currency_code: string;
|
||||
extra_config_json?: unknown;
|
||||
}>,
|
||||
): Promise<OddsVersionDetail> {
|
||||
return adminRequest.put(`${A}/config/odds-versions/${id}/items`, { items });
|
||||
}
|
||||
|
||||
export async function publishOddsVersion(id: number): Promise<OddsVersionDetail> {
|
||||
return adminRequest.post(`${A}/config/odds-versions/${id}/publish`);
|
||||
}
|
||||
|
||||
export async function getRiskCapVersions(params?: {
|
||||
status?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<ConfigVersionListData> {
|
||||
return adminRequest.get(`${A}/config/risk-cap-versions`, { params });
|
||||
}
|
||||
|
||||
export async function getRiskCapVersion(id: number): Promise<RiskCapVersionDetail> {
|
||||
return adminRequest.get(`${A}/config/risk-cap-versions/${id}`);
|
||||
}
|
||||
|
||||
export async function postRiskCapVersion(body?: {
|
||||
reason?: string | null;
|
||||
clone_from_version_id?: number | null;
|
||||
}): Promise<RiskCapVersionDetail> {
|
||||
return adminRequest.post(`${A}/config/risk-cap-versions`, body ?? {});
|
||||
}
|
||||
|
||||
export async function putRiskCapItems(
|
||||
id: number,
|
||||
items: Array<{
|
||||
draw_id?: number | null;
|
||||
normalized_number: string;
|
||||
cap_amount: number;
|
||||
cap_type: string;
|
||||
}>,
|
||||
): Promise<RiskCapVersionDetail> {
|
||||
return adminRequest.put(`${A}/config/risk-cap-versions/${id}/items`, { items });
|
||||
}
|
||||
|
||||
export async function publishRiskCapVersion(id: number): Promise<RiskCapVersionDetail> {
|
||||
return adminRequest.post(`${A}/config/risk-cap-versions/${id}/publish`);
|
||||
}
|
||||
14
src/app/admin/(shell)/config/layout.tsx
Normal file
14
src/app/admin/(shell)/config/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ConfigSubNav } from "@/modules/config/config-subnav";
|
||||
|
||||
export default function AdminConfigLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full max-w-none px-1">
|
||||
<ConfigSubNav />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/app/admin/(shell)/config/odds/page.tsx
Normal file
16
src/app/admin/(shell)/config/odds/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
|
||||
import { configOddsMeta } from "@/modules/config/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configOddsMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigOddsPage() {
|
||||
return (
|
||||
<ModuleScaffold className="max-w-6xl">
|
||||
<OddsConfigDocScreen />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
66
src/app/admin/(shell)/config/page.tsx
Normal file
66
src/app/admin/(shell)/config/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { configHubMeta } from "@/modules/config/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configHubMeta.title,
|
||||
};
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
href: "/admin/config/plays",
|
||||
title: "玩法配置",
|
||||
description: "§5.4:目录开关、显示名与排序、限额、规则说明(玩法配置版本)。",
|
||||
},
|
||||
{
|
||||
href: "/admin/config/odds",
|
||||
title: "赔率配置",
|
||||
description: "§5.5:按维度 / 玩法编辑五档赔率、回水率、历史版本与回滚。",
|
||||
},
|
||||
{
|
||||
href: "/admin/config/rebate",
|
||||
title: "佣金 / 回水",
|
||||
description: "§5.6:按 2D / 3D / 4D 批量写入 rebate_rate(共用赔率版本)。",
|
||||
},
|
||||
{
|
||||
href: "/admin/config/risk-cap",
|
||||
title: "风控封顶",
|
||||
description: "§5.7:默认封顶、特殊号码封顶;占用列为占位,待注单汇总接入。",
|
||||
},
|
||||
{
|
||||
href: "/admin/config/versions",
|
||||
title: "配置版本历史",
|
||||
description: "三套流水线的版本列表(玩法配置 / 赔率 / 风控封顶)。",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function AdminConfigHubPage() {
|
||||
return (
|
||||
<ModuleScaffold className="max-w-4xl">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{configHubMeta.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{configHubMeta.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{SECTIONS.map((s) => (
|
||||
<Link key={s.href} href={s.href} className="block rounded-lg outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Card className="h-full transition-colors hover:bg-muted/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{s.title}</CardTitle>
|
||||
<CardDescription>{s.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
5
src/app/admin/(shell)/config/play-limits/page.tsx
Normal file
5
src/app/admin/(shell)/config/play-limits/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminConfigPlayLimitsRedirectPage() {
|
||||
redirect("/admin/config/plays");
|
||||
}
|
||||
5
src/app/admin/(shell)/config/play-switches/page.tsx
Normal file
5
src/app/admin/(shell)/config/play-switches/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminConfigPlaySwitchesRedirectPage() {
|
||||
redirect("/admin/config/plays");
|
||||
}
|
||||
16
src/app/admin/(shell)/config/plays/page.tsx
Normal file
16
src/app/admin/(shell)/config/plays/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { PlayConfigDocScreen } from "@/modules/config/doc/play-config-doc-screen";
|
||||
import { configPlayConfigMeta } from "@/modules/config/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configPlayConfigMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigPlaysPage() {
|
||||
return (
|
||||
<ModuleScaffold className="max-w-6xl">
|
||||
<PlayConfigDocScreen />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
5
src/app/admin/(shell)/config/rebate-commission/page.tsx
Normal file
5
src/app/admin/(shell)/config/rebate-commission/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminConfigRebateCommissionRedirectPage() {
|
||||
redirect("/admin/config/rebate");
|
||||
}
|
||||
16
src/app/admin/(shell)/config/rebate/page.tsx
Normal file
16
src/app/admin/(shell)/config/rebate/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen";
|
||||
import { configRebateMeta } from "@/modules/config/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configRebateMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigRebateDedicatedPage() {
|
||||
return (
|
||||
<ModuleScaffold className="max-w-5xl">
|
||||
<RebateConfigDocScreen />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
16
src/app/admin/(shell)/config/risk-cap/page.tsx
Normal file
16
src/app/admin/(shell)/config/risk-cap/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { configRiskCapMeta } from "@/modules/config/meta";
|
||||
import { RiskCapDocScreen } from "@/modules/config/doc/risk-cap-doc-screen";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configRiskCapMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigRiskCapPage() {
|
||||
return (
|
||||
<ModuleScaffold className="max-w-5xl">
|
||||
<RiskCapDocScreen />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
16
src/app/admin/(shell)/config/versions/page.tsx
Normal file
16
src/app/admin/(shell)/config/versions/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { ConfigVersionsConsole } from "@/modules/config/config-versions-console";
|
||||
import { configVersionsMeta } from "@/modules/config/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configVersionsMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigVersionsPage() {
|
||||
return (
|
||||
<ModuleScaffold className="max-w-5xl">
|
||||
<ConfigVersionsConsole />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
LogIn,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
SlidersHorizontal,
|
||||
Ticket,
|
||||
Users,
|
||||
Wallet,
|
||||
@@ -18,6 +19,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
||||
dashboard: LayoutDashboard,
|
||||
players: Users,
|
||||
draws: CalendarClock,
|
||||
config: SlidersHorizontal,
|
||||
tickets: Ticket,
|
||||
wallet: Wallet,
|
||||
risk: ShieldAlert,
|
||||
|
||||
@@ -12,6 +12,7 @@ export type AdminNavItem = {
|
||||
| "dashboard"
|
||||
| "players"
|
||||
| "draws"
|
||||
| "config"
|
||||
| "tickets"
|
||||
| "wallet"
|
||||
| "risk"
|
||||
@@ -22,6 +23,7 @@ export const adminShellNavItems: AdminNavItem[] = [
|
||||
{ segment: "dashboard", label: "总览", href: "/admin" },
|
||||
{ segment: "players", label: "用户", href: "/admin/players" },
|
||||
{ segment: "draws", label: "开奖", href: "/admin/draws" },
|
||||
{ segment: "config", label: "运营配置", href: "/admin/config" },
|
||||
{ segment: "tickets", label: "注单 / 票务", href: "/admin/tickets" },
|
||||
{ segment: "wallet", label: "钱包", href: "/admin/wallet" },
|
||||
{ segment: "risk", label: "风控", href: "/admin/risk" },
|
||||
|
||||
19
src/modules/config/config-status-badge.tsx
Normal file
19
src/modules/config/config-status-badge.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
active: "生效中",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
export function ConfigStatusBadge({ status }: { status: string }) {
|
||||
const label = LABELS[status] ?? status;
|
||||
const variant =
|
||||
status === "active" ? "default" : status === "draft" ? "secondary" : "outline";
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className="font-normal tabular-nums">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
50
src/modules/config/config-subnav.tsx
Normal file
50
src/modules/config/config-subnav.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LINKS: { href: string; label: string; match?: "exact" | "prefix" }[] = [
|
||||
{ href: "/admin/config", label: "概览", match: "exact" },
|
||||
{ href: "/admin/config/plays", label: "玩法配置" },
|
||||
{ href: "/admin/config/odds", label: "赔率配置" },
|
||||
{ href: "/admin/config/rebate", label: "佣金 / 回水" },
|
||||
{ href: "/admin/config/risk-cap", label: "风控封顶" },
|
||||
];
|
||||
|
||||
function linkActive(pathname: string, href: string, match: "exact" | "prefix"): boolean {
|
||||
if (match === "exact") {
|
||||
return pathname === href || pathname === `${href}/`;
|
||||
}
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
export function ConfigSubNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex flex-wrap gap-2 border-b border-border pb-3 mb-6"
|
||||
aria-label="运营配置子导航"
|
||||
>
|
||||
{LINKS.map(({ href, label, match = "prefix" }) => {
|
||||
const active = linkActive(pathname, href, match);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
139
src/modules/config/config-versions-console.tsx
Normal file
139
src/modules/config/config-versions-console.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
getOddsVersions,
|
||||
getPlayConfigVersions,
|
||||
getRiskCapVersions,
|
||||
} from "@/api/admin-config";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { ConfigVersionSummary } from "@/types/api/admin-config";
|
||||
|
||||
type ConfigVersionTableProps = {
|
||||
rows: ConfigVersionSummary[];
|
||||
loading: boolean;
|
||||
formatDt: (iso: string) => string;
|
||||
};
|
||||
|
||||
function ConfigVersionTable({ rows, loading, formatDt }: ConfigVersionTableProps) {
|
||||
if (loading) {
|
||||
return <p className="text-sm text-muted-foreground py-4">加载中…</p>;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground py-4">暂无版本记录。</p>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[72px]">ID</TableHead>
|
||||
<TableHead className="w-[88px]">version_no</TableHead>
|
||||
<TableHead className="w-[100px]">状态</TableHead>
|
||||
<TableHead>生效时间</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((v) => (
|
||||
<TableRow key={v.id}>
|
||||
<TableCell className="tabular-nums">{v.id}</TableCell>
|
||||
<TableCell className="tabular-nums">{v.version_no}</TableCell>
|
||||
<TableCell>
|
||||
<ConfigStatusBadge status={v.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{v.effective_at ? formatDt(v.effective_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm max-w-[280px] truncate" title={v.reason ?? ""}>
|
||||
{v.reason ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigVersionsConsole() {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [playRows, setPlayRows] = useState<ConfigVersionSummary[]>([]);
|
||||
const [oddsRows, setOddsRows] = useState<ConfigVersionSummary[]>([]);
|
||||
const [riskRows, setRiskRows] = useState<ConfigVersionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [p, o, r] = await Promise.all([
|
||||
getPlayConfigVersions({ per_page: 100 }),
|
||||
getOddsVersions({ per_page: 100 }),
|
||||
getRiskCapVersions({ per_page: 100 }),
|
||||
]);
|
||||
setPlayRows(p.items.sort((a, b) => b.id - a.id));
|
||||
setOddsRows(o.items.sort((a, b) => b.id - a.id));
|
||||
setRiskRows(r.items.sort((a, b) => b.id - a.id));
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : "加载版本历史失败";
|
||||
setError(msg);
|
||||
setPlayRows([]);
|
||||
setOddsRows([]);
|
||||
setRiskRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadAll();
|
||||
});
|
||||
}, [loadAll]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-lg">配置版本历史</CardTitle>
|
||||
<CardDescription>
|
||||
三套流水线独立版本号;每次「发布」会将旧生效版本归档。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<Tabs defaultValue="play">
|
||||
<TabsList>
|
||||
<TabsTrigger value="play">玩法配置</TabsTrigger>
|
||||
<TabsTrigger value="odds">赔率</TabsTrigger>
|
||||
<TabsTrigger value="risk">风控封顶</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="play" className="mt-4">
|
||||
<ConfigVersionTable rows={playRows} loading={loading} formatDt={formatDt} />
|
||||
</TabsContent>
|
||||
<TabsContent value="odds" className="mt-4">
|
||||
<ConfigVersionTable rows={oddsRows} loading={loading} formatDt={formatDt} />
|
||||
</TabsContent>
|
||||
<TabsContent value="risk" className="mt-4">
|
||||
<ConfigVersionTable rows={riskRows} loading={loading} formatDt={formatDt} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
576
src/modules/config/doc/odds-config-doc-screen.tsx
Normal file
576
src/modules/config/doc/odds-config-doc-screen.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminPlayTypes,
|
||||
getOddsVersion,
|
||||
getOddsVersions,
|
||||
postOddsVersion,
|
||||
publishOddsVersion,
|
||||
putOddsItems,
|
||||
} from "@/api/admin-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminPlayTypeRow,
|
||||
ConfigVersionSummary,
|
||||
OddsItemRow,
|
||||
OddsVersionDetail,
|
||||
} from "@/types/api/admin-config";
|
||||
|
||||
import {
|
||||
PRIZE_SCOPE_LABELS,
|
||||
PRIZE_SCOPE_MULTIPLIER_HINT,
|
||||
PRIZE_SCOPE_ORDER,
|
||||
type PrizeScopeCode,
|
||||
} from "@/modules/config/doc/prize-scopes";
|
||||
|
||||
type CatTab = "all" | "d4" | "d3" | "d2" | "jackpot";
|
||||
|
||||
function oddsMultiplierLabel(oddsValue: number): string {
|
||||
return (oddsValue / 10000).toFixed(4);
|
||||
}
|
||||
|
||||
function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[] {
|
||||
if (tab === "all") {
|
||||
return types;
|
||||
}
|
||||
if (tab === "jackpot") {
|
||||
return types.filter((t) => t.category.toLowerCase().includes("jackpot"));
|
||||
}
|
||||
const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
|
||||
return types.filter((t) => t.dimension === dim);
|
||||
}
|
||||
|
||||
export function OddsConfigDocScreen() {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
||||
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
||||
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 [catTab, setCatTab] = useState<CatTab>("all");
|
||||
/** 用户点选的玩法;空字符串表示尚未选择,由 resolvedPlayCode 回落到分类内第一项 */
|
||||
const [playCode, setPlayCode] = useState<string>("");
|
||||
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
const refreshTypes = useCallback(async () => {
|
||||
setLoadingTypes(true);
|
||||
try {
|
||||
const d = await getAdminPlayTypes();
|
||||
setTypes(d.items);
|
||||
} 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 getOddsVersions({ per_page: 50 });
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void refreshTypes();
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshTypes, refreshList]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const d = await getOddsVersion(id);
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0 || selectedId !== "") {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
|
||||
const active = list.find((x) => x.status === "active");
|
||||
const pick = drafts[0] ?? active ?? [...list].sort((a, b) => b.id - a.id)[0];
|
||||
if (pick) {
|
||||
setSelectedId(String(pick.id));
|
||||
}
|
||||
});
|
||||
}, [list, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === "") {
|
||||
return;
|
||||
}
|
||||
const id = Number(selectedId);
|
||||
if (!Number.isFinite(id)) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadDetail(id);
|
||||
});
|
||||
}, [selectedId, loadDetail]);
|
||||
|
||||
const sortedTypes = useMemo(
|
||||
() => [...types].sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)),
|
||||
[types],
|
||||
);
|
||||
|
||||
const filteredTypes = useMemo(() => filterTypes(catTab, sortedTypes), [catTab, sortedTypes]);
|
||||
|
||||
const resolvedPlayCode = useMemo(() => {
|
||||
if (filteredTypes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (playCode && filteredTypes.some((t) => t.play_code === playCode)) {
|
||||
return playCode;
|
||||
}
|
||||
return filteredTypes[0].play_code;
|
||||
}, [filteredTypes, playCode]);
|
||||
|
||||
const isDraft = detail?.status === "draft";
|
||||
|
||||
const scopeRows = useMemo(() => {
|
||||
const rows: Partial<Record<PrizeScopeCode, OddsItemRow>> = {};
|
||||
if (!resolvedPlayCode) {
|
||||
return rows;
|
||||
}
|
||||
for (const scope of PRIZE_SCOPE_ORDER) {
|
||||
const hit = draftRows.find((r) => r.play_code === resolvedPlayCode && r.prize_scope === scope);
|
||||
if (hit) {
|
||||
rows[scope] = hit;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [draftRows, resolvedPlayCode]);
|
||||
|
||||
const rebatePercentUi = useMemo(() => {
|
||||
const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean);
|
||||
if (!first) {
|
||||
return "0";
|
||||
}
|
||||
const n = Number.parseFloat(String(first.rebate_rate));
|
||||
if (!Number.isFinite(n)) {
|
||||
return "0";
|
||||
}
|
||||
return String(Math.round(n * 10000) / 100);
|
||||
}, [scopeRows]);
|
||||
|
||||
function rowIndex(play_code: string, prize_scope: string): number {
|
||||
return draftRows.findIndex((r) => r.play_code === play_code && r.prize_scope === prize_scope);
|
||||
}
|
||||
|
||||
function updateOddsRow(idx: number, patch: Partial<OddsItemRow>) {
|
||||
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
function updateOddsForScope(scope: PrizeScopeCode, patch: Partial<OddsItemRow>) {
|
||||
const idx = rowIndex(resolvedPlayCode, scope);
|
||||
if (idx >= 0) {
|
||||
updateOddsRow(idx, patch);
|
||||
}
|
||||
}
|
||||
|
||||
function setRebateForPlayPercent(percentStr: string) {
|
||||
const p = Number.parseFloat(percentStr);
|
||||
const rate = Number.isFinite(p) ? p / 100 : 0;
|
||||
setDraftRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.play_code === resolvedPlayCode ? { ...r, rebate_rate: String(rate) } : r,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = draftRows.map((r) => ({
|
||||
play_code: r.play_code,
|
||||
prize_scope: r.prize_scope,
|
||||
odds_value: r.odds_value,
|
||||
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
||||
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
||||
currency_code: r.currency_code,
|
||||
extra_config_json: r.extra_config_json,
|
||||
}));
|
||||
const d = await putOddsItems(detail.id, payload);
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
toast.success("已保存草稿");
|
||||
void refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const d = await publishOddsVersion(detail.id);
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
toast.success("已启用为当前版本");
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewDraft() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const active = list.find((x) => x.status === "active");
|
||||
const d = await postOddsVersion({
|
||||
reason: `draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`已创建草稿 v${d.version_no}`);
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback() {
|
||||
if (!rollbackTarget) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const d = await postOddsVersion({
|
||||
reason: `rollback from v${rollbackTarget.version_no}`,
|
||||
clone_from_version_id: rollbackTarget.id,
|
||||
});
|
||||
toast.success(`已自 v${rollbackTarget.version_no} 克隆为新草稿 v${d.version_no}`);
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
setRollbackOpen(false);
|
||||
setRollbackTarget(null);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "回滚失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activeHead = list.find((x) => x.status === "active");
|
||||
|
||||
const catTabs: { id: CatTab; label: string }[] = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "d4", label: "4D" },
|
||||
{ id: "d3", label: "3D" },
|
||||
{ id: "d2", label: "2D" },
|
||||
{ id: "jackpot", label: "Jackpot" },
|
||||
];
|
||||
|
||||
const sortedHistory = useMemo(() => [...list].sort((a, b) => b.id - a.id), [list]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-lg">赔率配置</CardTitle>
|
||||
<CardDescription>
|
||||
对齐 §5.5:分类与玩法切换编辑五档赔率;odds_value = 赔率乘数 × 10000(NPR 100 基准展示)。
|
||||
</CardDescription>
|
||||
</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>
|
||||
{catTabs.map((t) => (
|
||||
<Button
|
||||
key={t.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={catTab === t.id ? "default" : "outline"}
|
||||
className={cn(catTab === t.id && "shadow-sm")}
|
||||
onClick={() => {
|
||||
setCatTab(t.id);
|
||||
setPlayCode("");
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">玩法</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredTypes.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">该分类下暂无玩法。</span>
|
||||
) : (
|
||||
filteredTypes.map((t) => (
|
||||
<Button
|
||||
key={t.play_code}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={resolvedPlayCode === t.play_code ? "secondary" : "outline"}
|
||||
onClick={() => setPlayCode(t.play_code)}
|
||||
>
|
||||
{t.display_name_zh ?? t.play_code}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={loadingList}
|
||||
onClick={() => void refreshList()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}>
|
||||
新建草稿
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{detail ? (
|
||||
<div className="rounded-lg border bg-muted/30 px-4 py-3 text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">当前编辑版本:</span>v{detail.version_no} ·{" "}
|
||||
{detail.status === "active" ? "生效中" : detail.status === "draft" ? "草稿" : "已归档"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">当前生效版本:</span>
|
||||
{activeHead ? (
|
||||
<>
|
||||
v{activeHead.version_no}
|
||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</p>
|
||||
{!isDraft ? (
|
||||
<p className="text-amber-600 dark:text-amber-400">当前为只读版本,请新建草稿后再改赔率。</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{loadingDetail || loadingTypes ? (
|
||||
<p className="text-sm text-muted-foreground">加载明细…</p>
|
||||
) : resolvedPlayCode ? (
|
||||
<div className="grid gap-4 max-w-md">
|
||||
{PRIZE_SCOPE_ORDER.map((scope) => {
|
||||
const row = scopeRows[scope];
|
||||
const hint = PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
|
||||
return (
|
||||
<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}
|
||||
</Label>
|
||||
{row && idx >= 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-9 font-mono tabular-nums max-w-[200px]"
|
||||
disabled={!isDraft || saving}
|
||||
value={row.odds_value}
|
||||
onChange={(e) =>
|
||||
updateOddsForScope(scope, {
|
||||
odds_value: Number.parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
乘数 ×{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">缺少 {scope} 行,请检查种子或版本数据。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid gap-1 pt-2 border-t">
|
||||
<Label>回水率(%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
className="h-9 font-mono tabular-nums max-w-[200px]"
|
||||
disabled={!isDraft || saving}
|
||||
value={rebatePercentUi}
|
||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">写入该玩法下全部奖项档位的 rebate_rate。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setHistoryOpen(true)}>
|
||||
查看历史版本
|
||||
</Button>
|
||||
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<SheetContent side="right" className="sm:max-w-lg flex flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle>赔率版本历史</SheetTitle>
|
||||
<SheetDescription>选择一条历史版本执行回滚(克隆为新草稿)。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-auto rounded-md border mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>版本</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedHistory.map((v) => (
|
||||
<TableRow key={v.id}>
|
||||
<TableCell className="font-mono text-xs">v{v.version_no}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{v.status === "active" ? "生效" : v.status === "draft" ? "草稿" : "归档"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{v.updated_at ? formatDt(v.updated_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={saving || v.status === "draft"}
|
||||
onClick={() => {
|
||||
setRollbackTarget(v);
|
||||
setRollbackOpen(true);
|
||||
setHistoryOpen(false);
|
||||
}}
|
||||
>
|
||||
回滚
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
onClick={() => void handlePublish()}
|
||||
disabled={!isDraft || saving || loadingDetail}
|
||||
>
|
||||
启用为当前版本
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认回滚</DialogTitle>
|
||||
<DialogDescription>
|
||||
将以版本 v{rollbackTarget?.version_no} 的快照克隆为新草稿;不会直接覆盖线上生效版本。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRollbackOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
|
||||
确认回滚
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
560
src/modules/config/doc/play-config-doc-screen.tsx
Normal file
560
src/modules/config/doc/play-config-doc-screen.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminPlayTypes,
|
||||
getPlayConfigVersion,
|
||||
getPlayConfigVersions,
|
||||
patchAdminPlayType,
|
||||
postPlayConfigVersion,
|
||||
publishPlayConfigVersion,
|
||||
putPlayConfigItems,
|
||||
} from "@/api/admin-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminPlayTypeRow,
|
||||
ConfigVersionSummary,
|
||||
PlayConfigItemRow,
|
||||
PlayConfigVersionDetail,
|
||||
} from "@/types/api/admin-config";
|
||||
|
||||
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 });
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void refreshTypes();
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshTypes, refreshList]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const d = await getPlayConfigVersion(id);
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0 || selectedId !== "") {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
|
||||
const active = list.find((x) => x.status === "active");
|
||||
const pick = drafts[0] ?? active ?? list.sort((a, b) => b.id - a.id)[0];
|
||||
if (pick) {
|
||||
setSelectedId(String(pick.id));
|
||||
}
|
||||
});
|
||||
}, [list, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === "") {
|
||||
return;
|
||||
}
|
||||
const id = Number(selectedId);
|
||||
if (!Number.isFinite(id)) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadDetail(id);
|
||||
});
|
||||
}, [selectedId, loadDetail]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 });
|
||||
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 nextRows = draftRows.map((r, i) =>
|
||||
i === idx ? { ...r, is_enabled: next } : r,
|
||||
);
|
||||
const payload = nextRows.map((r) => ({
|
||||
play_code: r.play_code,
|
||||
is_enabled: r.is_enabled,
|
||||
min_bet_amount: r.min_bet_amount,
|
||||
max_bet_amount: r.max_bet_amount,
|
||||
display_order: r.display_order,
|
||||
rule_text_zh: r.rule_text_zh,
|
||||
rule_text_en: r.rule_text_en,
|
||||
rule_text_ne: r.rule_text_ne,
|
||||
extra_config_json: r.extra_config_json,
|
||||
}));
|
||||
const 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() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
for (const r of draftRows) {
|
||||
if (r.min_bet_amount > r.max_bet_amount) {
|
||||
toast.error(`${r.play_code}: 最小额不能大于最大额`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = draftRows.map((r) => ({
|
||||
play_code: r.play_code,
|
||||
is_enabled: r.is_enabled,
|
||||
min_bet_amount: r.min_bet_amount,
|
||||
max_bet_amount: r.max_bet_amount,
|
||||
display_order: r.display_order,
|
||||
rule_text_zh: r.rule_text_zh,
|
||||
rule_text_en: r.rule_text_en,
|
||||
rule_text_ne: r.rule_text_ne,
|
||||
extra_config_json: r.extra_config_json,
|
||||
}));
|
||||
const d = await putPlayConfigItems(detail.id, payload);
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
toast.success("已保存草稿");
|
||||
void refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const d = await publishPlayConfigVersion(detail.id);
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
toast.success("已启用为当前版本");
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewDraft() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const active = list.find((x) => x.status === "active");
|
||||
const d = await postPlayConfigVersion({
|
||||
reason: `draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`已创建草稿 v${d.version_no}`);
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openRuleEditor(play_code: string) {
|
||||
const item = itemsByCode.get(play_code);
|
||||
setRulePlayCode(play_code);
|
||||
setRuleDraftZh(item?.rule_text_zh ?? "");
|
||||
setRuleDialogOpen(true);
|
||||
}
|
||||
|
||||
function saveRuleZh() {
|
||||
if (!rulePlayCode) {
|
||||
return;
|
||||
}
|
||||
updateConfigRow(rulePlayCode, { rule_text_zh: ruleDraftZh.trim() || null });
|
||||
setRuleDialogOpen(false);
|
||||
setRulePlayCode(null);
|
||||
toast.message("规则说明已写入本地草稿,记得保存草稿");
|
||||
}
|
||||
|
||||
const activeHead = list.find((x) => x.status === "active");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-lg">玩法配置</CardTitle>
|
||||
<CardDescription>
|
||||
对齐界面文档 §5.4:玩法名称、分类、状态、显示名称、排序、限额与规则说明;版本化明细需草稿编辑后发布。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshList()} disabled={loadingList}>
|
||||
刷新版本
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshTypes()} disabled={loadingTypes}>
|
||||
刷新目录
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}>
|
||||
新建草稿
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSaveDraft()} disabled={!isDraft || saving || loadingDetail}>
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
onClick={() => void handlePublish()}
|
||||
disabled={!isDraft || saving || loadingDetail}
|
||||
>
|
||||
启用为当前版本
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{detail ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前版本:v{detail.version_no} ·{" "}
|
||||
{detail.status === "active" ? "生效中" : detail.status === "draft" ? "草稿" : "已归档"}
|
||||
{activeHead ? (
|
||||
<>
|
||||
{" "}
|
||||
· 线上生效版本 v{activeHead.version_no}
|
||||
{activeHead.effective_at ? ` · ${activeHead.effective_at}` : ""}
|
||||
</>
|
||||
) : null}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
{" "}
|
||||
— 限额与规则为只读,请先新建草稿。
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{loadingDetail || loadingTypes ? (
|
||||
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>玩法名称</TableHead>
|
||||
<TableHead className="w-[100px]">分类</TableHead>
|
||||
<TableHead className="w-[88px] text-center">状态</TableHead>
|
||||
<TableHead className="min-w-[120px]">显示名称</TableHead>
|
||||
<TableHead className="w-[88px]">排序</TableHead>
|
||||
<TableHead className="w-[110px]">最小下注</TableHead>
|
||||
<TableHead className="w-[110px]">最大下注</TableHead>
|
||||
<TableHead className="w-[140px]">操作</TableHead>
|
||||
</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>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={t.is_enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(v) => {
|
||||
openToggleConfirm(t.play_code, v === true);
|
||||
}}
|
||||
aria-label={`启用 ${t.play_code}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
className="h-8 text-sm"
|
||||
defaultValue={t.display_name_zh ?? ""}
|
||||
key={`${t.play_code}-dn-${t.updated_at}`}
|
||||
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 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8 font-mono tabular-nums"
|
||||
defaultValue={t.sort_order}
|
||||
key={`${t.play_code}-so-${t.updated_at}`}
|
||||
disabled={saving}
|
||||
onBlur={(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 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</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}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!item || !isDraft || saving}
|
||||
onClick={() => openRuleEditor(t.play_code)}
|
||||
>
|
||||
规则说明
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<DialogTitle>规则说明(中文)</DialogTitle>
|
||||
<DialogDescription>
|
||||
玩法 {rulePlayCode ?? "—"};保存前内容仅写入草稿,需点「保存草稿」后随版本发布。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rule-zh">rule_text_zh</Label>
|
||||
<textarea
|
||||
id="rule-zh"
|
||||
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
|
||||
value={ruleDraftZh}
|
||||
onChange={(e) => setRuleDraftZh(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRuleDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={saveRuleZh}>
|
||||
应用到草稿
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
25
src/modules/config/doc/prize-scopes.ts
Normal file
25
src/modules/config/doc/prize-scopes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** 对齐界面文档 §5.5:头 / 二 / 三 / 特别 / 安慰(含 starter / consolation 映射)。 */
|
||||
|
||||
export const PRIZE_SCOPE_ORDER = [
|
||||
"first",
|
||||
"second",
|
||||
"third",
|
||||
"starter",
|
||||
"consolation",
|
||||
] as const;
|
||||
|
||||
export type PrizeScopeCode = (typeof PRIZE_SCOPE_ORDER)[number];
|
||||
|
||||
export const PRIZE_SCOPE_LABELS: Record<PrizeScopeCode, string> = {
|
||||
first: "头奖赔率",
|
||||
second: "二奖赔率",
|
||||
third: "三奖赔率",
|
||||
starter: "特别奖赔率",
|
||||
consolation: "安慰奖赔率",
|
||||
};
|
||||
|
||||
/** 文档示意:特别奖 / 安慰奖按组数展示时的倍数提示(仅文案)。 */
|
||||
export const PRIZE_SCOPE_MULTIPLIER_HINT: Partial<Record<PrizeScopeCode, string>> = {
|
||||
starter: "× 10",
|
||||
consolation: "× 10",
|
||||
};
|
||||
347
src/modules/config/doc/rebate-config-doc-screen.tsx
Normal file
347
src/modules/config/doc/rebate-config-doc-screen.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminPlayTypes,
|
||||
getOddsVersion,
|
||||
getOddsVersions,
|
||||
postOddsVersion,
|
||||
publishOddsVersion,
|
||||
putOddsItems,
|
||||
} from "@/api/admin-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminPlayTypeRow,
|
||||
ConfigVersionSummary,
|
||||
OddsItemRow,
|
||||
OddsVersionDetail,
|
||||
} from "@/types/api/admin-config";
|
||||
|
||||
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
|
||||
|
||||
function rateToPercentUi(rateStr: string): string {
|
||||
const n = Number.parseFloat(rateStr);
|
||||
if (!Number.isFinite(n)) {
|
||||
return "0";
|
||||
}
|
||||
return String(Math.round(n * 10000) / 100);
|
||||
}
|
||||
|
||||
function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPlayTypeRow[]): string {
|
||||
const codes = typeList.filter((t) => (t.dimension ?? 2) === dim).map((t) => t.play_code);
|
||||
const scope = PRIZE_SCOPE_ORDER[0];
|
||||
const hit = rows.find((r) => codes.includes(r.play_code) && r.prize_scope === scope);
|
||||
return hit ? rateToPercentUi(String(hit.rebate_rate)) : "0";
|
||||
}
|
||||
|
||||
export function RebateConfigDocScreen() {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
|
||||
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
||||
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [p2, setP2] = useState("0");
|
||||
const [p3, setP3] = useState("0");
|
||||
const [p4, setP4] = useState("0");
|
||||
|
||||
const refreshTypes = useCallback(async () => {
|
||||
try {
|
||||
const d = await getAdminPlayTypes();
|
||||
setTypes(d.items);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "加载玩法失败");
|
||||
setTypes([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
try {
|
||||
const d = await getOddsVersions({ per_page: 50 });
|
||||
setListRows(d.items);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本失败");
|
||||
setListRows([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(async () => {
|
||||
setLoading(true);
|
||||
await refreshTypes();
|
||||
await refreshList();
|
||||
setLoading(false);
|
||||
});
|
||||
}, [refreshTypes, refreshList]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const pt = await getAdminPlayTypes();
|
||||
const typeList = pt.items;
|
||||
setTypes(typeList);
|
||||
const d = await getOddsVersion(id);
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
setDetail(d);
|
||||
setDraftRows(rows);
|
||||
setP2(inferPercentFrom(2, rows, typeList));
|
||||
setP3(inferPercentFrom(3, rows, typeList));
|
||||
setP4(inferPercentFrom(4, rows, typeList));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "加载明细失败");
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRows.length === 0 || selectedId !== "") {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const drafts = listRows.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
|
||||
const active = listRows.find((x) => x.status === "active");
|
||||
const pick = drafts[0] ?? active ?? [...listRows].sort((a, b) => b.id - a.id)[0];
|
||||
if (pick) {
|
||||
setSelectedId(String(pick.id));
|
||||
}
|
||||
});
|
||||
}, [listRows, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === "") {
|
||||
return;
|
||||
}
|
||||
const id = Number(selectedId);
|
||||
if (!Number.isFinite(id)) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadDetail(id);
|
||||
});
|
||||
}, [selectedId, loadDetail]);
|
||||
|
||||
const typesByCode = useMemo(() => {
|
||||
const m = new Map<string, AdminPlayTypeRow>();
|
||||
for (const t of types) {
|
||||
m.set(t.play_code, t);
|
||||
}
|
||||
return m;
|
||||
}, [types]);
|
||||
|
||||
const isDraft = detail?.status === "draft";
|
||||
|
||||
function applyDimensionPercentsToRows(rows: OddsItemRow[]): OddsItemRow[] {
|
||||
const r2 = Number.parseFloat(p2);
|
||||
const r3 = Number.parseFloat(p3);
|
||||
const r4 = Number.parseFloat(p4);
|
||||
const rate2 = Number.isFinite(r2) ? r2 / 100 : 0;
|
||||
const rate3 = Number.isFinite(r3) ? r3 / 100 : 0;
|
||||
const rate4 = Number.isFinite(r4) ? r4 / 100 : 0;
|
||||
return rows.map((row) => {
|
||||
const t = typesByCode.get(row.play_code);
|
||||
const dim = (t?.dimension ?? 2) as 2 | 3 | 4;
|
||||
const rate = dim === 4 ? rate4 : dim === 3 ? rate3 : rate2;
|
||||
return { ...row, rebate_rate: String(rate) };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const nextRows = applyDimensionPercentsToRows(draftRows);
|
||||
const payload = nextRows.map((r) => ({
|
||||
play_code: r.play_code,
|
||||
prize_scope: r.prize_scope,
|
||||
odds_value: r.odds_value,
|
||||
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
||||
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
||||
currency_code: r.currency_code,
|
||||
extra_config_json: r.extra_config_json,
|
||||
}));
|
||||
const d = await putOddsItems(detail.id, payload);
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
setDetail(d);
|
||||
setDraftRows(rows);
|
||||
setP2(inferPercentFrom(2, rows, types));
|
||||
setP3(inferPercentFrom(3, rows, types));
|
||||
setP4(inferPercentFrom(4, rows, types));
|
||||
toast.success("已保存草稿");
|
||||
void refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const d = await publishOddsVersion(detail.id);
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
setDetail(d);
|
||||
setDraftRows(rows);
|
||||
setP2(inferPercentFrom(2, rows, types));
|
||||
setP3(inferPercentFrom(3, rows, types));
|
||||
setP4(inferPercentFrom(4, rows, types));
|
||||
toast.success("已发布赔率版本(含回水)");
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewDraft() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const active = listRows.find((x) => x.status === "active");
|
||||
const d = await postOddsVersion({
|
||||
reason: `rebate draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`已创建草稿 v${d.version_no}`);
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
setDetail(d);
|
||||
setDraftRows(rows);
|
||||
setP2(inferPercentFrom(2, rows, types));
|
||||
setP3(inferPercentFrom(3, rows, types));
|
||||
setP4(inferPercentFrom(4, rows, types));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activeHead = listRows.find((x) => x.status === "active");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-lg">佣金 / 回水配置</CardTitle>
|
||||
<CardDescription>
|
||||
对齐 §5.6:按 2D / 3D / 4D 维度批量写入 rebate_rate(与赔率共用 odds_versions);中奖是否享受字段待接入。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 max-w-xl">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshList()} disabled={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}>
|
||||
新建草稿
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}>
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
onClick={() => void handlePublish()}
|
||||
disabled={!isDraft || saving || loadingDetail}
|
||||
>
|
||||
发布生效
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{detail ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
编辑版本 v{detail.version_no} · {detail.status === "draft" ? "草稿" : detail.status === "active" ? "生效中" : "已归档"}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400"> — 请先新建草稿再改回水。</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>2D 回水率(%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
className="font-mono tabular-nums"
|
||||
disabled={!isDraft || saving}
|
||||
value={p2}
|
||||
onChange={(e) => setP2(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>3D 回水率(%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
className="font-mono tabular-nums"
|
||||
disabled={!isDraft || saving}
|
||||
value={p3}
|
||||
onChange={(e) => setP3(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>4D 回水率(%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
className="font-mono tabular-nums"
|
||||
disabled={!isDraft || saving}
|
||||
value={p4}
|
||||
onChange={(e) => setP4(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4">
|
||||
<Checkbox id="win-enjoy" checked aria-disabled disabled aria-label="中奖是否享受回水" />
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
|
||||
中奖是否享受回水
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
界面占位:后续可与风控 / 结算规则字段对齐并持久化。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1 text-sm">
|
||||
<span className="text-muted-foreground">生效时间(当前线上赔率版本)</span>
|
||||
<span className="font-mono text-xs">
|
||||
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading || loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
545
src/modules/config/doc/risk-cap-doc-screen.tsx
Normal file
545
src/modules/config/doc/risk-cap-doc-screen.tsx
Normal file
@@ -0,0 +1,545 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getRiskCapVersion,
|
||||
getRiskCapVersions,
|
||||
postRiskCapVersion,
|
||||
publishRiskCapVersion,
|
||||
putRiskCapItems,
|
||||
} from "@/api/admin-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
ConfigVersionSummary,
|
||||
RiskCapItemRow,
|
||||
RiskCapVersionDetail,
|
||||
} from "@/types/api/admin-config";
|
||||
|
||||
type DraftRiskRow = Omit<RiskCapItemRow, "id"> & { clientKey: string };
|
||||
|
||||
function newRow(): DraftRiskRow {
|
||||
return {
|
||||
clientKey: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
draw_id: null,
|
||||
normalized_number: "0000",
|
||||
cap_amount: 0,
|
||||
cap_type: "per_number",
|
||||
};
|
||||
}
|
||||
|
||||
export function RiskCapDocScreen() {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState<RiskCapVersionDetail | null>(null);
|
||||
const [draftRows, setDraftRows] = useState<DraftRiskRow[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [defaultCapStr, setDefaultCapStr] = useState("");
|
||||
const [syncOpen, setSyncOpen] = useState(false);
|
||||
|
||||
const [occSearch, setOccSearch] = useState("");
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await getRiskCapVersions({ per_page: 50 });
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshList]);
|
||||
|
||||
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
||||
if (rows.length === 0) {
|
||||
setDefaultCapStr("");
|
||||
return;
|
||||
}
|
||||
const amounts = [...new Set(rows.map((r) => r.cap_amount))];
|
||||
setDefaultCapStr(amounts.length === 1 ? String(amounts[0]) : "");
|
||||
}
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const d = await getRiskCapVersion(id);
|
||||
setDetail(d);
|
||||
const mapped = d.items.map((it) => ({
|
||||
clientKey: `srv-${it.id}`,
|
||||
draw_id: it.draw_id,
|
||||
normalized_number: it.normalized_number,
|
||||
cap_amount: it.cap_amount,
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(mapped);
|
||||
syncDefaultCapFromRows(mapped);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
syncDefaultCapFromRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0 || selectedId !== "") {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
|
||||
const active = list.find((x) => x.status === "active");
|
||||
const pick = drafts[0] ?? active ?? [...list].sort((a, b) => b.id - a.id)[0];
|
||||
if (pick) {
|
||||
setSelectedId(String(pick.id));
|
||||
}
|
||||
});
|
||||
}, [list, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === "") {
|
||||
return;
|
||||
}
|
||||
const id = Number(selectedId);
|
||||
if (!Number.isFinite(id)) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadDetail(id);
|
||||
});
|
||||
}, [selectedId, loadDetail]);
|
||||
|
||||
const isDraft = detail?.status === "draft";
|
||||
|
||||
const updateRow = (idx: number, patch: Partial<DraftRiskRow>) => {
|
||||
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
function removeRow(idx: number) {
|
||||
setDraftRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
if (draftRows.length === 0) {
|
||||
toast.error("至少保留一行封顶配置");
|
||||
return;
|
||||
}
|
||||
for (const r of draftRows) {
|
||||
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
|
||||
toast.error(`号码须为 4 位数字:${r.normalized_number}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = draftRows.map((r) => ({
|
||||
draw_id: r.draw_id && r.draw_id > 0 ? r.draw_id : null,
|
||||
normalized_number: r.normalized_number,
|
||||
cap_amount: r.cap_amount,
|
||||
cap_type: r.cap_type,
|
||||
}));
|
||||
const d = await putRiskCapItems(detail.id, payload);
|
||||
setDetail(d);
|
||||
const saved = d.items.map((it) => ({
|
||||
clientKey: `srv-${it.id}`,
|
||||
draw_id: it.draw_id,
|
||||
normalized_number: it.normalized_number,
|
||||
cap_amount: it.cap_amount,
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(saved);
|
||||
syncDefaultCapFromRows(saved);
|
||||
toast.success("已保存草稿");
|
||||
void refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const d = await publishRiskCapVersion(detail.id);
|
||||
setDetail(d);
|
||||
const pub = d.items.map((it) => ({
|
||||
clientKey: `srv-${it.id}`,
|
||||
draw_id: it.draw_id,
|
||||
normalized_number: it.normalized_number,
|
||||
cap_amount: it.cap_amount,
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(pub);
|
||||
syncDefaultCapFromRows(pub);
|
||||
toast.success("已启用为当前版本");
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewDraft() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const active = list.find((x) => x.status === "active");
|
||||
const d = await postRiskCapVersion({
|
||||
reason: `draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`已创建草稿 v${d.version_no}`);
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
const nd = d.items.map((it) => ({
|
||||
clientKey: `srv-${it.id}`,
|
||||
draw_id: it.draw_id,
|
||||
normalized_number: it.normalized_number,
|
||||
cap_amount: it.cap_amount,
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(nd);
|
||||
syncDefaultCapFromRows(nd);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDefaultCapToAll() {
|
||||
const n = Number.parseInt(defaultCapStr, 10);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
toast.error("请输入有效的封顶金额");
|
||||
return;
|
||||
}
|
||||
setDraftRows((prev) => prev.map((r) => ({ ...r, cap_amount: n })));
|
||||
setSyncOpen(false);
|
||||
toast.message("已写入本地草稿,记得保存草稿");
|
||||
}
|
||||
|
||||
const sortedList = useMemo(() => [...list].sort((a, b) => b.id - a.id), [list]);
|
||||
|
||||
const occFiltered = useMemo(() => {
|
||||
const q = occSearch.trim();
|
||||
if (!q) {
|
||||
return draftRows;
|
||||
}
|
||||
return draftRows.filter((r) => r.normalized_number.includes(q));
|
||||
}, [draftRows, occSearch]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-lg">
|
||||
风控封顶
|
||||
{detail ? (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
· 版本 v{detail.version_no}
|
||||
</span>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
对齐 §5.7:默认封顶批量同步、特殊封顶表与占位占用列;实时占用需接入注单聚合后展示。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="grid gap-2 min-w-[260px]">
|
||||
<Label>配置版本</Label>
|
||||
<Select
|
||||
value={selectedId}
|
||||
onValueChange={(v) => setSelectedId(v ?? "")}
|
||||
disabled={loadingList || sortedList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingList ? "加载中…" : "选择版本"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedList.map((v) => (
|
||||
<SelectItem key={v.id} value={String(v.id)}>
|
||||
#{v.id} · v{v.version_no} ·{" "}
|
||||
{v.status === "active"
|
||||
? "生效中"
|
||||
: v.status === "draft"
|
||||
? "草稿"
|
||||
: "已归档"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshList()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}>
|
||||
新建草稿
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}>
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
onClick={() => void handlePublish()}
|
||||
disabled={!isDraft || saving || loadingDetail}
|
||||
>
|
||||
启用为当前版本
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{detail ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
生效时间:{detail.effective_at ? formatDt(detail.effective_at) : "—"} · 备注:{detail.reason ?? "—"}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400"> — 只读,请先新建草稿。</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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">
|
||||
将下列金额同步到当前草稿中的<strong>全部号码行</strong>(适用于统一基数快速调整)。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="default-cap">封顶金额(最小货币单位)</Label>
|
||||
<Input
|
||||
id="default-cap"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-[220px] font-mono tabular-nums"
|
||||
disabled={!isDraft || saving}
|
||||
value={defaultCapStr}
|
||||
onChange={(e) => setDefaultCapStr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" disabled={!isDraft || saving} onClick={() => setSyncOpen(true)}>
|
||||
更新
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">特殊封顶</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!isDraft || saving}
|
||||
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
|
||||
>
|
||||
+ 添加特殊封顶
|
||||
</Button>
|
||||
</div>
|
||||
{loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">加载明细…</p>
|
||||
) : draftRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">无明细行。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[110px]">号码</TableHead>
|
||||
<TableHead className="w-[140px]">封顶金额</TableHead>
|
||||
<TableHead className="w-[90px] text-right">已占用</TableHead>
|
||||
<TableHead className="w-[90px] text-right">剩余</TableHead>
|
||||
<TableHead className="w-[72px] text-center">售罄</TableHead>
|
||||
<TableHead className="w-[160px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{draftRows.map((r, idx) => (
|
||||
<TableRow key={r.clientKey}>
|
||||
<TableCell>
|
||||
<Input
|
||||
className="h-8 font-mono tabular-nums"
|
||||
maxLength={4}
|
||||
disabled={!isDraft || saving}
|
||||
value={r.normalized_number}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 font-mono tabular-nums"
|
||||
disabled={!isDraft || saving}
|
||||
value={r.cap_amount}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
cap_amount: Number.parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground tabular-nums text-sm">—</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground tabular-nums text-sm">—</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground text-sm">—</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
disabled={!isDraft || saving || draftRows.length <= 1}
|
||||
onClick={() => removeRow(idx)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">全部号码占用情况</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
占位界面:筛选与导出待接入注单汇总;下列数据仍来源于当前草稿号码列表。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="occ-search">搜索号码</Label>
|
||||
<Input
|
||||
id="occ-search"
|
||||
className="w-[140px] font-mono"
|
||||
placeholder="如 8888"
|
||||
value={occSearch}
|
||||
onChange={(e) => setOccSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => toast.message("售罄 / 高风险筛选待接入")}>
|
||||
筛选预设…
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.message("导出 CSV 待接入")}
|
||||
>
|
||||
导出 CSV
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>号码</TableHead>
|
||||
<TableHead className="text-right">已占用</TableHead>
|
||||
<TableHead className="text-right">剩余</TableHead>
|
||||
<TableHead className="text-right">占比</TableHead>
|
||||
<TableHead className="text-center">售罄</TableHead>
|
||||
<TableHead className="w-[140px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{occFiltered.map((r) => (
|
||||
<TableRow key={`occ-${r.clientKey}`}>
|
||||
<TableCell className="font-mono text-sm">{r.normalized_number}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">—</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">—</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">—</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" size="sm" disabled>
|
||||
关闭
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>同步默认封顶</DialogTitle>
|
||||
<DialogDescription>
|
||||
将把当前列表中每个号码行的封顶金额统一设为 {defaultCapStr || "(空)"}。此操作仅修改草稿,确认后请保存草稿。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={applyDefaultCapToAll}>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
14
src/modules/config/meta.ts
Normal file
14
src/modules/config/meta.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const configHubMeta = {
|
||||
title: "运营配置",
|
||||
description: "玩法、赔率、回水、风控封顶(界面文档 §5.4–§5.7)",
|
||||
};
|
||||
|
||||
/** @deprecated 路由重定向至 `/admin/config/plays` */
|
||||
export const configPlaySwitchesMeta = { title: "玩法开关 · 运营配置" };
|
||||
/** @deprecated 路由重定向至 `/admin/config/plays` */
|
||||
export const configPlayLimitsMeta = { title: "最小/最大下注 · 运营配置" };
|
||||
export const configPlayConfigMeta = { title: "玩法配置 · 运营配置" };
|
||||
export const configOddsMeta = { title: "赔率配置 · 运营配置" };
|
||||
export const configRebateMeta = { title: "佣金 / 回水 · 运营配置" };
|
||||
export const configRiskCapMeta = { title: "风控封顶 · 运营配置" };
|
||||
export const configVersionsMeta = { title: "配置版本历史 · 运营配置" };
|
||||
@@ -245,7 +245,9 @@ function TransferOrdersPanel(): React.ReactElement {
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
const runSearch = () => {
|
||||
@@ -505,7 +507,9 @@ function WalletTxnsPanel(): React.ReactElement {
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
const runSearch = () => {
|
||||
|
||||
87
src/types/api/admin-config.ts
Normal file
87
src/types/api/admin-config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/** GET /admin/play-types */
|
||||
export type AdminPlayTypeRow = {
|
||||
id: number;
|
||||
play_code: string;
|
||||
category: string;
|
||||
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;
|
||||
sort_order: number;
|
||||
supports_multi_number: boolean;
|
||||
reserved_rule_json: unknown;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type AdminPlayTypesData = {
|
||||
items: AdminPlayTypeRow[];
|
||||
};
|
||||
|
||||
export type PaginatedMeta = {
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
};
|
||||
|
||||
export type ConfigVersionSummary = {
|
||||
id: number;
|
||||
version_no: number;
|
||||
status: string;
|
||||
effective_at: string | null;
|
||||
updated_by: number | null;
|
||||
reason: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type PlayConfigItemRow = {
|
||||
id: number;
|
||||
play_code: string;
|
||||
is_enabled: boolean;
|
||||
min_bet_amount: number;
|
||||
max_bet_amount: number;
|
||||
display_order: number;
|
||||
rule_text_zh: string | null;
|
||||
rule_text_en: string | null;
|
||||
rule_text_ne: string | null;
|
||||
extra_config_json: unknown;
|
||||
};
|
||||
|
||||
export type PlayConfigVersionDetail = ConfigVersionSummary & {
|
||||
items: PlayConfigItemRow[];
|
||||
};
|
||||
|
||||
export type OddsItemRow = {
|
||||
id: number;
|
||||
play_code: string;
|
||||
prize_scope: string;
|
||||
odds_value: number;
|
||||
rebate_rate: string;
|
||||
commission_rate: string;
|
||||
currency_code: string;
|
||||
extra_config_json: unknown;
|
||||
};
|
||||
|
||||
export type OddsVersionDetail = ConfigVersionSummary & {
|
||||
items: OddsItemRow[];
|
||||
};
|
||||
|
||||
export type RiskCapItemRow = {
|
||||
id: number;
|
||||
draw_id: number | null;
|
||||
normalized_number: string;
|
||||
cap_amount: number;
|
||||
cap_type: string;
|
||||
};
|
||||
|
||||
export type RiskCapVersionDetail = ConfigVersionSummary & {
|
||||
items: RiskCapItemRow[];
|
||||
};
|
||||
|
||||
export type ConfigVersionListData = {
|
||||
items: ConfigVersionSummary[];
|
||||
meta: PaginatedMeta;
|
||||
};
|
||||
@@ -22,3 +22,15 @@ export type {
|
||||
AdminWalletTxnItem,
|
||||
AdminWalletTxnListData,
|
||||
} from "./admin-wallet";
|
||||
export type {
|
||||
AdminPlayTypeRow,
|
||||
AdminPlayTypesData,
|
||||
ConfigVersionListData,
|
||||
ConfigVersionSummary,
|
||||
OddsItemRow,
|
||||
OddsVersionDetail,
|
||||
PlayConfigItemRow,
|
||||
PlayConfigVersionDetail,
|
||||
RiskCapItemRow,
|
||||
RiskCapVersionDetail,
|
||||
} from "./admin-config";
|
||||
|
||||
Reference in New Issue
Block a user