feat(config): 重构配置中心导航与版本展示,支持全量版本加载

This commit is contained in:
2026-05-16 10:28:00 +08:00
parent 8bd7cc3d73
commit 1578c7e214
15 changed files with 375 additions and 471 deletions

View File

@@ -8,12 +8,12 @@ const LABELS: Record<string, string> = {
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 (
<Badge variant={variant} className="font-normal tabular-nums">
{label}
</Badge>
);
return <Badge variant="outline" className={`font-normal tabular-nums ${className}`}>{label}</Badge>;
}

View File

@@ -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<string, ConfigVersionSummary[]>();
for (const status of STATUS_ORDER) {
groups.set(status, []);
}
for (const v of sortedVersions) {
const list = groups.get(v.status) ?? [];
list.push(v);
groups.set(v.status, list);
}
return groups;
}, [sortedVersions]);
const statusCounts = useMemo(
() =>
STATUS_ORDER.map((status) => ({
status,
label: versionStatusLabel(status),
count: groupedVersions.get(status)?.length ?? 0,
})),
[groupedVersions],
);
function switchTo(id: number) {
onSelectedIdChange(String(id));
setSheetOpen(false);
@@ -139,79 +158,104 @@ export function ConfigVersionSwitcher({
</div>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="right" className="sm:max-w-lg flex flex-col">
<SheetContent side="right" className="sm:max-w-2xl flex flex-col">
<SheetHeader>
<SheetTitle>{sheetTitle}</SheetTitle>
<SheetDescription>{sheetDescription}</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto rounded-md border mt-4">
<div className="mt-4 flex gap-2 flex-wrap">
{statusCounts.map((s) => (
<div key={s.status} className="rounded-full border border-border bg-muted/40 px-3 py-1 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{s.label}</span>
<span className="ml-1 tabular-nums">{s.count}</span>
</div>
))}
</div>
<div className="flex-1 overflow-auto mt-4 space-y-4">
{sortedVersions.length === 0 ? (
<p className="text-sm text-muted-foreground p-4"></p>
<Card className="p-4 text-sm text-muted-foreground"></Card>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[88px]">version_no</TableHead>
<TableHead className="w-[88px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right w-[180px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedVersions.map((v) => {
const isCurrent = selectedId === String(v.id);
return (
<TableRow key={v.id} data-state={isCurrent ? "selected" : undefined}>
<TableCell className="font-mono text-xs tabular-nums">v{v.version_no}</TableCell>
<TableCell>
<ConfigStatusBadge status={v.status} />
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{v.effective_at ? formatDt(v.effective_at) : "—"}
</TableCell>
<TableCell className="text-right">
<div className="inline-flex flex-wrap items-center justify-end gap-1">
<Button
type="button"
variant={isCurrent ? "secondary" : "outline"}
size="sm"
onClick={() => switchTo(v.id)}
>
{isCurrent ? "当前" : "查看"}
</Button>
{onRollbackVersion && v.status !== "draft" ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={rollbackBusy}
onClick={() => {
onRollbackVersion(v);
setSheetOpen(false);
}}
>
</Button>
) : null}
{onDeleteVersion && v.status !== "active" ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive"
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
</Button>
) : null}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
STATUS_ORDER.map((status) => {
const rows = groupedVersions.get(status) ?? [];
if (rows.length === 0) {
return null;
}
return (
<section key={status} className="space-y-2">
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<ConfigStatusBadge status={status} />
<p className="text-base font-medium text-foreground">{versionStatusLabel(status)}</p>
</div>
<p className="text-sm text-muted-foreground tabular-nums">{rows.length} </p>
</div>
<div className="space-y-2">
{rows.map((v) => {
const isCurrent = selectedId === String(v.id);
return (
<Card
key={v.id}
className={cn(
"border-border/70 bg-card/90 p-3 transition-colors",
isCurrent && "border-primary/30 bg-primary/5",
)}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-base tabular-nums text-foreground">v{v.version_no}</span>
<ConfigStatusBadge status={v.status} />
<span className="text-sm text-muted-foreground">#{v.id}</span>
</div>
<p className="text-sm text-muted-foreground">
{v.effective_at ? formatDt(v.effective_at) : "—"}
{v.reason ? ` · 备注:${v.reason}` : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={isCurrent ? "secondary" : "outline"}
size="sm"
onClick={() => switchTo(v.id)}
>
{isCurrent ? "当前查看" : "查看"}
</Button>
{onRollbackVersion && v.status !== "draft" ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={rollbackBusy}
onClick={() => {
onRollbackVersion(v);
setSheetOpen(false);
}}
>
</Button>
) : null}
{onDeleteVersion && v.status !== "active" ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive"
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
</Button>
) : null}
</div>
</div>
</Card>
);
})}
</div>
</section>
);
})
)}
</div>
</SheetContent>

View File

@@ -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 (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 lg:flex-row lg:gap-8">
<aside className="shrink-0 lg:w-48 lg:border-r lg:border-border lg:pr-4">
<div className="mb-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></p>
<p className="mt-0.5 text-[11px] leading-tight text-muted-foreground">
稿
</p>
</div>
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<aside className="shrink-0 lg:sticky lg:top-4 lg:w-56 lg:self-start">
<div className="rounded-2xl border border-border/70 bg-card/80 p-3 shadow-sm backdrop-blur lg:max-h-[calc(100vh-2rem)] lg:overflow-auto">
<div className="mb-3 px-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
</p>
</div>
<nav className="hidden lg:block space-y-4" aria-label="运营配置子导航">
{CONFIG_NAV_GROUPS.map((group) => (
<div key={group.id}>
<p className="mb-1 text-[11px] font-semibold text-muted-foreground">{group.label}</p>
<ul className="space-y-0.5">
{group.items.map((item) => {
<nav className="hidden space-y-3 lg:block" aria-label="运营配置子导航">
{CONFIG_NAV_GROUPS.map((group) => (
<div key={group.id} className="space-y-1.5">
<p className="px-2 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{group.label}
</p>
<ul className="space-y-1">
{group.items.map((item) => {
const active = navLinkActive(pathname, item.href);
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"block rounded-xl border px-3 py-2.5 text-sm transition-all outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring",
active
? "border-primary/20 bg-primary/10 text-primary shadow-sm"
: "border-transparent bg-transparent text-foreground hover:border-border hover:bg-muted/60",
)}
>
<div className="font-medium">{item.title}</div>
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="lg:hidden overflow-x-auto pb-1 -mx-1 px-1">
<div className="flex w-max gap-2">
{CONFIG_NAV_GROUPS.flatMap((g) => g.items).map((item) => {
const active = navLinkActive(pathname, item.href);
return (
<li key={item.href}>
<Link
href={item.href}
title={item.description}
className={cn(
"block rounded-md px-2 py-1.5 text-sm transition-colors outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-primary/10 text-primary font-medium"
: "text-foreground hover:bg-muted/80",
)}
>
{item.title}
</Link>
</li>
<Link
key={`m-${item.href}`}
href={item.href}
className={cn(
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors whitespace-nowrap",
active
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-foreground hover:bg-muted/60",
)}
>
{item.title}
</Link>
);
})}
</ul>
</div>
</div>
))}
</nav>
<div className="lg:hidden overflow-x-auto pb-1 -mx-1 px-1">
<div className="flex w-max gap-2">
{CONFIG_NAV_GROUPS.flatMap((g) => g.items).map((item) => {
const active = navLinkActive(pathname, item.href);
return (
<Link
key={`m-${item.href}`}
href={item.href}
className={cn(
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors whitespace-nowrap",
active
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-foreground hover:bg-muted/60",
)}
>
{item.title}
</Link>
);
})}
</div>
</div>
</aside>
</aside>
<div className="min-w-0 flex-1">{children}</div>
<div className="min-w-0 flex-1">{children}</div>
</div>
</div>
);
}

View File

@@ -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() {
</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>
<span className="text-base text-muted-foreground self-center mr-2"></span>
{catTabs.map((t) => (
<Button
key={t.id}
type="button"
variant={catTab === t.id ? "default" : "outline"}
className={cn(catTab === t.id && "shadow-sm")}
onClick={() => {
setCatTab(t.id);
setPlayCode("");
}}
onClick={() => setCatTab(t.id)}
>
{t.label}
</Button>
))}
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground"></p>
<div className="flex flex-wrap gap-2">
<div className="space-y-2 min-h-[96px]">
<p className="text-base text-muted-foreground"></p>
<div className="flex flex-wrap gap-2 min-h-[44px]">
{filteredTypes.length === 0 ? (
<span className="text-sm text-muted-foreground"></span>
<span className="text-base text-muted-foreground"></span>
) : (
filteredTypes.map((t) => (
<Button
@@ -438,9 +436,11 @@ export function OddsConfigDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail || loadingTypes ? (
<p className="text-sm text-muted-foreground"></p>
<div className="flex min-h-[420px] items-center">
<p className="text-base text-muted-foreground"></p>
</div>
) : resolvedPlayCode ? (
<div className="grid gap-4 max-w-md">
<div className="grid min-h-[420px] gap-4 max-w-md">
{PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = PRIZE_SCOPE_MULTIPLIER_HINT[scope];
@@ -449,7 +449,7 @@ export function OddsConfigDocScreen() {
<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}
{hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
<div className="flex flex-wrap items-center gap-2">
@@ -465,12 +465,12 @@ export function OddsConfigDocScreen() {
})
}
/>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-sm text-muted-foreground tabular-nums">
×{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
</span>
</div>
) : (
<p className="text-xs text-destructive"> {scope} </p>
<p className="text-sm text-destructive"> {scope} </p>
)}
</div>
);
@@ -486,7 +486,7 @@ export function OddsConfigDocScreen() {
value={rebatePercentUi}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
<p className="text-xs text-muted-foreground"> rebate_rate</p>
<p className="text-sm text-muted-foreground"> rebate_rate</p>
</div>
</div>
) : null}

View File

@@ -5,10 +5,9 @@ import { toast } from "sonner";
import {
deletePlayConfigVersion,
getAdminPlayTypes,
getAllConfigVersions,
getPlayConfigVersion,
getPlayConfigVersions,
patchAdminPlayType,
postPlayConfigVersion,
publishPlayConfigVersion,
putPlayConfigItems,
@@ -37,105 +36,77 @@ import {
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminPlayTypeRow,
ConfigVersionSummary,
PlayConfigItemRow,
PlayConfigVersionDetail,
} from "@/types/api/admin-config";
const DEFAULT_PLAY_MIN_BET = 100;
const DEFAULT_PLAY_MAX_BET = 500_000_000;
type PlayConfigSaveItemPayload = {
play_code: string;
category: string;
dimension: number | null;
bet_mode: string | null;
display_name_zh: string;
display_name_en: string | null;
display_name_ne: string | null;
is_enabled: boolean;
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;
extra_config_json: unknown;
};
/** 与「玩法目录」对齐的完整列表,避免保存草稿时用残缺数组覆盖后端导致其它玩法配置被删。 */
/** 版本草稿保存 payload直接按当前草稿快照落库。 */
function buildPlayConfigSavePayload(
typeRows: AdminPlayTypeRow[],
draftRows: PlayConfigItemRow[],
): PlayConfigSaveItemPayload[] {
const byCode = new Map(draftRows.map((r) => [r.play_code, r]));
const sorted = [...typeRows].sort(
(a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code),
);
return sorted.map((t) => {
const row = byCode.get(t.play_code);
if (row) {
return {
play_code: row.play_code,
is_enabled: row.is_enabled,
min_bet_amount: row.min_bet_amount,
max_bet_amount: row.max_bet_amount,
display_order: row.display_order,
rule_text_zh: row.rule_text_zh,
rule_text_en: row.rule_text_en,
rule_text_ne: row.rule_text_ne,
extra_config_json: row.extra_config_json,
};
}
return {
play_code: t.play_code,
is_enabled: t.is_enabled,
min_bet_amount: DEFAULT_PLAY_MIN_BET,
max_bet_amount: DEFAULT_PLAY_MAX_BET,
display_order: t.sort_order,
rule_text_zh: null,
rule_text_en: null,
rule_text_ne: null,
extra_config_json: null,
};
});
return [...draftRows]
.sort((a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code))
.map((row) => ({
play_code: row.play_code,
category: row.category ?? "",
dimension: row.dimension,
bet_mode: row.bet_mode,
display_name_zh: row.display_name_zh ?? row.play_code,
display_name_en: row.display_name_en ?? null,
display_name_ne: row.display_name_ne ?? null,
is_enabled: row.is_enabled,
min_bet_amount: row.min_bet_amount,
max_bet_amount: row.max_bet_amount,
display_order: row.display_order,
supports_multi_number: row.supports_multi_number,
reserved_rule_json: row.reserved_rule_json,
rule_text_zh: row.rule_text_zh,
rule_text_en: row.rule_text_en,
rule_text_ne: row.rule_text_ne,
extra_config_json: row.extra_config_json,
}));
}
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 });
const d = await getAllConfigVersions(getPlayConfigVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
@@ -148,10 +119,9 @@ export function PlayConfigDocScreen() {
useEffect(() => {
queueMicrotask(() => {
void refreshTypes();
void refreshList();
});
}, [refreshTypes, refreshList]);
}, [refreshList]);
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
@@ -197,96 +167,23 @@ export function PlayConfigDocScreen() {
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);
}
const orderedRows = useMemo(
() =>
[...draftRows].sort(
(a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code),
),
[draftRows],
);
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 });
const typesForPayload = types.map((r) => (r.play_code === updated.play_code ? updated : r));
setTypes((prev) =>
[...prev.map((r) => (r.play_code === updated.play_code ? updated : r))].sort(
(a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code),
),
);
if (isDraft) {
updateConfigRow(play_code, { is_enabled: next });
const idx = draftRowIndex(play_code);
const rowsForMerge =
idx >= 0
? draftRows.map((r, i) => (i === idx ? { ...r, is_enabled: next } : r))
: draftRows;
const payload = buildPlayConfigSavePayload(typesForPayload, rowsForMerge);
const d = await putPlayConfigItems(detail.id, payload);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
}
toast.success(`${next ? "启用" : "禁用"}${play_code}`);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "更新失败");
} finally {
setSaving(false);
setConfirmOpen(false);
setPendingToggle(null);
}
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
}
async function handleSaveDraft() {
if (!detail || !isDraft) {
return;
}
const payload = buildPlayConfigSavePayload(types, draftRows);
const payload = buildPlayConfigSavePayload(draftRows);
for (const r of payload) {
if (r.min_bet_amount > r.max_bet_amount) {
toast.error(`${r.play_code}: 最小额不能大于最大额`);
@@ -347,7 +244,7 @@ export function PlayConfigDocScreen() {
}
function openRuleEditor(play_code: string) {
const item = itemsByCode.get(play_code);
const item = draftRows.find((row) => row.play_code === play_code);
setRulePlayCode(play_code);
setRuleDraftZh(item?.rule_text_zh ?? "");
setRuleDialogOpen(true);
@@ -382,16 +279,6 @@ export function PlayConfigDocScreen() {
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-sky-200 bg-sky-50 px-3 py-2.5 text-sm text-sky-950 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-50">
<p className="font-medium"></p>
<p className="mt-1 text-xs leading-relaxed opacity-90">
{" "}
<span className="font-mono text-[11px]">GET /api/v1/play/effective</span>{" "}
稿稿<strong></strong>
</p>
</div>
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
@@ -405,9 +292,6 @@ export function PlayConfigDocScreen() {
<Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loadingList}>
</Button>
<Button type="button" variant="secondary" onClick={() => void refreshTypes()} disabled={loadingTypes}>
</Button>
<Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿
</Button>
@@ -446,7 +330,7 @@ export function PlayConfigDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail || loadingTypes ? (
{loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="overflow-x-auto rounded-md border">
@@ -464,32 +348,28 @@ export function PlayConfigDocScreen() {
</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>
{orderedRows.map((row) => (
<TableRow key={row.play_code}>
<TableCell className="font-mono text-sm">{row.play_code}</TableCell>
<TableCell className="text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
<TableCell className="text-center">
<Checkbox
checked={t.is_enabled}
checked={row.is_enabled}
disabled={saving}
onCheckedChange={(v) => {
openToggleConfirm(t.play_code, v === true);
updateConfigRow(row.play_code, { is_enabled: v === true });
}}
aria-label={`启用 ${t.play_code}`}
aria-label={`启用 ${row.play_code}`}
/>
</TableCell>
<TableCell>
<Input
className="h-8 text-sm"
defaultValue={t.display_name_zh ?? ""}
key={`${t.play_code}-dn-${t.updated_at}`}
value={row.display_name_zh ?? ""}
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 });
}
onChange={(e) => {
const next = e.target.value === "" ? null : e.target.value;
updateConfigRow(row.play_code, { display_name_zh: next });
}}
/>
</TableCell>
@@ -497,57 +377,50 @@ export function PlayConfigDocScreen() {
<Input
type="number"
className="h-8 w-full font-mono tabular-nums text-right"
defaultValue={t.sort_order}
key={`${t.play_code}-so-${t.updated_at}`}
value={row.display_order}
disabled={saving}
onBlur={(e) => {
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 });
}
}}
/>
</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>
)}
<Input
type="number"
min={0}
className="h-8 font-mono tabular-nums"
disabled={!isDraft || saving}
value={row.min_bet_amount}
onChange={(e) =>
updateConfigRow(row.play_code, {
min_bet_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
</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}
<Input
type="number"
min={0}
className="h-8 font-mono tabular-nums"
disabled={!isDraft || saving}
value={row.max_bet_amount}
onChange={(e) =>
updateConfigRow(row.play_code, {
max_bet_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="outline"
disabled={!item || !isDraft || saving}
onClick={() => openRuleEditor(t.play_code)}
disabled={!isDraft || saving}
onClick={() => openRuleEditor(row.play_code)}
>
</Button>
@@ -560,37 +433,6 @@ export function PlayConfigDocScreen() {
)}
</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>

View File

@@ -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() {
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
</Label>
<p className="text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground">
/
</p>
</div>
@@ -353,7 +354,7 @@ export function RebateConfigDocScreen() {
<div className="grid gap-1 text-sm">
<span className="text-muted-foreground">线</span>
<span className="font-mono text-xs">
<span className="font-mono text-sm">
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
</span>
</div>

View File

@@ -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() {
<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">
<p className="text-sm text-muted-foreground">
稿<strong></strong>
</p>
<div className="flex flex-wrap items-end gap-2">
@@ -447,7 +448,7 @@ export function RiskCapDocScreen() {
<section className="space-y-3">
<h3 className="text-sm font-medium"></h3>
<p className="text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground">
稿
</p>
<div className="flex flex-wrap gap-3 items-end">

View File

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