feat: 更新管理员导航,重定向菜单权限至根路径,添加角色同步API,移除菜单权限模块

This commit is contained in:
2026-05-13 10:40:12 +08:00
parent 188c6a04cf
commit 96b966cf62
15 changed files with 640 additions and 243 deletions

View File

@@ -11,9 +11,16 @@ import {
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -34,6 +41,70 @@ import type {
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
/** 与后端 reconcile_type 对齐;扩展时在 API 与下拉同步增加 */
const RECONCILE_TYPE_OPTIONS = [{ value: "wallet_transfer", label: "钱包划转(主站 ⇄ 彩票)" }] as const;
function reconcileTypeLabel(slug: string): string {
const hit = RECONCILE_TYPE_OPTIONS.find((o) => o.value === slug);
return hit?.label ?? slug;
}
function jobStatusLabel(status: string): string {
switch (status) {
case "completed":
return "已完成";
case "running":
return "执行中";
case "failed":
return "失败";
default:
return status;
}
}
function itemStatusLabel(status: string): string {
switch (status) {
case "mismatch":
return "不一致";
case "matched":
return "一致";
case "pending_check":
return "待核对";
default:
return status;
}
}
function toIsoFromDatetimeLocal(local: string): string | null {
const t = local.trim();
if (t === "") {
return null;
}
const d = new Date(t);
if (Number.isNaN(d.getTime())) {
return null;
}
return d.toISOString();
}
function scopeLinesToItems(
raw: string,
): NonNullable<Parameters<typeof postAdminReconcileJob>[0]["items"]> | undefined {
const lines = raw
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
if (lines.length === 0) {
return undefined;
}
return lines.map((side_a_ref) => ({
side_a_ref,
side_b_ref: null,
difference_amount: 0,
status: "pending_check",
}));
}
export function ReconcileConsole(): React.ReactElement {
const profile = useAdminProfile();
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
@@ -51,9 +122,11 @@ export function ReconcileConsole(): React.ReactElement {
const [itemsPerPage, setItemsPerPage] = useState(50);
const [itemsLoading, setItemsLoading] = useState(false);
const [reconcileType, setReconcileType] = useState("wallet_transfer");
const [periodStart, setPeriodStart] = useState("");
const [periodEnd, setPeriodEnd] = useState("");
const [reconcileType, setReconcileType] = useState<string>(RECONCILE_TYPE_OPTIONS[0].value);
const [periodStartLocal, setPeriodStartLocal] = useState("");
const [periodEndLocal, setPeriodEndLocal] = useState("");
const [scopeLines, setScopeLines] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [itemsJson, setItemsJson] = useState("[]");
const [submitting, setSubmitting] = useState(false);
@@ -104,28 +177,55 @@ export function ReconcileConsole(): React.ReactElement {
}, [loadItems]);
async function onCreate(): Promise<void> {
if (!periodStartLocal.trim() || !periodEndLocal.trim()) {
toast.error("请填写对账时间范围(开始与结束)");
return;
}
const periodStartIso = toIsoFromDatetimeLocal(periodStartLocal);
const periodEndIso = toIsoFromDatetimeLocal(periodEndLocal);
if (periodStartIso == null || periodEndIso == null) {
toast.error("时间无效,请检查所选日期与时间");
return;
}
if (new Date(periodStartIso).getTime() > new Date(periodEndIso).getTime()) {
toast.error("结束时间需晚于或等于开始时间");
return;
}
let itemsPayload: Parameters<typeof postAdminReconcileJob>[0]["items"];
const trimmed = itemsJson.trim();
if (trimmed !== "" && trimmed !== "[]") {
try {
itemsPayload = JSON.parse(trimmed) as NonNullable<
Parameters<typeof postAdminReconcileJob>[0]["items"]
>;
} catch {
toast.error("items JSON 无法解析");
return;
if (showAdvanced) {
const trimmed = itemsJson.trim();
if (trimmed !== "" && trimmed !== "[]") {
try {
itemsPayload = JSON.parse(trimmed) as NonNullable<
Parameters<typeof postAdminReconcileJob>[0]["items"]
>;
} catch {
toast.error("高级选项中的 JSON 无法解析");
return;
}
}
}
if (itemsPayload === undefined) {
itemsPayload = scopeLinesToItems(scopeLines);
}
setSubmitting(true);
try {
await postAdminReconcileJob({
reconcile_type: reconcileType,
period_start: periodStart.trim() || undefined,
period_end: periodEnd.trim() || undefined,
period_start: periodStartIso,
period_end: periodEndIso,
items: itemsPayload,
});
toast.success("已创建对账任务");
setPage(1);
setScopeLines("");
if (showAdvanced) {
setItemsJson("[]");
}
await loadJobs();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建失败");
@@ -142,49 +242,98 @@ export function ReconcileConsole(): React.ReactElement {
{canCreate ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle></CardTitle>
<CardDescription>
<strong className="font-medium text-foreground"></strong>
</CardDescription>
</CardHeader>
<CardContent className="grid max-w-3xl gap-4">
<div className="grid gap-1.5">
<Label htmlFor="rc-type">reconcile_type</Label>
<Input
id="rc-type"
<Label htmlFor="rc-type"></Label>
<Select
modal={false}
value={reconcileType}
onChange={(e) => setReconcileType(e.target.value)}
/>
onValueChange={(v) => {
if (v != null && v !== "") {
setReconcileType(v);
}
}}
>
<SelectTrigger id="rc-type" className="w-full max-w-md">
<SelectValue>{reconcileTypeLabel(reconcileType)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{RECONCILE_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="rc-start">period_startISO</Label>
<Label htmlFor="rc-start"></Label>
<Input
id="rc-start"
placeholder="2026-05-01T00:00:00Z"
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
type="datetime-local"
value={periodStartLocal}
onChange={(e) => setPeriodStartLocal(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-end">period_endISO</Label>
<Label htmlFor="rc-end"></Label>
<Input
id="rc-end"
placeholder="2026-05-02T00:00:00Z"
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
type="datetime-local"
value={periodEndLocal}
onChange={(e) => setPeriodEndLocal(e.target.value)}
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-items">items JSON</Label>
<Label htmlFor="rc-scope"></Label>
<Textarea
id="rc-items"
value={itemsJson}
onChange={(e) => setItemsJson(e.target.value)}
rows={6}
className="font-mono text-xs"
id="rc-scope"
value={scopeLines}
onChange={(e) => setScopeLines(e.target.value)}
rows={5}
placeholder={
"每行一条待核对引用,例如:玩家 ID、钱包划转单号、幂等键等。\n留空表示本时间段内不额外指定单据仅任务留痕。"
}
className="min-h-[100px] text-sm"
/>
<p className="text-xs text-muted-foreground">
pending_reconcile使
</p>
</div>
<div className="flex flex-col gap-2 border-t pt-4">
<Button
type="button"
variant="ghost"
size="sm"
className="w-fit px-0 text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((x) => !x)}
>
{showAdvanced ? "收起" : "展开"} JSON
</Button>
{showAdvanced ? (
<div className="grid gap-1.5">
<Label htmlFor="rc-items-adv"> JSON</Label>
<Textarea
id="rc-items-adv"
value={itemsJson}
onChange={(e) => setItemsJson(e.target.value)}
rows={6}
className="font-mono text-xs"
placeholder='[{"side_a_ref":"TO-1","side_b_ref":"MAIN-1","difference_amount":100,"status":"mismatch"}]'
/>
</div>
) : null}
</div>
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
{submitting ? "提交中…" : "创建任务"}
{submitting ? "提交中…" : "创建对账任务"}
</Button>
</CardContent>
</Card>
@@ -196,6 +345,7 @@ export function ReconcileConsole(): React.ReactElement {
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-1.5"></CardDescription>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
@@ -216,7 +366,7 @@ export function ReconcileConsole(): React.ReactElement {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
@@ -243,12 +393,15 @@ export function ReconcileConsole(): React.ReactElement {
>
<TableCell className="tabular-nums">{row.id}</TableCell>
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
<TableCell>{row.reconcile_type}</TableCell>
<TableCell className="text-sm">{reconcileTypeLabel(row.reconcile_type)}</TableCell>
<TableCell>
<Badge variant="secondary">{row.status}</Badge>
<Badge variant="secondary">{jobStatusLabel(row.status)}</Badge>
</TableCell>
<TableCell className="max-w-[14rem] truncate text-xs text-muted-foreground">
{row.period_start ?? "—"} ~ {row.period_end ?? "—"}
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
<span className="line-clamp-2">
{row.period_start ? formatTs(row.period_start) : "—"} ~{" "}
{row.period_end ? formatTs(row.period_end) : "—"}
</span>
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
@@ -282,7 +435,8 @@ export function ReconcileConsole(): React.ReactElement {
{selectedId != null ? (
<Card>
<CardHeader>
<CardTitle> #{selectedId} </CardTitle>
<CardTitle></CardTitle>
<CardDescription className="font-mono text-xs">#{selectedId}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{itemsLoading && !items ? (
@@ -291,16 +445,16 @@ export function ReconcileConsole(): React.ReactElement {
{items ? (
<>
{items.job_no ? (
<p className="font-mono text-sm text-muted-foreground">{items.job_no}</p>
<p className="font-mono text-sm text-muted-foreground"> {items.job_no}</p>
) : null}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead>side_a_ref</TableHead>
<TableHead>side_b_ref</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
@@ -318,7 +472,7 @@ export function ReconcileConsole(): React.ReactElement {
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
<TableCell className="tabular-nums">{r.difference_amount}</TableCell>
<TableCell>{r.status}</TableCell>
<TableCell className="text-sm">{itemStatusLabel(r.status)}</TableCell>
</TableRow>
))
)}