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,347 @@
"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 { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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_ORDER } from "@/modules/config/doc/prize-scopes";
function rateToPercentUi(rateStr: string): string {
const n = Number.parseFloat(rateStr);
if (!Number.isFinite(n)) {
return "0";
}
return String(Math.round(n * 10000) / 100);
}
function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPlayTypeRow[]): string {
const codes = typeList.filter((t) => (t.dimension ?? 2) === dim).map((t) => t.play_code);
const scope = PRIZE_SCOPE_ORDER[0];
const hit = rows.find((r) => codes.includes(r.play_code) && r.prize_scope === scope);
return hit ? rateToPercentUi(String(hit.rebate_rate)) : "0";
}
export function RebateConfigDocScreen() {
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const [p2, setP2] = useState("0");
const [p3, setP3] = useState("0");
const [p4, setP4] = useState("0");
const refreshTypes = useCallback(async () => {
try {
const d = await getAdminPlayTypes();
setTypes(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载玩法失败");
setTypes([]);
}
}, []);
const refreshList = useCallback(async () => {
try {
const d = await getOddsVersions({ per_page: 50 });
setListRows(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本失败");
setListRows([]);
}
}, []);
useEffect(() => {
queueMicrotask(async () => {
setLoading(true);
await refreshTypes();
await refreshList();
setLoading(false);
});
}, [refreshTypes, refreshList]);
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
try {
const pt = await getAdminPlayTypes();
const typeList = pt.items;
setTypes(typeList);
const d = await getOddsVersion(id);
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
setP2(inferPercentFrom(2, rows, typeList));
setP3(inferPercentFrom(3, rows, typeList));
setP4(inferPercentFrom(4, rows, typeList));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载明细失败");
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
useEffect(() => {
if (listRows.length === 0 || selectedId !== "") {
return;
}
queueMicrotask(() => {
const drafts = listRows.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
const active = listRows.find((x) => x.status === "active");
const pick = drafts[0] ?? active ?? [...listRows].sort((a, b) => b.id - a.id)[0];
if (pick) {
setSelectedId(String(pick.id));
}
});
}, [listRows, selectedId]);
useEffect(() => {
if (selectedId === "") {
return;
}
const id = Number(selectedId);
if (!Number.isFinite(id)) {
return;
}
queueMicrotask(() => {
void loadDetail(id);
});
}, [selectedId, loadDetail]);
const typesByCode = useMemo(() => {
const m = new Map<string, AdminPlayTypeRow>();
for (const t of types) {
m.set(t.play_code, t);
}
return m;
}, [types]);
const isDraft = detail?.status === "draft";
function applyDimensionPercentsToRows(rows: OddsItemRow[]): OddsItemRow[] {
const r2 = Number.parseFloat(p2);
const r3 = Number.parseFloat(p3);
const r4 = Number.parseFloat(p4);
const rate2 = Number.isFinite(r2) ? r2 / 100 : 0;
const rate3 = Number.isFinite(r3) ? r3 / 100 : 0;
const rate4 = Number.isFinite(r4) ? r4 / 100 : 0;
return rows.map((row) => {
const t = typesByCode.get(row.play_code);
const dim = (t?.dimension ?? 2) as 2 | 3 | 4;
const rate = dim === 4 ? rate4 : dim === 3 ? rate3 : rate2;
return { ...row, rebate_rate: String(rate) };
});
}
async function handleSave() {
if (!detail || !isDraft) {
return;
}
setSaving(true);
try {
const nextRows = applyDimensionPercentsToRows(draftRows);
const payload = nextRows.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);
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
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);
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
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 = listRows.find((x) => x.status === "active");
const d = await postOddsVersion({
reason: `rebate draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
} finally {
setSaving(false);
}
}
const activeHead = listRows.find((x) => x.status === "active");
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"> / </CardTitle>
<CardDescription>
§5.6 2D / 3D / 4D rebate_rate odds_versions
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 max-w-xl">
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void refreshList()} disabled={loading}>
</Button>
<Button type="button" size="sm" onClick={() => void handleNewDraft()} disabled={saving}>
稿
</Button>
<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>
{detail ? (
<p className="text-sm text-muted-foreground">
v{detail.version_no} · {detail.status === "draft" ? "草稿" : detail.status === "active" ? "生效中" : "已归档"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
) : null}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<Label>2D %</Label>
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={!isDraft || saving}
value={p2}
onChange={(e) => setP2(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>3D %</Label>
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={!isDraft || saving}
value={p3}
onChange={(e) => setP3(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>4D %</Label>
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={!isDraft || saving}
value={p4}
onChange={(e) => setP4(e.target.value)}
/>
</div>
</div>
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4">
<Checkbox id="win-enjoy" checked aria-disabled disabled aria-label="中奖是否享受回水" />
<div className="grid gap-1">
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
</Label>
<p className="text-xs text-muted-foreground">
/
</p>
</div>
</div>
<div className="grid gap-1 text-sm">
<span className="text-muted-foreground">线</span>
<span className="font-mono text-xs">
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
</span>
</div>
{loading || loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
) : null}
</CardContent>
</Card>
);
}