583 lines
20 KiB
TypeScript
583 lines
20 KiB
TypeScript
"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 { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||
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",
|
||
};
|
||
}
|
||
|
||
function isDefaultRiskRow(row: DraftRiskRow): boolean {
|
||
return row.cap_type === "default";
|
||
}
|
||
|
||
function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
|
||
return {
|
||
clientKey: `default-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
draw_id: null,
|
||
normalized_number: "0000",
|
||
cap_amount: amount,
|
||
cap_type: "default",
|
||
};
|
||
}
|
||
|
||
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[]) {
|
||
const defaultRow = rows.find(isDefaultRiskRow);
|
||
if (!defaultRow) {
|
||
setDefaultCapStr("");
|
||
return;
|
||
}
|
||
setDefaultCapStr(String(defaultRow.cap_amount));
|
||
}
|
||
|
||
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 selectedVersionSummary = useMemo(
|
||
() => list.find((x) => String(x.id) === selectedId) ?? null,
|
||
[list, selectedId],
|
||
);
|
||
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
|
||
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
|
||
const isDraft = selectedStatus === "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 (isDefaultRiskRow(r)) {
|
||
if (r.cap_amount <= 0) {
|
||
toast.error("默认封顶金额必须大于 0");
|
||
return;
|
||
}
|
||
continue;
|
||
}
|
||
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 applyDefaultCap() {
|
||
const n = Number.parseInt(defaultCapStr, 10);
|
||
if (!Number.isFinite(n) || n <= 0) {
|
||
toast.error("请输入有效的封顶金额");
|
||
return;
|
||
}
|
||
setDraftRows((prev) => {
|
||
const next = prev.filter((row) => !isDefaultRiskRow(row));
|
||
return [defaultRiskRowFromAmount(n), ...next];
|
||
});
|
||
setSyncOpen(false);
|
||
toast.message("已写入本地草稿,记得保存草稿");
|
||
}
|
||
|
||
const occFiltered = useMemo(() => {
|
||
const q = occSearch.trim();
|
||
if (!q) {
|
||
return draftRows.filter((row) => !isDefaultRiskRow(row));
|
||
}
|
||
return draftRows.filter((r) => !isDefaultRiskRow(r) && r.normalized_number.includes(q));
|
||
}, [draftRows, occSearch]);
|
||
|
||
const specialRows = useMemo(
|
||
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
|
||
[draftRows],
|
||
);
|
||
|
||
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">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<ConfigVersionSwitcher
|
||
versions={list}
|
||
selectedId={selectedId}
|
||
onSelectedIdChange={setSelectedId}
|
||
loading={loadingList}
|
||
sheetTitle="风控封顶版本"
|
||
onDeleteVersion={handleDeleteVersion}
|
||
className="w-auto min-w-0"
|
||
/>
|
||
|
||
<ConfigVersionActions
|
||
isDraft={isDraft}
|
||
loadingList={loadingList}
|
||
loadingDetail={loadingDetail}
|
||
saving={saving}
|
||
onRefresh={() => void refreshList()}
|
||
onNewDraft={() => void handleNewDraft()}
|
||
onSaveDraft={() => void handleSave()}
|
||
onPublish={() => void handlePublish()}
|
||
/>
|
||
|
||
{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}
|
||
</div>
|
||
|
||
{error ? <p className="text-sm text-destructive">{error}</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">
|
||
未设置特殊封顶的号码,将使用此默认封顶模板。
|
||
</p>
|
||
<div className="flex flex-wrap items-end gap-2">
|
||
<div className="grid gap-1">
|
||
<Label htmlFor="default-cap">封顶金额(最小货币单位)</Label>
|
||
{isDraft ? (
|
||
<Input
|
||
id="default-cap"
|
||
type="number"
|
||
min={0}
|
||
className="w-[220px] font-mono tabular-nums"
|
||
disabled={saving}
|
||
value={defaultCapStr}
|
||
onChange={(e) => setDefaultCapStr(e.target.value)}
|
||
/>
|
||
) : (
|
||
<ConfigReadonlyValue mono className="w-[220px]">
|
||
{defaultCapStr || "—"}
|
||
</ConfigReadonlyValue>
|
||
)}
|
||
</div>
|
||
{isDraft ? (
|
||
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
|
||
更新
|
||
</Button>
|
||
) : null}
|
||
</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>
|
||
{isDraft ? (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={saving}
|
||
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
|
||
>
|
||
+ 添加特殊封顶
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{loadingDetail ? (
|
||
<p className="text-sm text-muted-foreground">加载明细…</p>
|
||
) : specialRows.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>
|
||
{specialRows.map(({ row: r, index: idx }) => (
|
||
<TableRow key={r.clientKey}>
|
||
<TableCell>
|
||
{isDraft ? (
|
||
<Input
|
||
className="h-8 font-mono tabular-nums"
|
||
maxLength={4}
|
||
disabled={saving}
|
||
value={r.normalized_number}
|
||
onChange={(e) =>
|
||
updateRow(idx, {
|
||
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||
})
|
||
}
|
||
/>
|
||
) : (
|
||
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
|
||
)}
|
||
</TableCell>
|
||
<TableCell>
|
||
{isDraft ? (
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
className="h-8 font-mono tabular-nums"
|
||
disabled={saving}
|
||
value={r.cap_amount}
|
||
onChange={(e) =>
|
||
updateRow(idx, {
|
||
cap_amount: Number.parseInt(e.target.value, 10) || 0,
|
||
})
|
||
}
|
||
/>
|
||
) : (
|
||
<ConfigReadonlyValue mono>{r.cap_amount}</ConfigReadonlyValue>
|
||
)}
|
||
</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>
|
||
{isDraft ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="text-destructive"
|
||
disabled={saving}
|
||
onClick={() => removeRow(idx)}
|
||
>
|
||
删除
|
||
</Button>
|
||
) : (
|
||
<span className="text-sm text-muted-foreground">只读</span>
|
||
)}
|
||
</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={applyDefaultCap}>
|
||
确认
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Card>
|
||
);
|
||
}
|