feat: 更新管理员导航,重定向菜单权限至根路径,添加角色同步API,移除菜单权限模块
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
AdminPermissionCatalogData,
|
||||
AdminUserPermissionListData,
|
||||
AdminUserPermissionSyncData,
|
||||
AdminUserRoleSyncData,
|
||||
} from "@/types/api/admin-user";
|
||||
|
||||
const A = `${API_V1_PREFIX}/admin`;
|
||||
@@ -33,3 +34,12 @@ export async function putAdminUserPermissions(
|
||||
{ permission_slugs: permissionSlugs },
|
||||
);
|
||||
}
|
||||
|
||||
export async function putAdminUserRoles(
|
||||
adminUserId: number,
|
||||
roleSlugs: string[],
|
||||
): Promise<AdminUserRoleSyncData> {
|
||||
return adminRequest.put<AdminUserRoleSyncData>(`${A}/admin-users/${adminUserId}/roles`, {
|
||||
role_slugs: roleSlugs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { MenuPermissionsConsole } from "@/modules/menu-permissions/menu-permissions-console";
|
||||
import { menuPermissionsModuleMeta } from "@/modules/menu-permissions/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: menuPermissionsModuleMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminMenuPermissionsPage() {
|
||||
return (
|
||||
<ModuleScaffold className="w-full max-w-none">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{menuPermissionsModuleMeta.title}</h1>
|
||||
</div>
|
||||
<MenuPermissionsConsole />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
@@ -93,8 +93,6 @@ export function ShellToolbar() {
|
||||
adminProfile?.username?.trim() ||
|
||||
"管理员";
|
||||
|
||||
const permissionCount = adminProfile?.permissions?.length ?? 0;
|
||||
|
||||
function onLogout() {
|
||||
clearSession();
|
||||
toast.success("已退出登录");
|
||||
@@ -170,15 +168,8 @@ export function ShellToolbar() {
|
||||
{initialsFromProfile(adminProfile)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden min-w-0 flex-1 flex-col sm:flex">
|
||||
<span className="truncate text-sm font-semibold leading-tight">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{permissionCount > 0
|
||||
? `${permissionCount} 项功能权限 · 菜单已按角色过滤`
|
||||
: "重新登录可同步权限与侧栏菜单"}
|
||||
</span>
|
||||
<span className="hidden min-w-0 flex-1 truncate text-sm font-semibold leading-tight sm:block">
|
||||
{displayName}
|
||||
</span>
|
||||
<ChevronDownIcon className="hidden size-4 shrink-0 text-muted-foreground sm:block" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
FileSpreadsheet,
|
||||
Landmark,
|
||||
LayoutDashboard,
|
||||
ListTree,
|
||||
LogIn,
|
||||
Scale,
|
||||
ScrollText,
|
||||
@@ -24,7 +23,6 @@ import type { AdminNavItem } from "@/modules/_config/admin-nav";
|
||||
export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon> =
|
||||
{
|
||||
dashboard: LayoutDashboard,
|
||||
menu_permissions: ListTree,
|
||||
players: Users,
|
||||
draws: CalendarClock,
|
||||
config: SlidersHorizontal,
|
||||
|
||||
@@ -10,7 +10,6 @@ export type AdminNavItem = {
|
||||
href: string;
|
||||
segment:
|
||||
| "dashboard"
|
||||
| "menu_permissions"
|
||||
| "players"
|
||||
| "draws"
|
||||
| "config"
|
||||
@@ -31,6 +30,12 @@ export type AdminNavItem = {
|
||||
|
||||
export const adminShellNavItems: AdminNavItem[] = [
|
||||
{ segment: "dashboard", label: "仪表盘", href: "/admin" },
|
||||
{
|
||||
segment: "admin_users",
|
||||
label: "管理列表",
|
||||
href: "/admin/admin-users",
|
||||
requiredAny: ["prd.admin_user.manage"],
|
||||
},
|
||||
{
|
||||
segment: "draws",
|
||||
label: "开奖",
|
||||
@@ -125,11 +130,6 @@ export const adminShellNavItems: AdminNavItem[] = [
|
||||
"prd.users.view_cs",
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: "menu_permissions",
|
||||
label: "菜单权限",
|
||||
href: "/admin/menu-permissions",
|
||||
},
|
||||
{
|
||||
segment: "reports",
|
||||
label: "报表导出",
|
||||
@@ -147,11 +147,5 @@ export const adminShellNavItems: AdminNavItem[] = [
|
||||
href: "/admin/audit-logs",
|
||||
requiredAny: ["prd.audit.all", "prd.audit.self", "prd.audit.finance"],
|
||||
},
|
||||
{
|
||||
segment: "admin_users",
|
||||
label: "管理员权限",
|
||||
href: "/admin/admin-users",
|
||||
requiredAny: ["prd.admin_user.manage"],
|
||||
},
|
||||
{ segment: "settings", label: "系统设置", href: "/admin/settings" },
|
||||
];
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminUserPermissionCatalog,
|
||||
getAdminUsers,
|
||||
putAdminUserPermissions,
|
||||
putAdminUserRoles,
|
||||
} from "@/api/admin-users";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -24,6 +32,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
|
||||
|
||||
export function AdminUsersConsole(): React.ReactElement {
|
||||
@@ -40,14 +49,57 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [draftRoles, setDraftRoles] = useState<string[]>([]);
|
||||
const [draftPermissions, setDraftPermissions] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingRoles, setSavingRoles] = useState(false);
|
||||
const [permissionOpen, setPermissionOpen] = useState(false);
|
||||
/** `false` = 折叠;缺省为展开 */
|
||||
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const selectedUser = useMemo(
|
||||
() => items.find((u) => u.id === selectedId) ?? null,
|
||||
[items, selectedId],
|
||||
);
|
||||
|
||||
function openPermissionEditor(row: AdminUserPermissionRow): void {
|
||||
setSelectedId(row.id);
|
||||
setDraftRoles([...row.roles].sort());
|
||||
setDraftPermissions([...row.direct_permissions].sort());
|
||||
setDirectMenuExpanded({});
|
||||
setPermissionOpen(true);
|
||||
}
|
||||
|
||||
function handlePermissionDialogOpenChange(open: boolean): void {
|
||||
setPermissionOpen(open);
|
||||
if (!open) {
|
||||
setSelectedId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const directPermissionGroups = useMemo(() => {
|
||||
const g = catalog?.permission_menu_groups;
|
||||
if (g && g.length > 0) {
|
||||
return g;
|
||||
}
|
||||
const flat = catalog?.permissions ?? [];
|
||||
if (flat.length > 0) {
|
||||
return [{ key: "all", label: "全部权限", permissions: flat }];
|
||||
}
|
||||
return [];
|
||||
}, [catalog]);
|
||||
|
||||
function isDirectGroupOpen(key: string): boolean {
|
||||
return directMenuExpanded[key] !== false;
|
||||
}
|
||||
|
||||
function toggleDirectGroup(key: string): void {
|
||||
setDirectMenuExpanded((prev) => {
|
||||
const wasOpen = prev[key] !== false;
|
||||
return { ...prev, [key]: wasOpen ? false : true };
|
||||
});
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
@@ -90,6 +142,43 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRole(slug: string, checked: boolean): void {
|
||||
setDraftRoles((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((s) => s !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveRoles(): Promise<void> {
|
||||
if (!selectedUser) {
|
||||
return;
|
||||
}
|
||||
setSavingRoles(true);
|
||||
try {
|
||||
const result = await putAdminUserRoles(selectedUser.id, draftRoles);
|
||||
setDraftRoles([...result.roles].sort());
|
||||
setItems((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === result.id
|
||||
? {
|
||||
...row,
|
||||
roles: result.roles,
|
||||
effective_permissions: result.effective_permissions,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
);
|
||||
toast.success(`已更新 ${result.username} 的角色`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : "保存角色失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSavingRoles(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePermissions(): Promise<void> {
|
||||
if (!selectedUser) {
|
||||
return;
|
||||
@@ -98,6 +187,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
try {
|
||||
const result = await putAdminUserPermissions(selectedUser.id, draftPermissions);
|
||||
setDraftPermissions([...result.direct_permissions].sort());
|
||||
setDraftRoles([...result.roles].sort());
|
||||
setItems((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === result.id
|
||||
@@ -105,6 +195,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
...row,
|
||||
direct_permissions: result.direct_permissions,
|
||||
effective_permissions: result.effective_permissions,
|
||||
roles: result.roles,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
@@ -206,10 +297,9 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selectedId === row.id ? "default" : "outline"}
|
||||
variant={permissionOpen && selectedId === row.id ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setSelectedId(row.id);
|
||||
setDraftPermissions([...row.direct_permissions].sort());
|
||||
openPermissionEditor(row);
|
||||
}}
|
||||
>
|
||||
权限
|
||||
@@ -237,65 +327,163 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>权限分配</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void savePermissions()}
|
||||
disabled={!selectedUser || saving}
|
||||
>
|
||||
{saving ? "保存中…" : "保存权限"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selectedUser ? (
|
||||
<p className="text-sm text-muted-foreground">请先在上方列表选择一个管理员。</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border bg-muted/20 p-3 text-sm">
|
||||
当前用户:
|
||||
<span className="ml-1 font-medium">{selectedUser.username}</span>
|
||||
<span className="ml-2 text-muted-foreground">({selectedUser.nickname})</span>
|
||||
</div>
|
||||
<Dialog open={permissionOpen} onOpenChange={handlePermissionDialogOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton
|
||||
className="flex h-[min(88vh,800px)] max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-[min(85vh,780px)] sm:max-w-3xl"
|
||||
>
|
||||
<DialogHeader className="shrink-0 space-y-1 border-b px-4 py-3 pr-12">
|
||||
<DialogTitle>管理员权限</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedUser ? (
|
||||
<>
|
||||
<span className="font-medium text-foreground">{selectedUser.username}</span>
|
||||
<span className="text-muted-foreground"> · {selectedUser.nickname}</span>
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 rounded-md border p-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(catalog?.permissions ?? []).map((p) => {
|
||||
const checked = draftPermissions.includes(p.slug);
|
||||
return (
|
||||
<label key={p.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => togglePermission(p.slug, v === true)}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none">{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.slug}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>角色带来的权限(只读)</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedUser.roles.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">无角色</span>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
||||
<div className="space-y-6 pb-1">
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium leading-none">角色</h3>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
保存至默认站点,与「直接权限」叠加为有效权限。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||
{(catalog?.roles ?? []).map((r) => {
|
||||
const checked = draftRoles.includes(r.slug);
|
||||
return (
|
||||
<label key={r.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleRole(r.slug, v === true)}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none font-medium">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{r.slug}</span>
|
||||
<span className="block text-xs text-muted-foreground/90">
|
||||
含 {r.permission_slugs.length} 项功能权限
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium leading-none">直接权限</h3>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
按菜单/业务域展开,勾选具体的 prd.*;多数情况只调角色即可。
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 p-2.5 text-xs text-muted-foreground">
|
||||
当前勾选的角色:
|
||||
{draftRoles.length === 0 ? (
|
||||
<span className="ml-1 text-foreground/80">无</span>
|
||||
) : (
|
||||
selectedUser.roles.map((slug) => (
|
||||
<Badge key={slug} variant="outline">
|
||||
{slug}
|
||||
</Badge>
|
||||
))
|
||||
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
|
||||
{draftRoles.map((slug) => (
|
||||
<Badge key={slug} variant="secondary" className="text-xs font-normal">
|
||||
{slug}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{directPermissionGroups.map((group) => {
|
||||
const isOpen = isDirectGroupOpen(group.key);
|
||||
const nSelected = group.permissions.filter((p) =>
|
||||
draftPermissions.includes(p.slug),
|
||||
).length;
|
||||
return (
|
||||
<div key={group.key} className="border-b last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
|
||||
onClick={() => toggleDirectGroup(group.key)}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 font-medium">{group.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
||||
{nSelected}/{group.permissions.length}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="space-y-2 border-t bg-muted/20 px-3 py-3 sm:grid sm:grid-cols-2 sm:gap-2 sm:gap-y-3">
|
||||
{group.permissions.map((p) => {
|
||||
const checked = draftPermissions.includes(p.slug);
|
||||
return (
|
||||
<label key={p.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => togglePermission(p.slug, v === true)}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none">{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.slug}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 flex-col gap-3 border-t bg-muted/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
data-slot="dialog-footer-actions"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
onClick={() => {
|
||||
handlePermissionDialogOpenChange(false);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-nowrap sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
disabled={!selectedUser || savingRoles}
|
||||
onClick={() => void saveRoles()}
|
||||
>
|
||||
{savingRoles ? "保存中…" : "保存角色"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
disabled={!selectedUser || saving}
|
||||
onClick={() => void savePermissions()}
|
||||
>
|
||||
{saving ? "保存中…" : "保存直接权限"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const adminUsersModuleMeta = {
|
||||
segment: "admin_users",
|
||||
title: "管理员权限",
|
||||
title: "管理列表",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
@@ -68,6 +68,16 @@ export function DrawsIndexConsole() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState<number>(20);
|
||||
|
||||
const drawStatusTriggerLabel = useMemo(
|
||||
() =>
|
||||
drawAdminStatusSelectLabel(
|
||||
draftStatus === "" || !DRAW_STATUS_OPTIONS.some((o) => o.value === draftStatus)
|
||||
? DRAW_FILTER_ALL
|
||||
: draftStatus,
|
||||
),
|
||||
[draftStatus],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -142,7 +152,7 @@ export function DrawsIndexConsole() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="draw-filter-status" className="h-8 w-full min-w-0 sm:w-44">
|
||||
<SelectValue>{(v) => drawAdminStatusSelectLabel(v)}</SelectValue>
|
||||
<SelectValue>{drawStatusTriggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={DRAW_FILTER_ALL}>不限</SelectItem>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { adminShellNavItems } from "@/modules/_config/admin-nav";
|
||||
|
||||
export function MenuPermissionsConsole() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
以下为侧栏各菜单与 Laravel 功能权限 slug(<code className="text-foreground">prd.*</code>
|
||||
)的对应关系。未配置「所需权限」的项对任意已登录管理员显示;已配置的项需拥有所列权限中的<strong className="text-foreground font-medium">至少一项</strong>
|
||||
才会出现在侧栏。
|
||||
</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[160px]">菜单</TableHead>
|
||||
<TableHead className="w-[200px]">路径</TableHead>
|
||||
<TableHead>所需权限(任一)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminShellNavItems.map((item) => {
|
||||
const req = item.requiredAny;
|
||||
const permCell =
|
||||
req === undefined || req.length === 0 ? (
|
||||
<span className="text-muted-foreground">—(任意已登录)</span>
|
||||
) : (
|
||||
<ul className="font-mono text-xs leading-relaxed">
|
||||
{req.map((slug) => (
|
||||
<li key={slug}>{slug}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
return (
|
||||
<TableRow key={item.segment}>
|
||||
<TableCell className="font-medium">{item.label}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-primary font-mono text-xs underline-offset-4 hover:underline"
|
||||
>
|
||||
{item.href}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{permCell}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const menuPermissionsModuleMeta = {
|
||||
segment: "menu_permissions",
|
||||
title: "菜单权限",
|
||||
description: "",
|
||||
} as const;
|
||||
@@ -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_start(ISO)</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_end(ISO)</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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, 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,
|
||||
@@ -20,17 +30,48 @@ import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
|
||||
|
||||
const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "pending", label: "未开始" },
|
||||
{ value: "open", label: "可下注" },
|
||||
{ value: "closing", label: "封盘中" },
|
||||
{ value: "closed", label: "已封盘待开奖" },
|
||||
{ value: "drawing", label: "开奖处理中" },
|
||||
{ value: "review", label: "待审核" },
|
||||
{ value: "cooldown", label: "冷静期" },
|
||||
{ value: "settling", label: "结算中" },
|
||||
{ value: "settled", label: "已结算" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
export function RiskIndexConsole() {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminDrawListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(25);
|
||||
const [drawNoInput, setDrawNoInput] = useState("");
|
||||
const [drawNoQuery, setDrawNoQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const riskStatusTriggerLabel = useMemo(() => {
|
||||
if (statusFilter === "") {
|
||||
return "全部";
|
||||
}
|
||||
return DRAW_STATUS_OPTIONS.find((o) => o.value === statusFilter)?.label ?? statusFilter;
|
||||
}, [statusFilter]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await getAdminDraws({ page: 1, per_page: 50 });
|
||||
const d = await getAdminDraws({
|
||||
page,
|
||||
per_page: perPage,
|
||||
...(drawNoQuery.trim() !== "" ? { draw_no: drawNoQuery.trim() } : {}),
|
||||
...(statusFilter !== "" ? { status: statusFilter } : {}),
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
@@ -40,7 +81,7 @@ export function RiskIndexConsole() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [page, perPage, drawNoQuery, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -48,14 +89,74 @@ export function RiskIndexConsole() {
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
function applySearch(): void {
|
||||
setDrawNoQuery(drawNoInput.trim());
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const total = data?.meta.total ?? 0;
|
||||
const lastPage = Math.max(1, data?.meta.last_page ?? 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<CardTitle className="text-lg">风控中心</CardTitle>
|
||||
<div className="flex w-full max-w-4xl flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="grid flex-1 gap-2 sm:min-w-[12rem]">
|
||||
<Label htmlFor="risk-index-draw-no" className="text-xs text-muted-foreground">
|
||||
期号
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-index-draw-no"
|
||||
placeholder="模糊匹配期号"
|
||||
value={drawNoInput}
|
||||
onChange={(e) => setDrawNoInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
applySearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:w-44">
|
||||
<Label htmlFor="risk-index-status" className="text-xs text-muted-foreground">
|
||||
状态
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={statusFilter === "" ? "all" : statusFilter}
|
||||
onValueChange={(v) => {
|
||||
const next = v == null || v === "all" ? "" : v;
|
||||
setStatusFilter(next);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-index-status" size="sm" className="w-full">
|
||||
<SelectValue>{riskStatusTriggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
{DRAW_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" onClick={() => applySearch()}>
|
||||
搜索
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{loading ? (
|
||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
@@ -69,29 +170,50 @@ export function RiskIndexConsole() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.items ?? []).map((row: AdminDrawListItem) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono font-medium">{row.draw_no}</TableCell>
|
||||
<TableCell>
|
||||
<DrawStatusBadge status={row.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{row.close_time ? formatDt(row.close_time) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link
|
||||
href={`/admin/risk/draws/${row.id}/occupancy`}
|
||||
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
|
||||
>
|
||||
进入风控
|
||||
</Link>
|
||||
{(data?.items ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
) : (
|
||||
(data?.items ?? []).map((row: AdminDrawListItem) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono font-medium">{row.draw_no}</TableCell>
|
||||
<TableCell>
|
||||
<DrawStatusBadge status={row.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{row.close_time ? formatDt(row.close_time) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link
|
||||
href={`/admin/risk/draws/${row.id}/occupancy`}
|
||||
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
|
||||
>
|
||||
进入风控
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
<AdminListPaginationFooter
|
||||
selectId="risk-index-draws-per-page"
|
||||
total={total}
|
||||
page={page}
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,12 @@ export type AdminUserPermissionListData = {
|
||||
|
||||
export type AdminPermissionCatalogData = {
|
||||
permissions: { id: number; slug: string; name: string }[];
|
||||
/** 菜单/业务域 → 下属直接权限(prd.*),二级列表用 */
|
||||
permission_menu_groups?: {
|
||||
key: string;
|
||||
label: string;
|
||||
permissions: { id: number; slug: string; name: string }[];
|
||||
}[];
|
||||
roles: {
|
||||
id: number;
|
||||
slug: string;
|
||||
@@ -38,3 +44,6 @@ export type AdminUserPermissionSyncData = {
|
||||
direct_permissions: string[];
|
||||
effective_permissions: string[];
|
||||
};
|
||||
|
||||
/** 与 {@link AdminUserPermissionSyncData} 相同(角色同步返回体一致) */
|
||||
export type AdminUserRoleSyncData = AdminUserPermissionSyncData;
|
||||
|
||||
@@ -60,4 +60,5 @@ export type {
|
||||
AdminUserPermissionListData,
|
||||
AdminUserPermissionRow,
|
||||
AdminUserPermissionSyncData,
|
||||
AdminUserRoleSyncData,
|
||||
} from "./admin-user";
|
||||
|
||||
Reference in New Issue
Block a user