feat: 添加配置模块和更新管理员导航,优化钱包控制台加载逻辑

This commit is contained in:
2026-05-11 10:08:56 +08:00
parent ac3f28459b
commit 78045de9a3
25 changed files with 2705 additions and 2 deletions

View File

@@ -0,0 +1,560 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
getAdminPlayTypes,
getPlayConfigVersion,
getPlayConfigVersions,
patchAdminPlayType,
postPlayConfigVersion,
publishPlayConfigVersion,
putPlayConfigItems,
} from "@/api/admin-config";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminPlayTypeRow,
ConfigVersionSummary,
PlayConfigItemRow,
PlayConfigVersionDetail,
} from "@/types/api/admin-config";
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 });
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void refreshTypes();
void refreshList();
});
}, [refreshTypes, refreshList]);
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
try {
const d = await getPlayConfigVersion(id);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
useEffect(() => {
if (list.length === 0 || selectedId !== "") {
return;
}
queueMicrotask(() => {
const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
const active = list.find((x) => x.status === "active");
const pick = drafts[0] ?? active ?? list.sort((a, b) => b.id - a.id)[0];
if (pick) {
setSelectedId(String(pick.id));
}
});
}, [list, selectedId]);
useEffect(() => {
if (selectedId === "") {
return;
}
const id = Number(selectedId);
if (!Number.isFinite(id)) {
return;
}
queueMicrotask(() => {
void loadDetail(id);
});
}, [selectedId, loadDetail]);
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);
}
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 });
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 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 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);
}
}
async function handleSaveDraft() {
if (!detail || !isDraft) {
return;
}
for (const r of draftRows) {
if (r.min_bet_amount > r.max_bet_amount) {
toast.error(`${r.play_code}: 最小额不能大于最大额`);
return;
}
}
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 })));
toast.success("已保存草稿");
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
async function handlePublish() {
if (!detail || !isDraft) {
return;
}
setSaving(true);
try {
const d = await publishPlayConfigVersion(detail.id);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success("已启用为当前版本");
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
} finally {
setSaving(false);
}
}
async function handleNewDraft() {
setSaving(true);
try {
const active = list.find((x) => x.status === "active");
const d = await postPlayConfigVersion({
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
} finally {
setSaving(false);
}
}
function openRuleEditor(play_code: string) {
const item = itemsByCode.get(play_code);
setRulePlayCode(play_code);
setRuleDraftZh(item?.rule_text_zh ?? "");
setRuleDialogOpen(true);
}
function saveRuleZh() {
if (!rulePlayCode) {
return;
}
updateConfigRow(rulePlayCode, { rule_text_zh: ruleDraftZh.trim() || null });
setRuleDialogOpen(false);
setRulePlayCode(null);
toast.message("规则说明已写入本地草稿,记得保存草稿");
}
const activeHead = list.find((x) => x.status === "active");
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle>
<CardDescription>
§5.4稿
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshList()} disabled={loadingList}>
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshTypes()} disabled={loadingTypes}>
</Button>
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}>
稿
</Button>
<Button type="button" onClick={() => void handleSaveDraft()} disabled={!isDraft || saving || loadingDetail}>
稿
</Button>
<Button
type="button"
className="bg-emerald-600 text-white hover:bg-emerald-600/90"
onClick={() => void handlePublish()}
disabled={!isDraft || saving || loadingDetail}
>
</Button>
</div>
{detail ? (
<p className="text-sm text-muted-foreground">
v{detail.version_no} ·{" "}
{detail.status === "active" ? "生效中" : detail.status === "draft" ? "草稿" : "已归档"}
{activeHead ? (
<>
{" "}
· 线 v{activeHead.version_no}
{activeHead.effective_at ? ` · ${activeHead.effective_at}` : ""}
</>
) : null}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400">
{" "}
稿
</span>
) : null}
</p>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail || loadingTypes ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<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-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
</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>
<TableCell className="text-center">
<Checkbox
checked={t.is_enabled}
disabled={saving}
onCheckedChange={(v) => {
openToggleConfirm(t.play_code, v === true);
}}
aria-label={`启用 ${t.play_code}`}
/>
</TableCell>
<TableCell>
<Input
className="h-8 text-sm"
defaultValue={t.display_name_zh ?? ""}
key={`${t.play_code}-dn-${t.updated_at}`}
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 });
}
}}
/>
</TableCell>
<TableCell>
<Input
type="number"
className="h-8 font-mono tabular-nums"
defaultValue={t.sort_order}
key={`${t.play_code}-so-${t.updated_at}`}
disabled={saving}
onBlur={(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 });
}
}}
/>
</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>
)}
</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}
</TableCell>
<TableCell>
<Button
type="button"
variant="outline"
size="sm"
disabled={!item || !isDraft || saving}
onClick={() => openRuleEditor(t.play_code)}
>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</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>
<DialogTitle></DialogTitle>
<DialogDescription>
{rulePlayCode ?? "—"}稿稿
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="rule-zh">rule_text_zh</Label>
<textarea
id="rule-zh"
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
value={ruleDraftZh}
onChange={(e) => setRuleDraftZh(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRuleDialogOpen(false)}>
</Button>
<Button type="button" onClick={saveRuleZh}>
稿
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}