diff --git a/next.config.ts b/next.config.ts index 4564f99..9b8dcd1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,7 +7,12 @@ const nextConfig: NextConfig = { return [ { source: "/admin/service-desk", - destination: "/admin/menu-permissions", + destination: "/admin", + permanent: true, + }, + { + source: "/admin/menu-permissions", + destination: "/admin", permanent: true, }, ]; diff --git a/src/api/admin-users.ts b/src/api/admin-users.ts index 37068b7..9b8a9c2 100644 --- a/src/api/admin-users.ts +++ b/src/api/admin-users.ts @@ -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 { + return adminRequest.put(`${A}/admin-users/${adminUserId}/roles`, { + role_slugs: roleSlugs, + }); +} diff --git a/src/app/admin/(shell)/menu-permissions/page.tsx b/src/app/admin/(shell)/menu-permissions/page.tsx deleted file mode 100644 index bab5219..0000000 --- a/src/app/admin/(shell)/menu-permissions/page.tsx +++ /dev/null @@ -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 ( - -
-

{menuPermissionsModuleMeta.title}

-
- -
- ); -} diff --git a/src/components/admin/toolbar.tsx b/src/components/admin/toolbar.tsx index a7e9137..c62fd3f 100644 --- a/src/components/admin/toolbar.tsx +++ b/src/components/admin/toolbar.tsx @@ -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)} - - - {displayName} - - - {permissionCount > 0 - ? `${permissionCount} 项功能权限 · 菜单已按角色过滤` - : "重新登录可同步权限与侧栏菜单"} - + + {displayName} diff --git a/src/modules/_config/admin-nav-icons.tsx b/src/modules/_config/admin-nav-icons.tsx index 2f56690..885c2be 100644 --- a/src/modules/_config/admin-nav-icons.tsx +++ b/src/modules/_config/admin-nav-icons.tsx @@ -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 = { dashboard: LayoutDashboard, - menu_permissions: ListTree, players: Users, draws: CalendarClock, config: SlidersHorizontal, diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index 3a557f8..3ad8941 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -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" }, ]; diff --git a/src/modules/admin-users/admin-users-console.tsx b/src/modules/admin-users/admin-users-console.tsx index de86152..e4f57cc 100644 --- a/src/modules/admin-users/admin-users-console.tsx +++ b/src/modules/admin-users/admin-users-console.tsx @@ -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(null); const [selectedId, setSelectedId] = useState(null); + const [draftRoles, setDraftRoles] = useState([]); const [draftPermissions, setDraftPermissions] = useState([]); const [saving, setSaving] = useState(false); + const [savingRoles, setSavingRoles] = useState(false); + const [permissionOpen, setPermissionOpen] = useState(false); + /** `false` = 折叠;缺省为展开 */ + const [directMenuExpanded, setDirectMenuExpanded] = useState>({}); 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 { + 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 { 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 { - - - {!selectedUser ? ( -

请先在上方列表选择一个管理员。

- ) : ( - <> -
- 当前用户: - {selectedUser.username} - ({selectedUser.nickname}) -
+ + + + 管理员权限 + + {selectedUser ? ( + <> + {selectedUser.username} + · {selectedUser.nickname} + + ) : null} + + -
- {(catalog?.permissions ?? []).map((p) => { - const checked = draftPermissions.includes(p.slug); - return ( - - ); - })} -
-
- -
- {selectedUser.roles.length === 0 ? ( - 无角色 +
+
+
+
+

角色

+

+ 保存至默认站点,与「直接权限」叠加为有效权限。 +

+
+
+ {(catalog?.roles ?? []).map((r) => { + const checked = draftRoles.includes(r.slug); + return ( + + ); + })} +
+
+ +
+
+

直接权限

+

+ 按菜单/业务域展开,勾选具体的 prd.*;多数情况只调角色即可。 +

+
+
+ 当前勾选的角色: + {draftRoles.length === 0 ? ( + ) : ( - selectedUser.roles.map((slug) => ( - - {slug} - - )) + + {draftRoles.map((slug) => ( + + {slug} + + ))} + )}
-
- - )} - - +
+ {directPermissionGroups.map((group) => { + const isOpen = isDirectGroupOpen(group.key); + const nSelected = group.permissions.filter((p) => + draftPermissions.includes(p.slug), + ).length; + return ( +
+ + {isOpen ? ( +
+ {group.permissions.map((p) => { + const checked = draftPermissions.includes(p.slug); + return ( + + ); + })} +
+ ) : null} +
+ ); + })} +
+ +
+
+ +
+ +
+ + +
+
+ +
); } diff --git a/src/modules/admin-users/meta.ts b/src/modules/admin-users/meta.ts index 94e3b83..0d69ec8 100644 --- a/src/modules/admin-users/meta.ts +++ b/src/modules/admin-users/meta.ts @@ -1,5 +1,5 @@ export const adminUsersModuleMeta = { segment: "admin_users", - title: "管理员权限", + title: "管理列表", description: "", } as const; diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx index 4fe74e1..d5d6b5a 100644 --- a/src/modules/draws/draws-index-console.tsx +++ b/src/modules/draws/draws-index-console.tsx @@ -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(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() { } > - {(v) => drawAdminStatusSelectLabel(v)} + {drawStatusTriggerLabel} 不限 diff --git a/src/modules/menu-permissions/menu-permissions-console.tsx b/src/modules/menu-permissions/menu-permissions-console.tsx deleted file mode 100644 index e0d5d03..0000000 --- a/src/modules/menu-permissions/menu-permissions-console.tsx +++ /dev/null @@ -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 ( -
-

- 以下为侧栏各菜单与 Laravel 功能权限 slug(prd.* - )的对应关系。未配置「所需权限」的项对任意已登录管理员显示;已配置的项需拥有所列权限中的至少一项 - 才会出现在侧栏。 -

- - - - 菜单 - 路径 - 所需权限(任一) - - - - {adminShellNavItems.map((item) => { - const req = item.requiredAny; - const permCell = - req === undefined || req.length === 0 ? ( - —(任意已登录) - ) : ( -
    - {req.map((slug) => ( -
  • {slug}
  • - ))} -
- ); - return ( - - {item.label} - - - {item.href} - - - {permCell} - - ); - })} -
-
-
- ); -} diff --git a/src/modules/menu-permissions/meta.ts b/src/modules/menu-permissions/meta.ts deleted file mode 100644 index 7e01926..0000000 --- a/src/modules/menu-permissions/meta.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const menuPermissionsModuleMeta = { - segment: "menu_permissions", - title: "菜单权限", - description: "", -} as const; diff --git a/src/modules/reconcile/reconcile-console.tsx b/src/modules/reconcile/reconcile-console.tsx index 7df1ff3..6bc164b 100644 --- a/src/modules/reconcile/reconcile-console.tsx +++ b/src/modules/reconcile/reconcile-console.tsx @@ -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[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(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 { + 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[0]["items"]; - const trimmed = itemsJson.trim(); - if (trimmed !== "" && trimmed !== "[]") { - try { - itemsPayload = JSON.parse(trimmed) as NonNullable< - Parameters[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[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 ? ( - 新建对账任务 + 人工发起对账 + + 异常流水由定时任务自动核对。此处供财务按产品文档手动触发 + :选择对账类型与时间范围;可选填写待核对对象(玩家标识、划转单号或幂等键,每行一条)。任务与明细落库留痕,后续可接自动差异引擎。 +
- - 对账类型 +
- + setPeriodStart(e.target.value)} + type="datetime-local" + value={periodStartLocal} + onChange={(e) => setPeriodStartLocal(e.target.value)} />
- + setPeriodEnd(e.target.value)} + type="datetime-local" + value={periodEndLocal} + onChange={(e) => setPeriodEndLocal(e.target.value)} />
- +