From 78045de9a3aff7413d65668848ca755351e9cd8a Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 11 May 2026 10:08:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=92=8C=E6=9B=B4=E6=96=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=AF=BC=E8=88=AA=EF=BC=8C=E4=BC=98=E5=8C=96=E9=92=B1?= =?UTF-8?q?=E5=8C=85=E6=8E=A7=E5=88=B6=E5=8F=B0=E5=8A=A0=E8=BD=BD=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin-config.ts | 146 +++++ src/app/admin/(shell)/config/layout.tsx | 14 + src/app/admin/(shell)/config/odds/page.tsx | 16 + src/app/admin/(shell)/config/page.tsx | 66 ++ .../admin/(shell)/config/play-limits/page.tsx | 5 + .../(shell)/config/play-switches/page.tsx | 5 + src/app/admin/(shell)/config/plays/page.tsx | 16 + .../(shell)/config/rebate-commission/page.tsx | 5 + src/app/admin/(shell)/config/rebate/page.tsx | 16 + .../admin/(shell)/config/risk-cap/page.tsx | 16 + .../admin/(shell)/config/versions/page.tsx | 16 + src/modules/_config/admin-nav-icons.tsx | 2 + src/modules/_config/admin-nav.ts | 2 + src/modules/config/config-status-badge.tsx | 19 + src/modules/config/config-subnav.tsx | 50 ++ .../config/config-versions-console.tsx | 139 +++++ .../config/doc/odds-config-doc-screen.tsx | 576 ++++++++++++++++++ .../config/doc/play-config-doc-screen.tsx | 560 +++++++++++++++++ src/modules/config/doc/prize-scopes.ts | 25 + .../config/doc/rebate-config-doc-screen.tsx | 347 +++++++++++ .../config/doc/risk-cap-doc-screen.tsx | 545 +++++++++++++++++ src/modules/config/meta.ts | 14 + src/modules/wallet/wallet-console.tsx | 8 +- src/types/api/admin-config.ts | 87 +++ src/types/api/index.ts | 12 + 25 files changed, 2705 insertions(+), 2 deletions(-) create mode 100644 src/api/admin-config.ts create mode 100644 src/app/admin/(shell)/config/layout.tsx create mode 100644 src/app/admin/(shell)/config/odds/page.tsx create mode 100644 src/app/admin/(shell)/config/page.tsx create mode 100644 src/app/admin/(shell)/config/play-limits/page.tsx create mode 100644 src/app/admin/(shell)/config/play-switches/page.tsx create mode 100644 src/app/admin/(shell)/config/plays/page.tsx create mode 100644 src/app/admin/(shell)/config/rebate-commission/page.tsx create mode 100644 src/app/admin/(shell)/config/rebate/page.tsx create mode 100644 src/app/admin/(shell)/config/risk-cap/page.tsx create mode 100644 src/app/admin/(shell)/config/versions/page.tsx create mode 100644 src/modules/config/config-status-badge.tsx create mode 100644 src/modules/config/config-subnav.tsx create mode 100644 src/modules/config/config-versions-console.tsx create mode 100644 src/modules/config/doc/odds-config-doc-screen.tsx create mode 100644 src/modules/config/doc/play-config-doc-screen.tsx create mode 100644 src/modules/config/doc/prize-scopes.ts create mode 100644 src/modules/config/doc/rebate-config-doc-screen.tsx create mode 100644 src/modules/config/doc/risk-cap-doc-screen.tsx create mode 100644 src/modules/config/meta.ts create mode 100644 src/types/api/admin-config.ts diff --git a/src/api/admin-config.ts b/src/api/admin-config.ts new file mode 100644 index 0000000..c3544db --- /dev/null +++ b/src/api/admin-config.ts @@ -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 { + return adminRequest.get(`${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 { + return adminRequest.patch(`${A}/play-types/${encodeURIComponent(playCode)}`, body); +} + +export async function getPlayConfigVersions(params?: { + status?: string; + page?: number; + per_page?: number; +}): Promise { + return adminRequest.get(`${A}/config/play-versions`, { params }); +} + +export async function getPlayConfigVersion(id: number): Promise { + return adminRequest.get(`${A}/config/play-versions/${id}`); +} + +export async function postPlayConfigVersion(body?: { + reason?: string | null; + clone_from_version_id?: number | null; +}): Promise { + 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 { + return adminRequest.put(`${A}/config/play-versions/${id}/items`, { items }); +} + +export async function publishPlayConfigVersion(id: number): Promise { + return adminRequest.post(`${A}/config/play-versions/${id}/publish`); +} + +export async function getOddsVersions(params?: { + status?: string; + page?: number; + per_page?: number; +}): Promise { + return adminRequest.get(`${A}/config/odds-versions`, { params }); +} + +export async function getOddsVersion(id: number): Promise { + return adminRequest.get(`${A}/config/odds-versions/${id}`); +} + +export async function postOddsVersion(body?: { + reason?: string | null; + clone_from_version_id?: number | null; +}): Promise { + 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 { + return adminRequest.put(`${A}/config/odds-versions/${id}/items`, { items }); +} + +export async function publishOddsVersion(id: number): Promise { + return adminRequest.post(`${A}/config/odds-versions/${id}/publish`); +} + +export async function getRiskCapVersions(params?: { + status?: string; + page?: number; + per_page?: number; +}): Promise { + return adminRequest.get(`${A}/config/risk-cap-versions`, { params }); +} + +export async function getRiskCapVersion(id: number): Promise { + return adminRequest.get(`${A}/config/risk-cap-versions/${id}`); +} + +export async function postRiskCapVersion(body?: { + reason?: string | null; + clone_from_version_id?: number | null; +}): Promise { + 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 { + return adminRequest.put(`${A}/config/risk-cap-versions/${id}/items`, { items }); +} + +export async function publishRiskCapVersion(id: number): Promise { + return adminRequest.post(`${A}/config/risk-cap-versions/${id}/publish`); +} diff --git a/src/app/admin/(shell)/config/layout.tsx b/src/app/admin/(shell)/config/layout.tsx new file mode 100644 index 0000000..086a4c7 --- /dev/null +++ b/src/app/admin/(shell)/config/layout.tsx @@ -0,0 +1,14 @@ +import { ConfigSubNav } from "@/modules/config/config-subnav"; + +export default function AdminConfigLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/admin/(shell)/config/odds/page.tsx b/src/app/admin/(shell)/config/odds/page.tsx new file mode 100644 index 0000000..532ac54 --- /dev/null +++ b/src/app/admin/(shell)/config/odds/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/admin/(shell)/config/page.tsx b/src/app/admin/(shell)/config/page.tsx new file mode 100644 index 0000000..34c4826 --- /dev/null +++ b/src/app/admin/(shell)/config/page.tsx @@ -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 ( + +
+

{configHubMeta.title}

+

{configHubMeta.description}

+
+
+ {SECTIONS.map((s) => ( + + + + {s.title} + {s.description} + + + + ))} +
+
+ ); +} diff --git a/src/app/admin/(shell)/config/play-limits/page.tsx b/src/app/admin/(shell)/config/play-limits/page.tsx new file mode 100644 index 0000000..c74e269 --- /dev/null +++ b/src/app/admin/(shell)/config/play-limits/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminConfigPlayLimitsRedirectPage() { + redirect("/admin/config/plays"); +} diff --git a/src/app/admin/(shell)/config/play-switches/page.tsx b/src/app/admin/(shell)/config/play-switches/page.tsx new file mode 100644 index 0000000..f250705 --- /dev/null +++ b/src/app/admin/(shell)/config/play-switches/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminConfigPlaySwitchesRedirectPage() { + redirect("/admin/config/plays"); +} diff --git a/src/app/admin/(shell)/config/plays/page.tsx b/src/app/admin/(shell)/config/plays/page.tsx new file mode 100644 index 0000000..c266ee9 --- /dev/null +++ b/src/app/admin/(shell)/config/plays/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/admin/(shell)/config/rebate-commission/page.tsx b/src/app/admin/(shell)/config/rebate-commission/page.tsx new file mode 100644 index 0000000..7534406 --- /dev/null +++ b/src/app/admin/(shell)/config/rebate-commission/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminConfigRebateCommissionRedirectPage() { + redirect("/admin/config/rebate"); +} diff --git a/src/app/admin/(shell)/config/rebate/page.tsx b/src/app/admin/(shell)/config/rebate/page.tsx new file mode 100644 index 0000000..a7c0b74 --- /dev/null +++ b/src/app/admin/(shell)/config/rebate/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/admin/(shell)/config/risk-cap/page.tsx b/src/app/admin/(shell)/config/risk-cap/page.tsx new file mode 100644 index 0000000..10cb08c --- /dev/null +++ b/src/app/admin/(shell)/config/risk-cap/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/admin/(shell)/config/versions/page.tsx b/src/app/admin/(shell)/config/versions/page.tsx new file mode 100644 index 0000000..a7fcc0c --- /dev/null +++ b/src/app/admin/(shell)/config/versions/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/modules/_config/admin-nav-icons.tsx b/src/modules/_config/admin-nav-icons.tsx index 513724a..6f7bddc 100644 --- a/src/modules/_config/admin-nav-icons.tsx +++ b/src/modules/_config/admin-nav-icons.tsx @@ -5,6 +5,7 @@ import { LogIn, Settings, ShieldAlert, + SlidersHorizontal, Ticket, Users, Wallet, @@ -18,6 +19,7 @@ export const adminNavIconBySegment: Record dashboard: LayoutDashboard, players: Users, draws: CalendarClock, + config: SlidersHorizontal, tickets: Ticket, wallet: Wallet, risk: ShieldAlert, diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index 48e2352..886324b 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -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" }, diff --git a/src/modules/config/config-status-badge.tsx b/src/modules/config/config-status-badge.tsx new file mode 100644 index 0000000..cb98bb8 --- /dev/null +++ b/src/modules/config/config-status-badge.tsx @@ -0,0 +1,19 @@ +import { Badge } from "@/components/ui/badge"; + +const LABELS: Record = { + draft: "草稿", + active: "生效中", + archived: "已归档", +}; + +export function ConfigStatusBadge({ status }: { status: string }) { + const label = LABELS[status] ?? status; + const variant = + status === "active" ? "default" : status === "draft" ? "secondary" : "outline"; + + return ( + + {label} + + ); +} diff --git a/src/modules/config/config-subnav.tsx b/src/modules/config/config-subnav.tsx new file mode 100644 index 0000000..d485116 --- /dev/null +++ b/src/modules/config/config-subnav.tsx @@ -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 ( + + ); +} diff --git a/src/modules/config/config-versions-console.tsx b/src/modules/config/config-versions-console.tsx new file mode 100644 index 0000000..f10f907 --- /dev/null +++ b/src/modules/config/config-versions-console.tsx @@ -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

加载中…

; + } + if (rows.length === 0) { + return

暂无版本记录。

; + } + return ( +
+ + + + ID + version_no + 状态 + 生效时间 + 备注 + + + + {rows.map((v) => ( + + {v.id} + {v.version_no} + + + + + {v.effective_at ? formatDt(v.effective_at) : "—"} + + + {v.reason ?? "—"} + + + ))} + +
+
+ ); +} + +export function ConfigVersionsConsole() { + const formatDt = useAdminDateTimeFormatter(); + const [playRows, setPlayRows] = useState([]); + const [oddsRows, setOddsRows] = useState([]); + const [riskRows, setRiskRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + 配置版本历史 + + 三套流水线独立版本号;每次「发布」会将旧生效版本归档。 + + + + {error ?

{error}

: null} + + + 玩法配置 + 赔率 + 风控封顶 + + + + + + + + + + + +
+
+ ); +} diff --git a/src/modules/config/doc/odds-config-doc-screen.tsx b/src/modules/config/doc/odds-config-doc-screen.tsx new file mode 100644 index 0000000..65d0469 --- /dev/null +++ b/src/modules/config/doc/odds-config-doc-screen.tsx @@ -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([]); + const [list, setList] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [detail, setDetail] = useState(null); + const [draftRows, setDraftRows] = useState([]); + const [loadingTypes, setLoadingTypes] = useState(true); + const [loadingList, setLoadingList] = useState(true); + const [loadingDetail, setLoadingDetail] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const [catTab, setCatTab] = useState("all"); + /** 用户点选的玩法;空字符串表示尚未选择,由 resolvedPlayCode 回落到分类内第一项 */ + const [playCode, setPlayCode] = useState(""); + + const [rollbackOpen, setRollbackOpen] = useState(false); + const [rollbackTarget, setRollbackTarget] = useState(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> = {}; + 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) { + setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r))); + } + + function updateOddsForScope(scope: PrizeScopeCode, patch: Partial) { + 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 ( + + + 赔率配置 + + 对齐 §5.5:分类与玩法切换编辑五档赔率;odds_value = 赔率乘数 × 10000(NPR 100 基准展示)。 + + + +
+ 分类 + {catTabs.map((t) => ( + + ))} +
+ +
+

玩法

+
+ {filteredTypes.length === 0 ? ( + 该分类下暂无玩法。 + ) : ( + filteredTypes.map((t) => ( + + )) + )} +
+
+ +
+ + +
+ + {detail ? ( +
+

+ 当前编辑版本:v{detail.version_no} ·{" "} + {detail.status === "active" ? "生效中" : detail.status === "draft" ? "草稿" : "已归档"} +

+

+ 当前生效版本: + {activeHead ? ( + <> + v{activeHead.version_no} + {activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""} + + ) : ( + "—" + )} +

+ {!isDraft ? ( +

当前为只读版本,请新建草稿后再改赔率。

+ ) : null} +
+ ) : null} + + {error ?

{error}

: null} + + {loadingDetail || loadingTypes ? ( +

加载明细…

+ ) : resolvedPlayCode ? ( +
+ {PRIZE_SCOPE_ORDER.map((scope) => { + const row = scopeRows[scope]; + const hint = PRIZE_SCOPE_MULTIPLIER_HINT[scope]; + const idx = row ? rowIndex(resolvedPlayCode, scope) : -1; + return ( +
+ + {row && idx >= 0 ? ( +
+ + updateOddsForScope(scope, { + odds_value: Number.parseInt(e.target.value, 10) || 0, + }) + } + /> + + 乘数 ×{oddsMultiplierLabel(row.odds_value)} · {row.currency_code} + +
+ ) : ( +

缺少 {scope} 行,请检查种子或版本数据。

+ )} +
+ ); + })} +
+ + setRebateForPlayPercent(e.target.value)} + /> +

写入该玩法下全部奖项档位的 rebate_rate。

+
+
+ ) : null} + +
+ + + + + 赔率版本历史 + 选择一条历史版本执行回滚(克隆为新草稿)。 + +
+ + + + 版本 + 状态 + 时间 + 操作 + + + + {sortedHistory.map((v) => ( + + v{v.version_no} + + {v.status === "active" ? "生效" : v.status === "draft" ? "草稿" : "归档"} + + + {v.updated_at ? formatDt(v.updated_at) : "—"} + + + + + + ))} + +
+
+
+
+ + + +
+
+ + + + + 确认回滚 + + 将以版本 v{rollbackTarget?.version_no} 的快照克隆为新草稿;不会直接覆盖线上生效版本。 + + + + + + + + +
+ ); +} diff --git a/src/modules/config/doc/play-config-doc-screen.tsx b/src/modules/config/doc/play-config-doc-screen.tsx new file mode 100644 index 0000000..0e92921 --- /dev/null +++ b/src/modules/config/doc/play-config-doc-screen.tsx @@ -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([]); + const [list, setList] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [detail, setDetail] = useState(null); + const [draftRows, setDraftRows] = useState([]); + const [loadingTypes, setLoadingTypes] = useState(true); + const [loadingList, setLoadingList] = useState(true); + const [loadingDetail, setLoadingDetail] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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(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(); + 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) { + 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 ( + + + 玩法配置 + + 对齐界面文档 §5.4:玩法名称、分类、状态、显示名称、排序、限额与规则说明;版本化明细需草稿编辑后发布。 + + + +
+ + + + + +
+ + {detail ? ( +

+ 当前版本:v{detail.version_no} ·{" "} + {detail.status === "active" ? "生效中" : detail.status === "draft" ? "草稿" : "已归档"} + {activeHead ? ( + <> + {" "} + · 线上生效版本 v{activeHead.version_no} + {activeHead.effective_at ? ` · ${activeHead.effective_at}` : ""} + + ) : null} + {!isDraft ? ( + + {" "} + — 限额与规则为只读,请先新建草稿。 + + ) : null} +

+ ) : null} + + {error ?

{error}

: null} + + {loadingDetail || loadingTypes ? ( +

加载中…

+ ) : ( +
+ + + + 玩法名称 + 分类 + 状态 + 显示名称 + 排序 + 最小下注 + 最大下注 + 操作 + + + + {mergedRows.map(({ type: t, item }) => ( + + {t.play_code} + {t.category} + + { + openToggleConfirm(t.play_code, v === true); + }} + aria-label={`启用 ${t.play_code}`} + /> + + + { + 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 }); + } + }} + /> + + + { + const n = Number.parseInt(e.target.value, 10); + if (Number.isFinite(n) && n !== t.sort_order) { + void patchTypeField(t.play_code, { sort_order: n }); + } + }} + /> + + + {item ? ( + + updateConfigRow(t.play_code, { + min_bet_amount: Number.parseInt(e.target.value, 10) || 0, + }) + } + /> + ) : ( + 无配置行 + )} + + + {item ? ( + + updateConfigRow(t.play_code, { + max_bet_amount: Number.parseInt(e.target.value, 10) || 0, + }) + } + /> + ) : null} + + + + + + ))} + +
+
+ )} +
+ + { + setConfirmOpen(open); + if (!open) { + setPendingToggle(null); + } + }} + > + + + 确认变更状态 + + {pendingToggle + ? `确定要${pendingToggle.next ? "启用" : "禁用"}玩法「${pendingToggle.play_code}」吗?将同步更新玩法目录与${ + isDraft ? "当前草稿" : "(非草稿时仅更新目录,配置明细请在草稿中维护)" + }。` + : null} + + + + + + + + + + + + + 规则说明(中文) + + 玩法 {rulePlayCode ?? "—"};保存前内容仅写入草稿,需点「保存草稿」后随版本发布。 + + +
+ +