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