feat: 添加配置模块和更新管理员导航,优化钱包控制台加载逻辑
This commit is contained in:
576
src/modules/config/doc/odds-config-doc-screen.tsx
Normal file
576
src/modules/config/doc/odds-config-doc-screen.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminPlayTypes,
|
||||
getOddsVersion,
|
||||
getOddsVersions,
|
||||
postOddsVersion,
|
||||
publishOddsVersion,
|
||||
putOddsItems,
|
||||
} from "@/api/admin-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminPlayTypeRow,
|
||||
ConfigVersionSummary,
|
||||
OddsItemRow,
|
||||
OddsVersionDetail,
|
||||
} from "@/types/api/admin-config";
|
||||
|
||||
import {
|
||||
PRIZE_SCOPE_LABELS,
|
||||
PRIZE_SCOPE_MULTIPLIER_HINT,
|
||||
PRIZE_SCOPE_ORDER,
|
||||
type PrizeScopeCode,
|
||||
} from "@/modules/config/doc/prize-scopes";
|
||||
|
||||
type CatTab = "all" | "d4" | "d3" | "d2" | "jackpot";
|
||||
|
||||
function oddsMultiplierLabel(oddsValue: number): string {
|
||||
return (oddsValue / 10000).toFixed(4);
|
||||
}
|
||||
|
||||
function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[] {
|
||||
if (tab === "all") {
|
||||
return types;
|
||||
}
|
||||
if (tab === "jackpot") {
|
||||
return types.filter((t) => t.category.toLowerCase().includes("jackpot"));
|
||||
}
|
||||
const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
|
||||
return types.filter((t) => t.dimension === dim);
|
||||
}
|
||||
|
||||
export function OddsConfigDocScreen() {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
||||
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
||||
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 [catTab, setCatTab] = useState<CatTab>("all");
|
||||
/** 用户点选的玩法;空字符串表示尚未选择,由 resolvedPlayCode 回落到分类内第一项 */
|
||||
const [playCode, setPlayCode] = useState<string>("");
|
||||
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
const refreshTypes = useCallback(async () => {
|
||||
setLoadingTypes(true);
|
||||
try {
|
||||
const d = await getAdminPlayTypes();
|
||||
setTypes(d.items);
|
||||
} 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 getOddsVersions({ 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 getOddsVersion(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 sortedTypes = useMemo(
|
||||
() => [...types].sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)),
|
||||
[types],
|
||||
);
|
||||
|
||||
const filteredTypes = useMemo(() => filterTypes(catTab, sortedTypes), [catTab, sortedTypes]);
|
||||
|
||||
const resolvedPlayCode = useMemo(() => {
|
||||
if (filteredTypes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (playCode && filteredTypes.some((t) => t.play_code === playCode)) {
|
||||
return playCode;
|
||||
}
|
||||
return filteredTypes[0].play_code;
|
||||
}, [filteredTypes, playCode]);
|
||||
|
||||
const isDraft = detail?.status === "draft";
|
||||
|
||||
const scopeRows = useMemo(() => {
|
||||
const rows: Partial<Record<PrizeScopeCode, OddsItemRow>> = {};
|
||||
if (!resolvedPlayCode) {
|
||||
return rows;
|
||||
}
|
||||
for (const scope of PRIZE_SCOPE_ORDER) {
|
||||
const hit = draftRows.find((r) => r.play_code === resolvedPlayCode && r.prize_scope === scope);
|
||||
if (hit) {
|
||||
rows[scope] = hit;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [draftRows, resolvedPlayCode]);
|
||||
|
||||
const rebatePercentUi = useMemo(() => {
|
||||
const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean);
|
||||
if (!first) {
|
||||
return "0";
|
||||
}
|
||||
const n = Number.parseFloat(String(first.rebate_rate));
|
||||
if (!Number.isFinite(n)) {
|
||||
return "0";
|
||||
}
|
||||
return String(Math.round(n * 10000) / 100);
|
||||
}, [scopeRows]);
|
||||
|
||||
function rowIndex(play_code: string, prize_scope: string): number {
|
||||
return draftRows.findIndex((r) => r.play_code === play_code && r.prize_scope === prize_scope);
|
||||
}
|
||||
|
||||
function updateOddsRow(idx: number, patch: Partial<OddsItemRow>) {
|
||||
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
function updateOddsForScope(scope: PrizeScopeCode, patch: Partial<OddsItemRow>) {
|
||||
const idx = rowIndex(resolvedPlayCode, scope);
|
||||
if (idx >= 0) {
|
||||
updateOddsRow(idx, patch);
|
||||
}
|
||||
}
|
||||
|
||||
function setRebateForPlayPercent(percentStr: string) {
|
||||
const p = Number.parseFloat(percentStr);
|
||||
const rate = Number.isFinite(p) ? p / 100 : 0;
|
||||
setDraftRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.play_code === resolvedPlayCode ? { ...r, rebate_rate: String(rate) } : r,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!detail || !isDraft) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = draftRows.map((r) => ({
|
||||
play_code: r.play_code,
|
||||
prize_scope: r.prize_scope,
|
||||
odds_value: r.odds_value,
|
||||
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
||||
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
||||
currency_code: r.currency_code,
|
||||
extra_config_json: r.extra_config_json,
|
||||
}));
|
||||
const d = await putOddsItems(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 publishOddsVersion(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 postOddsVersion({
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback() {
|
||||
if (!rollbackTarget) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const d = await postOddsVersion({
|
||||
reason: `rollback from v${rollbackTarget.version_no}`,
|
||||
clone_from_version_id: rollbackTarget.id,
|
||||
});
|
||||
toast.success(`已自 v${rollbackTarget.version_no} 克隆为新草稿 v${d.version_no}`);
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
setRollbackOpen(false);
|
||||
setRollbackTarget(null);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "回滚失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activeHead = list.find((x) => x.status === "active");
|
||||
|
||||
const catTabs: { id: CatTab; label: string }[] = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "d4", label: "4D" },
|
||||
{ id: "d3", label: "3D" },
|
||||
{ id: "d2", label: "2D" },
|
||||
{ id: "jackpot", label: "Jackpot" },
|
||||
];
|
||||
|
||||
const sortedHistory = useMemo(() => [...list].sort((a, b) => b.id - a.id), [list]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-lg">赔率配置</CardTitle>
|
||||
<CardDescription>
|
||||
对齐 §5.5:分类与玩法切换编辑五档赔率;odds_value = 赔率乘数 × 10000(NPR 100 基准展示)。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-muted-foreground self-center mr-2">分类</span>
|
||||
{catTabs.map((t) => (
|
||||
<Button
|
||||
key={t.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={catTab === t.id ? "default" : "outline"}
|
||||
className={cn(catTab === t.id && "shadow-sm")}
|
||||
onClick={() => {
|
||||
setCatTab(t.id);
|
||||
setPlayCode("");
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">玩法</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredTypes.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">该分类下暂无玩法。</span>
|
||||
) : (
|
||||
filteredTypes.map((t) => (
|
||||
<Button
|
||||
key={t.play_code}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={resolvedPlayCode === t.play_code ? "secondary" : "outline"}
|
||||
onClick={() => setPlayCode(t.play_code)}
|
||||
>
|
||||
{t.display_name_zh ?? t.play_code}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={loadingList}
|
||||
onClick={() => void refreshList()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}>
|
||||
新建草稿
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{detail ? (
|
||||
<div className="rounded-lg border bg-muted/30 px-4 py-3 text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">当前编辑版本:</span>v{detail.version_no} ·{" "}
|
||||
{detail.status === "active" ? "生效中" : detail.status === "draft" ? "草稿" : "已归档"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">当前生效版本:</span>
|
||||
{activeHead ? (
|
||||
<>
|
||||
v{activeHead.version_no}
|
||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</p>
|
||||
{!isDraft ? (
|
||||
<p className="text-amber-600 dark:text-amber-400">当前为只读版本,请新建草稿后再改赔率。</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{loadingDetail || loadingTypes ? (
|
||||
<p className="text-sm text-muted-foreground">加载明细…</p>
|
||||
) : resolvedPlayCode ? (
|
||||
<div className="grid gap-4 max-w-md">
|
||||
{PRIZE_SCOPE_ORDER.map((scope) => {
|
||||
const row = scopeRows[scope];
|
||||
const hint = PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
|
||||
return (
|
||||
<div key={scope} className="grid gap-1">
|
||||
<Label className="flex items-baseline gap-2">
|
||||
{PRIZE_SCOPE_LABELS[scope]}
|
||||
{hint ? <span className="text-xs text-muted-foreground font-normal">{hint}</span> : null}
|
||||
</Label>
|
||||
{row && idx >= 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-9 font-mono tabular-nums max-w-[200px]"
|
||||
disabled={!isDraft || saving}
|
||||
value={row.odds_value}
|
||||
onChange={(e) =>
|
||||
updateOddsForScope(scope, {
|
||||
odds_value: Number.parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
乘数 ×{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">缺少 {scope} 行,请检查种子或版本数据。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid gap-1 pt-2 border-t">
|
||||
<Label>回水率(%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
className="h-9 font-mono tabular-nums max-w-[200px]"
|
||||
disabled={!isDraft || saving}
|
||||
value={rebatePercentUi}
|
||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">写入该玩法下全部奖项档位的 rebate_rate。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setHistoryOpen(true)}>
|
||||
查看历史版本
|
||||
</Button>
|
||||
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<SheetContent side="right" className="sm:max-w-lg flex flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle>赔率版本历史</SheetTitle>
|
||||
<SheetDescription>选择一条历史版本执行回滚(克隆为新草稿)。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-auto rounded-md border mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>版本</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedHistory.map((v) => (
|
||||
<TableRow key={v.id}>
|
||||
<TableCell className="font-mono text-xs">v{v.version_no}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{v.status === "active" ? "生效" : v.status === "draft" ? "草稿" : "归档"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{v.updated_at ? formatDt(v.updated_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={saving || v.status === "draft"}
|
||||
onClick={() => {
|
||||
setRollbackTarget(v);
|
||||
setRollbackOpen(true);
|
||||
setHistoryOpen(false);
|
||||
}}
|
||||
>
|
||||
回滚
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Button type="button" onClick={() => void handleSave()} 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>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认回滚</DialogTitle>
|
||||
<DialogDescription>
|
||||
将以版本 v{rollbackTarget?.version_no} 的快照克隆为新草稿;不会直接覆盖线上生效版本。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRollbackOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
|
||||
确认回滚
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user