feat: 添加配置模块和更新管理员导航,优化钱包控制台加载逻辑
This commit is contained in:
560
src/modules/config/doc/play-config-doc-screen.tsx
Normal file
560
src/modules/config/doc/play-config-doc-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user