Files
lotteryAdmin/src/modules/config/doc/risk-cap-doc-screen.tsx

530 lines
18 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 {
deleteRiskCapVersion,
getAllConfigVersions,
getRiskCapVersion,
getRiskCapVersions,
postRiskCapVersion,
publishRiskCapVersion,
putRiskCapItems,
} 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 { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
ConfigVersionSummary,
RiskCapItemRow,
RiskCapVersionDetail,
} from "@/types/api/admin-config";
type DraftRiskRow = Omit<RiskCapItemRow, "id"> & { clientKey: string };
function newRow(): DraftRiskRow {
return {
clientKey: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
draw_id: null,
normalized_number: "0000",
cap_amount: 0,
cap_type: "per_number",
};
}
export function RiskCapDocScreen() {
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<RiskCapVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<DraftRiskRow[]>([]);
const [loadingList, setLoadingList] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [defaultCapStr, setDefaultCapStr] = useState("");
const [syncOpen, setSyncOpen] = useState(false);
const [occSearch, setOccSearch] = useState("");
const refreshList = useCallback(async () => {
setLoadingList(true);
setError(null);
try {
const d = await getAllConfigVersions(getRiskCapVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void refreshList();
});
}, [refreshList]);
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
if (rows.length === 0) {
setDefaultCapStr("");
return;
}
const amounts = [...new Set(rows.map((r) => r.cap_amount))];
setDefaultCapStr(amounts.length === 1 ? String(amounts[0]) : "");
}
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
try {
const d = await getRiskCapVersion(id);
setDetail(d);
const mapped = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(mapped);
syncDefaultCapFromRows(mapped);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
setDetail(null);
setDraftRows([]);
syncDefaultCapFromRows([]);
} 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 isDraft = detail?.status === "draft";
const updateRow = (idx: number, patch: Partial<DraftRiskRow>) => {
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
};
function removeRow(idx: number) {
setDraftRows((prev) => prev.filter((_, i) => i !== idx));
}
async function handleSave() {
if (!detail || !isDraft) {
return;
}
if (draftRows.length === 0) {
toast.error("至少保留一行封顶配置");
return;
}
for (const r of draftRows) {
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
toast.error(`号码须为 4 位数字:${r.normalized_number}`);
return;
}
}
setSaving(true);
try {
const payload = draftRows.map((r) => ({
draw_id: r.draw_id && r.draw_id > 0 ? r.draw_id : null,
normalized_number: r.normalized_number,
cap_amount: r.cap_amount,
cap_type: r.cap_type,
}));
const d = await putRiskCapItems(detail.id, payload);
setDetail(d);
const saved = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(saved);
syncDefaultCapFromRows(saved);
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 publishRiskCapVersion(detail.id);
setDetail(d);
const pub = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(pub);
syncDefaultCapFromRows(pub);
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 postRiskCapVersion({
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);
const nd = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(nd);
syncDefaultCapFromRows(nd);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
} finally {
setSaving(false);
}
}
function applyDefaultCapToAll() {
const n = Number.parseInt(defaultCapStr, 10);
if (!Number.isFinite(n) || n < 0) {
toast.error("请输入有效的封顶金额");
return;
}
setDraftRows((prev) => prev.map((r) => ({ ...r, cap_amount: n })));
setSyncOpen(false);
toast.message("已写入本地草稿,记得保存草稿");
}
const occFiltered = useMemo(() => {
const q = occSearch.trim();
if (!q) {
return draftRows;
}
return draftRows.filter((r) => r.normalized_number.includes(q));
}, [draftRows, occSearch]);
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteRiskCapVersion(row.id);
toast.success("已删除该版本");
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
throw e;
}
}
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg">
{detail ? (
<span className="text-muted-foreground font-normal">
{" "}
· v{detail.version_no}
</span>
) : null}
</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="风控封顶版本"
onDeleteVersion={handleDeleteVersion}
/>
<div className="flex flex-wrap items-end gap-4">
<Button type="button" variant="secondary" onClick={() => void refreshList()}>
</Button>
<Button type="button" 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>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{detail ? (
<p className="text-sm text-muted-foreground">
{detail.effective_at ? formatDt(detail.effective_at) : "—"} · {detail.reason ?? "—"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
) : null}
</p>
) : null}
<section className="space-y-3 rounded-lg border bg-muted/20 p-4">
<h3 className="text-sm font-medium"></h3>
<p className="text-sm text-muted-foreground">
稿<strong></strong>
</p>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
<Label htmlFor="default-cap"></Label>
<Input
id="default-cap"
type="number"
min={0}
className="w-[220px] font-mono tabular-nums"
disabled={!isDraft || saving}
value={defaultCapStr}
onChange={(e) => setDefaultCapStr(e.target.value)}
/>
</div>
<Button type="button" variant="secondary" disabled={!isDraft || saving} onClick={() => setSyncOpen(true)}>
</Button>
</div>
</section>
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium"></h3>
<Button
type="button"
variant="outline"
disabled={!isDraft || saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
>
+
</Button>
</div>
{loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
) : draftRows.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[72px] text-center"></TableHead>
<TableHead className="w-[160px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{draftRows.map((r, idx) => (
<TableRow key={r.clientKey}>
<TableCell>
<Input
className="h-8 font-mono tabular-nums"
maxLength={4}
disabled={!isDraft || saving}
value={r.normalized_number}
onChange={(e) =>
updateRow(idx, {
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
})
}
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
className="h-8 font-mono tabular-nums"
disabled={!isDraft || saving}
value={r.cap_amount}
onChange={(e) =>
updateRow(idx, {
cap_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
</TableCell>
<TableCell className="text-right text-muted-foreground tabular-nums text-sm"></TableCell>
<TableCell className="text-right text-muted-foreground tabular-nums text-sm"></TableCell>
<TableCell className="text-center text-muted-foreground text-sm"></TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
className="text-destructive"
disabled={!isDraft || saving || draftRows.length <= 1}
onClick={() => removeRow(idx)}
>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</section>
<section className="space-y-3">
<h3 className="text-sm font-medium"></h3>
<p className="text-sm text-muted-foreground">
稿
</p>
<div className="flex flex-wrap gap-3 items-end">
<div className="grid gap-1">
<Label htmlFor="occ-search"></Label>
<Input
id="occ-search"
className="w-[140px] font-mono"
placeholder="如 8888"
value={occSearch}
onChange={(e) => setOccSearch(e.target.value)}
/>
</div>
<Button type="button" variant="outline" onClick={() => toast.message("售罄 / 高风险筛选待接入")}>
</Button>
<Button
type="button"
variant="outline"
onClick={() => toast.message("导出 CSV 待接入")}
>
CSV
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[140px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{occFiltered.map((r) => (
<TableRow key={`occ-${r.clientKey}`}>
<TableCell className="font-mono text-sm">{r.normalized_number}</TableCell>
<TableCell className="text-right text-muted-foreground"></TableCell>
<TableCell className="text-right text-muted-foreground"></TableCell>
<TableCell className="text-right text-muted-foreground"></TableCell>
<TableCell className="text-center text-muted-foreground"></TableCell>
<TableCell>
<Button type="button" variant="ghost" disabled>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
</CardContent>
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{defaultCapStr || "(空)"}稿稿
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
</Button>
<Button type="button" onClick={applyDefaultCapToAll}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}