feat: 添加配置模块和更新管理员导航,优化钱包控制台加载逻辑

This commit is contained in:
2026-05-11 10:08:56 +08:00
parent ac3f28459b
commit 78045de9a3
25 changed files with 2705 additions and 2 deletions

146
src/api/admin-config.ts Normal file
View 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`);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminConfigPlayLimitsRedirectPage() {
redirect("/admin/config/plays");
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminConfigPlaySwitchesRedirectPage() {
redirect("/admin/config/plays");
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminConfigRebateCommissionRedirectPage() {
redirect("/admin/config/rebate");
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,

View File

@@ -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" },

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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.5odds_value = × 10000NPR 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>
);
}

View 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>
);
}

View 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",
};

View 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>
);
}

View 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>
);
}

View 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: "配置版本历史 · 运营配置" };

View File

@@ -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 = () => {

View 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;
};

View File

@@ -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";