feat: 扩展开奖与结算管理,支持手动操作、导出和版本展示

This commit is contained in:
2026-05-16 18:00:57 +08:00
parent 34f9175304
commit fae8c1ae01
21 changed files with 1148 additions and 410 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
@@ -33,6 +33,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -96,7 +98,9 @@ export function PlayConfigDocScreen() {
const [loadingList, setLoadingList] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const [creatingDraftId, setCreatingDraftId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const detailRequestSeq = useRef(0);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [rulePlayCode, setRulePlayCode] = useState<string | null>(null);
@@ -108,6 +112,9 @@ export function PlayConfigDocScreen() {
try {
const d = await getAllConfigVersions(getPlayConfigVersions);
setList(d.items);
setCreatingDraftId((draftId) =>
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
setError(msg);
@@ -124,33 +131,58 @@ export function PlayConfigDocScreen() {
}, [refreshList]);
const loadDetail = useCallback(async (id: number) => {
const requestSeq = detailRequestSeq.current + 1;
detailRequestSeq.current = requestSeq;
setLoadingDetail(true);
setDetail(null);
setDraftRows([]);
try {
const d = await getPlayConfigVersion(id);
if (detailRequestSeq.current !== requestSeq) {
return;
}
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
if (detailRequestSeq.current !== requestSeq) {
return;
}
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
if (detailRequestSeq.current === requestSeq) {
setLoadingDetail(false);
}
}
}, []);
useEffect(() => {
if (list.length === 0 || selectedId !== "") {
if (list.length === 0) {
if (selectedId !== "") {
queueMicrotask(() => {
setSelectedId("");
setDetail(null);
setDraftRows([]);
});
}
return;
}
if (selectedId !== "" && list.some((x) => String(x.id) === selectedId)) {
return;
}
if (creatingDraftId !== null && selectedId === creatingDraftId) {
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];
const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
const pick = active ?? drafts[0] ?? [...list].sort((a, b) => b.id - a.id)[0];
if (pick) {
setSelectedId(String(pick.id));
}
});
}, [list, selectedId]);
}, [list, selectedId, creatingDraftId]);
useEffect(() => {
if (selectedId === "") {
@@ -165,7 +197,13 @@ export function PlayConfigDocScreen() {
});
}, [selectedId, loadDetail]);
const isDraft = detail?.status === "draft";
const selectedVersionSummary = useMemo(
() => list.find((x) => String(x.id) === selectedId) ?? null,
[list, selectedId],
);
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const orderedRows = useMemo(
() =>
@@ -232,10 +270,11 @@ export function PlayConfigDocScreen() {
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
await refreshList();
setCreatingDraftId(String(d.id));
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
} finally {
@@ -291,25 +330,16 @@ export function PlayConfigDocScreen() {
className="lg:flex-1"
/>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loadingList}>
</Button>
<Button type="button" 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>
<ConfigVersionActions
isDraft={isDraft}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSaveDraft()}
onPublish={() => void handlePublish()}
/>
</div>
</div>
@@ -339,94 +369,128 @@ export function PlayConfigDocScreen() {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[88px] text-center"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="min-w-[120px] text-center"></TableHead>
<TableHead className="w-[120px] text-center"></TableHead>
<TableHead className="w-[110px] text-center"></TableHead>
<TableHead className="w-[110px] text-center"></TableHead>
<TableHead className="w-[140px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 font-mono text-sm">{row.play_code}</TableCell>
<TableCell className="text-center text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
<TableCell className="text-center">
<Checkbox
checked={row.is_enabled}
disabled={saving}
onCheckedChange={(v) => {
updateConfigRow(row.play_code, { is_enabled: v === true });
}}
aria-label={`启用 ${row.play_code}`}
/>
{isDraft ? (
<Checkbox
checked={row.is_enabled}
disabled={saving}
onCheckedChange={(v) => {
updateConfigRow(row.play_code, { is_enabled: v === true });
}}
aria-label={`启用 ${row.play_code}`}
/>
) : (
<ConfigReadonlyValue className="justify-center">
{row.is_enabled ? "启用" : "停用"}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
<Input
className="h-8 text-sm"
value={row.display_name_zh ?? ""}
disabled={saving}
onChange={(e) => {
const next = e.target.value === "" ? null : e.target.value;
updateConfigRow(row.play_code, { display_name_zh: next });
}}
/>
{isDraft ? (
<Input
className="h-8 text-center text-sm"
value={row.display_name_zh ?? ""}
disabled={saving}
onChange={(e) => {
const next = e.target.value === "" ? null : e.target.value;
updateConfigRow(row.play_code, { display_name_zh: next });
}}
/>
) : (
<ConfigReadonlyValue className="justify-center">
{row.display_name_zh ?? "—"}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="w-[96px]">
<Input
type="text"
inputMode="numeric"
className="h-8 w-16 font-mono tabular-nums text-center"
value={row.display_order}
disabled={saving}
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
if (Number.isFinite(n)) {
updateConfigRow(row.play_code, { display_order: n });
<TableCell className="w-[96px] text-center">
{isDraft ? (
<Input
type="text"
inputMode="numeric"
className="h-8 w-16 font-mono tabular-nums text-center"
value={row.display_order}
disabled={saving}
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
if (Number.isFinite(n)) {
updateConfigRow(row.play_code, { display_order: n });
}
}}
/>
) : (
<ConfigReadonlyValue mono className="justify-center">
{row.display_order}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="text-center">
{isDraft ? (
<Input
type="text"
inputMode="numeric"
className="h-8 text-center font-mono tabular-nums"
disabled={saving}
value={row.min_bet_amount}
onChange={(e) =>
updateConfigRow(row.play_code, {
min_bet_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
}}
/>
/>
) : (
<ConfigReadonlyValue mono className="justify-center">
{row.min_bet_amount}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
<Input
type="text"
inputMode="numeric"
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 className="text-center">
{isDraft ? (
<Input
type="text"
inputMode="numeric"
className="h-8 text-center font-mono tabular-nums"
disabled={saving}
value={row.max_bet_amount}
onChange={(e) =>
updateConfigRow(row.play_code, {
max_bet_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
) : (
<ConfigReadonlyValue mono className="justify-center">
{row.max_bet_amount}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
<Input
type="text"
inputMode="numeric"
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={!isDraft || saving}
onClick={() => openRuleEditor(row.play_code)}
>
</Button>
<TableCell className="text-center">
{isDraft ? (
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => openRuleEditor(row.play_code)}
>
</Button>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}