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

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