feat(config): 重构配置中心导航与版本展示,支持全量版本加载
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user