feat: 扩展开奖与结算管理,支持手动操作、导出和版本展示
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user