feat(config): 重构配置模块导航与版本切换,新增版本删除能力

This commit is contained in:
2026-05-15 15:30:52 +08:00
parent 000295ae2b
commit 8bd7cc3d73
20 changed files with 669 additions and 377 deletions

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
deletePlayConfigVersion,
getAdminPlayTypes,
getPlayConfigVersion,
getPlayConfigVersions,
@@ -33,6 +34,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminPlayTypeRow,
@@ -41,6 +43,59 @@ import type {
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;
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;
};
/** 与「玩法目录」对齐的完整列表,避免保存草稿时用残缺数组覆盖后端导致其它玩法配置被删。 */
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,
};
});
}
export function PlayConfigDocScreen() {
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
@@ -199,6 +254,7 @@ export function PlayConfigDocScreen() {
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),
@@ -207,20 +263,11 @@ export function PlayConfigDocScreen() {
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 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 })));
@@ -239,7 +286,8 @@ export function PlayConfigDocScreen() {
if (!detail || !isDraft) {
return;
}
for (const r of draftRows) {
const payload = buildPlayConfigSavePayload(types, draftRows);
for (const r of payload) {
if (r.min_bet_amount > r.max_bet_amount) {
toast.error(`${r.play_code}: 最小额不能大于最大额`);
return;
@@ -247,17 +295,6 @@ export function PlayConfigDocScreen() {
}
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 })));
@@ -328,20 +365,50 @@ export function PlayConfigDocScreen() {
const activeHead = list.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deletePlayConfigVersion(row.id);
toast.success("已删除该版本");
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
throw e;
}
}
return (
<Card>
<CardHeader className="space-y-1">
<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}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="玩法配置版本"
onDeleteVersion={handleDeleteVersion}
/>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshList()} disabled={loadingList}>
<Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loadingList}>
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshTypes()} disabled={loadingTypes}>
<Button type="button" variant="secondary" onClick={() => void refreshTypes()} disabled={loadingTypes}>
</Button>
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}>
<Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿
</Button>
<Button type="button" onClick={() => void handleSaveDraft()} disabled={!isDraft || saving || loadingDetail}>
@@ -390,7 +457,7 @@ export function PlayConfigDocScreen() {
<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-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
@@ -426,10 +493,10 @@ export function PlayConfigDocScreen() {
}}
/>
</TableCell>
<TableCell>
<TableCell className="w-[120px]">
<Input
type="number"
className="h-8 font-mono tabular-nums"
className="h-8 w-full font-mono tabular-nums text-right"
defaultValue={t.sort_order}
key={`${t.play_code}-so-${t.updated_at}`}
disabled={saving}
@@ -479,7 +546,6 @@ export function PlayConfigDocScreen() {
<Button
type="button"
variant="outline"
size="sm"
disabled={!item || !isDraft || saving}
onClick={() => openRuleEditor(t.play_code)}
>