Files
lotteryAdmin/src/modules/config/doc/odds-config-doc-screen.tsx

574 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, 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>
</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>
);
}