From 1578c7e21458d17e232db89d0fb9ea5aef0d6d0f Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 16 May 2026 10:28:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E9=87=8D=E6=9E=84=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E5=BF=83=E5=AF=BC=E8=88=AA=E4=B8=8E=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=B1=95=E7=A4=BA=EF=BC=8C=E6=94=AF=E6=8C=81=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E7=89=88=E6=9C=AC=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin-config.ts | 41 +++ src/app/admin/(shell)/config/page.tsx | 57 +-- src/components/admin/admin-breadcrumb.tsx | 2 +- src/components/admin/admin-shell.tsx | 6 +- src/components/admin/admin-sidebar.tsx | 8 +- src/components/ui/sidebar.tsx | 2 +- src/modules/config/config-status-badge.tsx | 14 +- .../config/config-version-switcher.tsx | 194 +++++++---- src/modules/config/config-workspace-shell.tsx | 128 ++++--- .../config/doc/odds-config-doc-screen.tsx | 32 +- .../config/doc/play-config-doc-screen.tsx | 328 +++++------------- .../config/doc/rebate-config-doc-screen.tsx | 7 +- .../config/doc/risk-cap-doc-screen.tsx | 7 +- src/modules/config/meta.ts | 12 +- src/types/api/admin-config.ts | 8 + 15 files changed, 375 insertions(+), 471 deletions(-) diff --git a/src/api/admin-config.ts b/src/api/admin-config.ts index 426912d..7eab044 100644 --- a/src/api/admin-config.ts +++ b/src/api/admin-config.ts @@ -13,6 +13,12 @@ import type { const A = `${API_V1_PREFIX}/admin`; +type ConfigVersionListParams = { + status?: string; + page?: number; + per_page?: number; +}; + export async function getAdminPlayTypes(): Promise { return adminRequest.get(`${A}/play-types`); } @@ -55,10 +61,18 @@ export async function putPlayConfigItems( id: number, items: Array<{ play_code: string; + category: string | null; + dimension: number | null; + bet_mode: string | null; + display_name_zh: string; + display_name_en?: string | null; + display_name_ne?: string | null; is_enabled?: boolean; min_bet_amount: number; max_bet_amount: number; display_order?: number; + supports_multi_number?: boolean; + reserved_rule_json?: unknown; rule_text_zh?: string | null; rule_text_en?: string | null; rule_text_ne?: string | null; @@ -126,6 +140,33 @@ export async function getRiskCapVersions(params?: { return adminRequest.get(`${A}/config/risk-cap-versions`, { params }); } +export async function getAllConfigVersions( + fetchPage: (params?: ConfigVersionListParams) => Promise, + params?: Omit, +): Promise { + const items: ConfigVersionListData["items"] = []; + let meta: ConfigVersionListData["meta"] | null = null; + + for (let page = 1; ; page += 1) { + const data = await fetchPage({ ...params, page, per_page: 100 }); + items.push(...data.items); + meta = data.meta; + if (page >= data.meta.last_page) { + break; + } + } + + return { + items, + meta: meta ?? { + current_page: 1, + per_page: 0, + total: 0, + last_page: 1, + }, + }; +} + export async function getRiskCapVersion(id: number): Promise { return adminRequest.get(`${A}/config/risk-cap-versions/${id}`); } diff --git a/src/app/admin/(shell)/config/page.tsx b/src/app/admin/(shell)/config/page.tsx index af95f80..b778389 100644 --- a/src/app/admin/(shell)/config/page.tsx +++ b/src/app/admin/(shell)/config/page.tsx @@ -1,64 +1,11 @@ -import Link from "next/link"; -import { Layers, Shield, Wrench } from "lucide-react"; - -import { ModuleScaffold } from "@/components/admin/module-scaffold"; -import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model"; import { configHubMeta } from "@/modules/config/meta"; +import { redirect } from "next/navigation"; import type { Metadata } from "next"; export const metadata: Metadata = { title: configHubMeta.title, }; -const GROUP_ICONS = { - betting: Layers, - risk_wallet: Shield, - ops: Wrench, -} as const; - export default function AdminConfigHubPage() { - return ( - -
-

{configHubMeta.title}

-

- 玩法限额、赔率、封顶三套配置各自有「草稿 → 发布」生命周期;玩家端与大厅接口只读取当前{" "} - active 版本。请先在本区左侧选择模块,改完后务必在对应页点击「启用为当前版本」。 -

-
- -
- {CONFIG_NAV_GROUPS.map((group) => { - const Icon = GROUP_ICONS[group.id as keyof typeof GROUP_ICONS] ?? Layers; - return ( -
-
- -

- {group.label} -

-
-
- {group.items.map((item) => ( - - - - {item.title} - {item.description} - - - - ))} -
-
- ); - })} -
-
- ); + redirect("/admin/config/plays"); } diff --git a/src/components/admin/admin-breadcrumb.tsx b/src/components/admin/admin-breadcrumb.tsx index 0a7c8e9..ab355f6 100644 --- a/src/components/admin/admin-breadcrumb.tsx +++ b/src/components/admin/admin-breadcrumb.tsx @@ -84,7 +84,7 @@ export function AdminBreadcrumb() { return ( - + {breadcrumbs.map((crumb, index) => { const isLast = index === breadcrumbs.length - 1; const itemKey = `${crumb.href}-${index}`; diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 0a032b4..01ba187 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -17,15 +17,15 @@ export function AdminShell({ children }: { children: ReactNode }) { -
+
- +
-
+
{children}
diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 2fd3327..4a86e56 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -45,19 +45,19 @@ export function AdminAppSidebar() { return ( - + } - className="gap-3 px-0 hover:bg-transparent" + className="gap-2 px-0 hover:bg-transparent" > -
+
彩票后台 - + Lottery Admin
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 04f358e..4a161ed 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -307,7 +307,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
= { export function ConfigStatusBadge({ status }: { status: string }) { const label = LABELS[status] ?? status; - const variant = - status === "active" ? "default" : status === "draft" ? "secondary" : "outline"; + const className = + status === "active" + ? "border-emerald-500/20 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300" + : status === "draft" + ? "border-amber-500/20 bg-amber-500/12 text-amber-700 dark:text-amber-300" + : "border-slate-300 bg-slate-100 text-slate-600 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300"; - return ( - - {label} - - ); + return {label}; } diff --git a/src/modules/config/config-version-switcher.tsx b/src/modules/config/config-version-switcher.tsx index 0524fae..5fb52de 100644 --- a/src/modules/config/config-version-switcher.tsx +++ b/src/modules/config/config-version-switcher.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; import { Dialog, DialogContent, @@ -26,14 +27,7 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; import { ConfigStatusBadge } from "@/modules/config/config-status-badge"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import type { ConfigVersionSummary } from "@/types/api/admin-config"; @@ -55,6 +49,8 @@ function versionSelectLabel(v: ConfigVersionSummary): string { return `#${v.id} · v${v.version_no} · ${versionStatusLabel(v.status)}`; } +const STATUS_ORDER = ["draft", "active", "archived"] as const; + export type ConfigVersionSwitcherProps = { versions: ConfigVersionSummary[]; selectedId: string; @@ -90,6 +86,29 @@ export function ConfigVersionSwitcher({ [versions], ); + const groupedVersions = useMemo(() => { + const groups = new Map(); + for (const status of STATUS_ORDER) { + groups.set(status, []); + } + for (const v of sortedVersions) { + const list = groups.get(v.status) ?? []; + list.push(v); + groups.set(v.status, list); + } + return groups; + }, [sortedVersions]); + + const statusCounts = useMemo( + () => + STATUS_ORDER.map((status) => ({ + status, + label: versionStatusLabel(status), + count: groupedVersions.get(status)?.length ?? 0, + })), + [groupedVersions], + ); + function switchTo(id: number) { onSelectedIdChange(String(id)); setSheetOpen(false); @@ -139,79 +158,104 @@ export function ConfigVersionSwitcher({
- + {sheetTitle} {sheetDescription} -
+
+ {statusCounts.map((s) => ( +
+ {s.label} + {s.count} +
+ ))} +
+
{sortedVersions.length === 0 ? ( -

暂无版本记录。

+ 暂无版本记录。 ) : ( - - - - version_no - 状态 - 生效时间 - 操作 - - - - {sortedVersions.map((v) => { - const isCurrent = selectedId === String(v.id); - return ( - - v{v.version_no} - - - - - {v.effective_at ? formatDt(v.effective_at) : "—"} - - -
- - {onRollbackVersion && v.status !== "draft" ? ( - - ) : null} - {onDeleteVersion && v.status !== "active" ? ( - - ) : null} -
-
-
- ); - })} -
-
+ STATUS_ORDER.map((status) => { + const rows = groupedVersions.get(status) ?? []; + if (rows.length === 0) { + return null; + } + return ( +
+
+
+ +

{versionStatusLabel(status)}

+
+

{rows.length} 条

+
+
+ {rows.map((v) => { + const isCurrent = selectedId === String(v.id); + return ( + +
+
+
+ v{v.version_no} + + #{v.id} +
+

+ 生效时间:{v.effective_at ? formatDt(v.effective_at) : "—"} + {v.reason ? ` · 备注:${v.reason}` : ""} +

+
+
+ + {onRollbackVersion && v.status !== "draft" ? ( + + ) : null} + {onDeleteVersion && v.status !== "active" ? ( + + ) : null} +
+
+
+ ); + })} +
+
+ ); + }) )}
diff --git a/src/modules/config/config-workspace-shell.tsx b/src/modules/config/config-workspace-shell.tsx index 2de8d13..3e8eaf4 100644 --- a/src/modules/config/config-workspace-shell.tsx +++ b/src/modules/config/config-workspace-shell.tsx @@ -1,11 +1,13 @@ "use client"; +import { useMemo } from "react"; import type { ReactNode } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model"; +import { configHubMeta } from "@/modules/config/meta"; function navLinkActive(pathname: string, href: string): boolean { return pathname === href || pathname.startsWith(`${href}/`); @@ -13,70 +15,88 @@ function navLinkActive(pathname: string, href: string): boolean { export function ConfigWorkspaceShell({ children }: { children: ReactNode }) { const pathname = usePathname() ?? ""; + const activeNav = useMemo(() => { + for (const group of CONFIG_NAV_GROUPS) { + for (const item of group.items) { + if (navLinkActive(pathname, item.href)) { + return { group, item }; + } + } + } + return null; + }, [pathname]); + + const title = activeNav?.item.title ?? configHubMeta.title; + const description = activeNav?.item.description || configHubMeta.description; + const groupLabel = activeNav?.group.label ?? "总览"; return ( -
- -
{children}
+
{children}
+
); } diff --git a/src/modules/config/doc/odds-config-doc-screen.tsx b/src/modules/config/doc/odds-config-doc-screen.tsx index 6dc6add..3cea35e 100644 --- a/src/modules/config/doc/odds-config-doc-screen.tsx +++ b/src/modules/config/doc/odds-config-doc-screen.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { deleteOddsVersion, getAdminPlayTypes, + getAllConfigVersions, getOddsVersion, getOddsVersions, postOddsVersion, @@ -96,7 +97,7 @@ export function OddsConfigDocScreen() { setLoadingList(true); setError(null); try { - const d = await getOddsVersions({ per_page: 50 }); + const d = await getAllConfigVersions(getOddsVersions); setList(d.items); } catch (e) { const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败"; @@ -349,28 +350,25 @@ export function OddsConfigDocScreen() {
- 分类 + 分类 {catTabs.map((t) => ( ))}
-
-

玩法

-
+
+

玩法

+
{filteredTypes.length === 0 ? ( - 该分类下暂无玩法。 + 该分类下暂无玩法。 ) : ( filteredTypes.map((t) => ( - @@ -446,7 +330,7 @@ export function PlayConfigDocScreen() { {error ?

{error}

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

加载中…

) : (
@@ -464,32 +348,28 @@ export function PlayConfigDocScreen() { - {mergedRows.map(({ type: t, item }) => ( - - {t.play_code} - {t.category} + {orderedRows.map((row) => ( + + {row.play_code} + {row.category ?? "—"} { - openToggleConfirm(t.play_code, v === true); + updateConfigRow(row.play_code, { is_enabled: v === true }); }} - aria-label={`启用 ${t.play_code}`} + aria-label={`启用 ${row.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 }); - } + onChange={(e) => { + const next = e.target.value === "" ? null : e.target.value; + updateConfigRow(row.play_code, { display_name_zh: next }); }} /> @@ -497,57 +377,50 @@ export function PlayConfigDocScreen() { { + onChange={(e) => { const n = Number.parseInt(e.target.value, 10); - if (Number.isFinite(n) && n !== t.sort_order) { - void patchTypeField(t.play_code, { sort_order: n }); + if (Number.isFinite(n)) { + updateConfigRow(row.play_code, { display_order: n }); } }} /> - {item ? ( - - updateConfigRow(t.play_code, { - min_bet_amount: Number.parseInt(e.target.value, 10) || 0, - }) - } - /> - ) : ( - 无配置行 - )} + + updateConfigRow(row.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} + + updateConfigRow(row.play_code, { + max_bet_amount: Number.parseInt(e.target.value, 10) || 0, + }) + } + /> @@ -560,37 +433,6 @@ export function PlayConfigDocScreen() { )} - { - setConfirmOpen(open); - if (!open) { - setPendingToggle(null); - } - }} - > - - - 确认变更状态 - - {pendingToggle - ? `确定要${pendingToggle.next ? "启用" : "禁用"}玩法「${pendingToggle.play_code}」吗?将同步更新玩法目录与${ - isDraft ? "当前草稿" : "(非草稿时仅更新目录,配置明细请在草稿中维护)" - }。` - : null} - - - - - - - - - diff --git a/src/modules/config/doc/rebate-config-doc-screen.tsx b/src/modules/config/doc/rebate-config-doc-screen.tsx index e56b7c5..1e82052 100644 --- a/src/modules/config/doc/rebate-config-doc-screen.tsx +++ b/src/modules/config/doc/rebate-config-doc-screen.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { deleteOddsVersion, getAdminPlayTypes, + getAllConfigVersions, getOddsVersion, getOddsVersions, postOddsVersion, @@ -72,7 +73,7 @@ export function RebateConfigDocScreen() { const refreshList = useCallback(async () => { try { - const d = await getOddsVersions({ per_page: 50 }); + const d = await getAllConfigVersions(getOddsVersions); setListRows(d.items); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本失败"); @@ -345,7 +346,7 @@ export function RebateConfigDocScreen() { -

+

界面占位:后续可与风控 / 结算规则字段对齐并持久化。

@@ -353,7 +354,7 @@ export function RebateConfigDocScreen() {
生效时间(当前线上赔率版本) - + {activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
diff --git a/src/modules/config/doc/risk-cap-doc-screen.tsx b/src/modules/config/doc/risk-cap-doc-screen.tsx index 25c8e8a..cdf6949 100644 --- a/src/modules/config/doc/risk-cap-doc-screen.tsx +++ b/src/modules/config/doc/risk-cap-doc-screen.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { deleteRiskCapVersion, + getAllConfigVersions, getRiskCapVersion, getRiskCapVersions, postRiskCapVersion, @@ -72,7 +73,7 @@ export function RiskCapDocScreen() { setLoadingList(true); setError(null); try { - const d = await getRiskCapVersions({ per_page: 50 }); + const d = await getAllConfigVersions(getRiskCapVersions); setList(d.items); } catch (e) { const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败"; @@ -342,7 +343,7 @@ export function RiskCapDocScreen() {

默认封顶

-

+

将下列金额同步到当前草稿中的全部号码行(适用于统一基数快速调整)。

@@ -447,7 +448,7 @@ export function RiskCapDocScreen() {

全部号码占用情况

-

+

占位界面:筛选与导出待接入注单汇总;下列数据仍来源于当前草稿号码列表。

diff --git a/src/modules/config/meta.ts b/src/modules/config/meta.ts index 66b8730..26c6394 100644 --- a/src/modules/config/meta.ts +++ b/src/modules/config/meta.ts @@ -1,29 +1,29 @@ export const configHubMeta = { title: "配置中心", - description: "", + description: "统一管理玩法目录、赔率、回水和风险封顶,先草稿、后发布、再生效。", } as const; export const configPlayConfigMeta = { title: "玩法配置", - description: "", + description: "维护玩法开关、限额和规则文案,目录变更会直接影响下注入口。", } as const; export const configOddsMeta = { title: "赔率配置", - description: "", + description: "维护赔率、返水和佣金,发布前请重点核对数值范围与币种。", } as const; export const configRebateMeta = { title: "佣金 / 回水", - description: "", + description: "从赔率草稿中批量调整回水比例,适合按玩法维度统一修正。", } as const; export const configRiskCapMeta = { title: "风控封顶", - description: "", + description: "管理号码封顶版本和风险池阈值,发布前先确认号码与期号。", } as const; export const configWalletMeta = { title: "钱包配置", - description: "", + description: "维护钱包相关阈值与转账策略。", } as const; diff --git a/src/types/api/admin-config.ts b/src/types/api/admin-config.ts index fd562c9..660bd5b 100644 --- a/src/types/api/admin-config.ts +++ b/src/types/api/admin-config.ts @@ -40,10 +40,18 @@ export type ConfigVersionSummary = { export type PlayConfigItemRow = { id: number; play_code: string; + category: string | null; + dimension: number | null; + bet_mode: string | null; + display_name_zh: string | null; + display_name_en: string | null; + display_name_ne: string | null; is_enabled: boolean; min_bet_amount: number; max_bet_amount: number; display_order: number; + supports_multi_number: boolean; + reserved_rule_json: unknown; rule_text_zh: string | null; rule_text_en: string | null; rule_text_ne: string | null;