diff --git a/src/api/modules/roles.ts b/src/api/modules/roles.ts new file mode 100644 index 0000000..4a5e897 --- /dev/null +++ b/src/api/modules/roles.ts @@ -0,0 +1,32 @@ +import { api } from '@/api/client' +import type { AdminRole, ApiResponse, PermissionCatalog, RoleUser } from '@/api/types' + +const BASE = '/api/admin/v1/auth' + +export function fetchRoles() { + return api.get>(`${BASE}/roles`) +} + +export function createRole(name: string) { + return api.post>(`${BASE}/roles`, { name }) +} + +export function syncRolePermissions(role: string, permissions: string[]) { + return api.put>(`${BASE}/roles/${role}/permissions`, { permissions }) +} + +export function deleteRole(role: string) { + return api.delete>(`${BASE}/roles/${role}`) +} + +export function fetchPermissionCatalog() { + return api.get>(`${BASE}/permissions`) +} + +export function searchRoleUsers(q: string) { + return api.get>(`${BASE}/users`, { q }) +} + +export function syncUserRoles(uid: string, roles: string[]) { + return api.put>(`${BASE}/users/${uid}/roles`, { roles }) +} diff --git a/src/api/types.ts b/src/api/types.ts index cbd8c72..ad93b8e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -352,6 +352,94 @@ export interface RetranslateResult { queued_blocks: number } +export interface AdminRole { + kind?: string + name: string + permissions: string[] + users_count: number + builtin: boolean +} + +/** module → { module, groups: { group → { permissionKey → description } } } */ +export type PermissionCatalog = Record< + string, + { module: string; groups: Record> } +> + +export interface RoleUser { + kind?: string + uid: string + display_name?: string | null + email?: string | null + username?: string | null + roles: string[] +} + +export type DimzouDocumentState = 'active' | 'archived' | 'trashed' + +export interface DimzouDocumentVersion { + id: number + document_id: number + version_number: number + status: 'draft' | 'published' | 'superseded' + visibility?: 'public' | 'group_only' + structured_title?: unknown + language?: string | null + publication_id?: number | null + published_at?: string | null + frozen_at?: string | null + created_at?: string + updated_at?: string +} + +export interface DimzouDocument { + kind?: string + id: number + title?: string | null + owner?: ReportUser | null + category?: { id: string; name?: string | null } | null + state: DimzouDocumentState + versions_count: number + published_version?: number | null + draft_version?: number | null + archived_at?: string | null + deleted_at?: string | null + created_at?: string + updated_at?: string + // detail-only + versions?: DimzouDocumentVersion[] + translations?: Array<{ + id: number + language_code: string + status: DimzouTranslationStatus + translated_document_id: number + created_by_uid: string + created_at?: string + }> + pending_revisions_count?: number + pending_join_requests_count?: number +} + +export interface DimzouPublication { + kind?: string + id: number + document_id: number + document_version_id: number + version_number: number + visibility: 'public' | 'group_only' + title?: string | null + summary?: string | null + cover_image?: unknown + language?: string | null + keywords?: string[] + feed_card?: unknown + author_headline?: string | null + published_at?: string | null + block_count?: number + author?: ReportUser | null + category?: { id: string; name?: string | null } | null +} + // --- Exc 专长交易(只读监控)--- export interface ExcUser { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f90a111..6ae1a2d 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -13,12 +13,14 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as AuthedRouteImport } from './routes/_authed' import { Route as AuthedIndexRouteImport } from './routes/_authed/index' import { Route as AuthedSidRouteImport } from './routes/_authed/sid' +import { Route as AuthedRolesRouteImport } from './routes/_authed/roles' import { Route as AuthedRobotsRouteImport } from './routes/_authed/robots' import { Route as AuthedLocalesRouteImport } from './routes/_authed/locales' import { Route as AuthedEventReportsRouteImport } from './routes/_authed/event-reports' import { Route as AuthedDimzouTranslationsRouteImport } from './routes/_authed/dimzou-translations' import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices' import { Route as AuthedCommentReportsRouteImport } from './routes/_authed/comment-reports' +import { Route as AuthedAdminUsersRouteImport } from './routes/_authed/admin-users' import { Route as AuthedCategoriesIndexRouteImport } from './routes/_authed/categories/index' import { Route as AuthedStudioSubtopicsRouteImport } from './routes/_authed/studio/subtopics' import { Route as AuthedStudioStylePresetsRouteImport } from './routes/_authed/studio/style-presets' @@ -31,6 +33,8 @@ import { Route as AuthedExcProvidersRouteImport } from './routes/_authed/exc/pro import { Route as AuthedExcOrdersRouteImport } from './routes/_authed/exc/orders' import { Route as AuthedExcDispatchesRouteImport } from './routes/_authed/exc/dispatches' import { Route as AuthedExcDemandsRouteImport } from './routes/_authed/exc/demands' +import { Route as AuthedDimzouPublicationsRouteImport } from './routes/_authed/dimzou/publications' +import { Route as AuthedDimzouDocumentsRouteImport } from './routes/_authed/dimzou/documents' import { Route as AuthedCategoriesPendingRouteImport } from './routes/_authed/categories/pending' const LoginRoute = LoginRouteImport.update({ @@ -52,6 +56,11 @@ const AuthedSidRoute = AuthedSidRouteImport.update({ path: '/sid', getParentRoute: () => AuthedRoute, } as any) +const AuthedRolesRoute = AuthedRolesRouteImport.update({ + id: '/roles', + path: '/roles', + getParentRoute: () => AuthedRoute, +} as any) const AuthedRobotsRoute = AuthedRobotsRouteImport.update({ id: '/robots', path: '/robots', @@ -83,6 +92,11 @@ const AuthedCommentReportsRoute = AuthedCommentReportsRouteImport.update({ path: '/comment-reports', getParentRoute: () => AuthedRoute, } as any) +const AuthedAdminUsersRoute = AuthedAdminUsersRouteImport.update({ + id: '/admin-users', + path: '/admin-users', + getParentRoute: () => AuthedRoute, +} as any) const AuthedCategoriesIndexRoute = AuthedCategoriesIndexRouteImport.update({ id: '/categories/', path: '/categories/', @@ -146,6 +160,17 @@ const AuthedExcDemandsRoute = AuthedExcDemandsRouteImport.update({ path: '/exc/demands', getParentRoute: () => AuthedRoute, } as any) +const AuthedDimzouPublicationsRoute = + AuthedDimzouPublicationsRouteImport.update({ + id: '/dimzou/publications', + path: '/dimzou/publications', + getParentRoute: () => AuthedRoute, + } as any) +const AuthedDimzouDocumentsRoute = AuthedDimzouDocumentsRouteImport.update({ + id: '/dimzou/documents', + path: '/dimzou/documents', + getParentRoute: () => AuthedRoute, +} as any) const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({ id: '/categories/pending', path: '/categories/pending', @@ -155,14 +180,18 @@ const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof AuthedIndexRoute '/login': typeof LoginRoute + '/admin-users': typeof AuthedAdminUsersRoute '/comment-reports': typeof AuthedCommentReportsRoute '/devices': typeof AuthedDevicesRoute '/dimzou-translations': typeof AuthedDimzouTranslationsRoute '/event-reports': typeof AuthedEventReportsRoute '/locales': typeof AuthedLocalesRoute '/robots': typeof AuthedRobotsRoute + '/roles': typeof AuthedRolesRoute '/sid': typeof AuthedSidRoute '/categories/pending': typeof AuthedCategoriesPendingRoute + '/dimzou/documents': typeof AuthedDimzouDocumentsRoute + '/dimzou/publications': typeof AuthedDimzouPublicationsRoute '/exc/demands': typeof AuthedExcDemandsRoute '/exc/dispatches': typeof AuthedExcDispatchesRoute '/exc/orders': typeof AuthedExcOrdersRoute @@ -178,15 +207,19 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/login': typeof LoginRoute + '/admin-users': typeof AuthedAdminUsersRoute '/comment-reports': typeof AuthedCommentReportsRoute '/devices': typeof AuthedDevicesRoute '/dimzou-translations': typeof AuthedDimzouTranslationsRoute '/event-reports': typeof AuthedEventReportsRoute '/locales': typeof AuthedLocalesRoute '/robots': typeof AuthedRobotsRoute + '/roles': typeof AuthedRolesRoute '/sid': typeof AuthedSidRoute '/': typeof AuthedIndexRoute '/categories/pending': typeof AuthedCategoriesPendingRoute + '/dimzou/documents': typeof AuthedDimzouDocumentsRoute + '/dimzou/publications': typeof AuthedDimzouPublicationsRoute '/exc/demands': typeof AuthedExcDemandsRoute '/exc/dispatches': typeof AuthedExcDispatchesRoute '/exc/orders': typeof AuthedExcOrdersRoute @@ -204,15 +237,19 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_authed': typeof AuthedRouteWithChildren '/login': typeof LoginRoute + '/_authed/admin-users': typeof AuthedAdminUsersRoute '/_authed/comment-reports': typeof AuthedCommentReportsRoute '/_authed/devices': typeof AuthedDevicesRoute '/_authed/dimzou-translations': typeof AuthedDimzouTranslationsRoute '/_authed/event-reports': typeof AuthedEventReportsRoute '/_authed/locales': typeof AuthedLocalesRoute '/_authed/robots': typeof AuthedRobotsRoute + '/_authed/roles': typeof AuthedRolesRoute '/_authed/sid': typeof AuthedSidRoute '/_authed/': typeof AuthedIndexRoute '/_authed/categories/pending': typeof AuthedCategoriesPendingRoute + '/_authed/dimzou/documents': typeof AuthedDimzouDocumentsRoute + '/_authed/dimzou/publications': typeof AuthedDimzouPublicationsRoute '/_authed/exc/demands': typeof AuthedExcDemandsRoute '/_authed/exc/dispatches': typeof AuthedExcDispatchesRoute '/_authed/exc/orders': typeof AuthedExcOrdersRoute @@ -231,14 +268,18 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' + | '/admin-users' | '/comment-reports' | '/devices' | '/dimzou-translations' | '/event-reports' | '/locales' | '/robots' + | '/roles' | '/sid' | '/categories/pending' + | '/dimzou/documents' + | '/dimzou/publications' | '/exc/demands' | '/exc/dispatches' | '/exc/orders' @@ -254,15 +295,19 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/login' + | '/admin-users' | '/comment-reports' | '/devices' | '/dimzou-translations' | '/event-reports' | '/locales' | '/robots' + | '/roles' | '/sid' | '/' | '/categories/pending' + | '/dimzou/documents' + | '/dimzou/publications' | '/exc/demands' | '/exc/dispatches' | '/exc/orders' @@ -279,15 +324,19 @@ export interface FileRouteTypes { | '__root__' | '/_authed' | '/login' + | '/_authed/admin-users' | '/_authed/comment-reports' | '/_authed/devices' | '/_authed/dimzou-translations' | '/_authed/event-reports' | '/_authed/locales' | '/_authed/robots' + | '/_authed/roles' | '/_authed/sid' | '/_authed/' | '/_authed/categories/pending' + | '/_authed/dimzou/documents' + | '/_authed/dimzou/publications' | '/_authed/exc/demands' | '/_authed/exc/dispatches' | '/_authed/exc/orders' @@ -337,6 +386,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedSidRouteImport parentRoute: typeof AuthedRoute } + '/_authed/roles': { + id: '/_authed/roles' + path: '/roles' + fullPath: '/roles' + preLoaderRoute: typeof AuthedRolesRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/robots': { id: '/_authed/robots' path: '/robots' @@ -379,6 +435,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedCommentReportsRouteImport parentRoute: typeof AuthedRoute } + '/_authed/admin-users': { + id: '/_authed/admin-users' + path: '/admin-users' + fullPath: '/admin-users' + preLoaderRoute: typeof AuthedAdminUsersRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/categories/': { id: '/_authed/categories/' path: '/categories' @@ -463,6 +526,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedExcDemandsRouteImport parentRoute: typeof AuthedRoute } + '/_authed/dimzou/publications': { + id: '/_authed/dimzou/publications' + path: '/dimzou/publications' + fullPath: '/dimzou/publications' + preLoaderRoute: typeof AuthedDimzouPublicationsRouteImport + parentRoute: typeof AuthedRoute + } + '/_authed/dimzou/documents': { + id: '/_authed/dimzou/documents' + path: '/dimzou/documents' + fullPath: '/dimzou/documents' + preLoaderRoute: typeof AuthedDimzouDocumentsRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/categories/pending': { id: '/_authed/categories/pending' path: '/categories/pending' @@ -474,15 +551,19 @@ declare module '@tanstack/react-router' { } interface AuthedRouteChildren { + AuthedAdminUsersRoute: typeof AuthedAdminUsersRoute AuthedCommentReportsRoute: typeof AuthedCommentReportsRoute AuthedDevicesRoute: typeof AuthedDevicesRoute AuthedDimzouTranslationsRoute: typeof AuthedDimzouTranslationsRoute AuthedEventReportsRoute: typeof AuthedEventReportsRoute AuthedLocalesRoute: typeof AuthedLocalesRoute AuthedRobotsRoute: typeof AuthedRobotsRoute + AuthedRolesRoute: typeof AuthedRolesRoute AuthedSidRoute: typeof AuthedSidRoute AuthedIndexRoute: typeof AuthedIndexRoute AuthedCategoriesPendingRoute: typeof AuthedCategoriesPendingRoute + AuthedDimzouDocumentsRoute: typeof AuthedDimzouDocumentsRoute + AuthedDimzouPublicationsRoute: typeof AuthedDimzouPublicationsRoute AuthedExcDemandsRoute: typeof AuthedExcDemandsRoute AuthedExcDispatchesRoute: typeof AuthedExcDispatchesRoute AuthedExcOrdersRoute: typeof AuthedExcOrdersRoute @@ -498,15 +579,19 @@ interface AuthedRouteChildren { } const AuthedRouteChildren: AuthedRouteChildren = { + AuthedAdminUsersRoute: AuthedAdminUsersRoute, AuthedCommentReportsRoute: AuthedCommentReportsRoute, AuthedDevicesRoute: AuthedDevicesRoute, AuthedDimzouTranslationsRoute: AuthedDimzouTranslationsRoute, AuthedEventReportsRoute: AuthedEventReportsRoute, AuthedLocalesRoute: AuthedLocalesRoute, AuthedRobotsRoute: AuthedRobotsRoute, + AuthedRolesRoute: AuthedRolesRoute, AuthedSidRoute: AuthedSidRoute, AuthedIndexRoute: AuthedIndexRoute, AuthedCategoriesPendingRoute: AuthedCategoriesPendingRoute, + AuthedDimzouDocumentsRoute: AuthedDimzouDocumentsRoute, + AuthedDimzouPublicationsRoute: AuthedDimzouPublicationsRoute, AuthedExcDemandsRoute: AuthedExcDemandsRoute, AuthedExcDispatchesRoute: AuthedExcDispatchesRoute, AuthedExcOrdersRoute: AuthedExcOrdersRoute, diff --git a/src/routes/_authed/admin-users.tsx b/src/routes/_authed/admin-users.tsx new file mode 100644 index 0000000..08c71a0 --- /dev/null +++ b/src/routes/_authed/admin-users.tsx @@ -0,0 +1,189 @@ +import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import { toast } from 'sonner' +import type { RoleUser } from '@/api/types' +import { fetchRoles, searchRoleUsers, syncUserRoles } from '@/api/modules/roles' +import { useAuthStore } from '@/auth/store' +import { handleApiError } from '@/lib/errors' +import { DataTable } from '@/components/data-table/data-table' +import { PageHeader } from '@/components/page-header' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' + +export const Route = createFileRoute('/_authed/admin-users')({ + beforeLoad: () => requirePermission(PERM.ROLE_ASSIGN), + component: AdminUsersPage, +}) + +function AdminUsersPage() { + const myUid = useAuthStore((s) => s.user?.uid) + // search applies on Enter to avoid a request per keystroke + const [queryInput, setQueryInput] = useState('') + const [query, setQuery] = useState('') + const [editing, setEditing] = useState(null) + + const results = useQuery({ + queryKey: ['role-users', query], + queryFn: () => searchRoleUsers(query), + enabled: query.length > 0, + }) + + const columns: ColumnDef[] = [ + { + header: '用户', + cell: ({ row }) => ( +
+
{row.original.display_name ?? row.original.username ?? '—'}
+
+ {row.original.email} + {row.original.uid} +
+
+ ), + }, + { + header: '角色', + cell: ({ row }) => + row.original.roles.length > 0 ? ( +
+ {row.original.roles.map((role) => ( + + {role} + + ))} +
+ ) : ( + '—' + ), + }, + { + header: '操作', + cell: ({ row }) => + row.original.uid === myUid ? ( + 不能修改自己 + ) : ( + + ), + }, + ] + + return ( +
+ setQueryInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') setQuery(queryInput.trim()) + }} + placeholder="邮箱 / 用户名 / UID,回车搜索" + className="h-8 w-64" + /> + } + /> + 0 && results.isPending} + emptyText={query ? '没有匹配的用户' : '输入关键字并回车搜索'} + /> + setEditing(null)} + onSaved={() => void results.refetch()} + /> +
+ ) +} + +function AssignRolesDialog({ + user, + onClose, + onSaved, +}: { + user: RoleUser | null + onClose: () => void + onSaved: () => void +}) { + const roles = useQuery({ queryKey: ['roles'], queryFn: fetchRoles, enabled: user !== null }) + const [selected, setSelected] = useState>(new Set()) + const [busy, setBusy] = useState(false) + + useEffect(() => { + setSelected(new Set(user?.roles ?? [])) + }, [user]) + + const toggle = (name: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev) + if (checked) next.add(name) + else next.delete(name) + return next + }) + } + + const save = async () => { + if (!user) return + setBusy(true) + try { + await syncUserRoles(user.uid, [...selected]) + toast.success(`已更新 ${user.display_name ?? user.email} 的角色`) + onSaved() + onClose() + } catch (error) { + handleApiError(error) + } finally { + setBusy(false) + } + } + + return ( + !open && onClose()}> + + + 分配角色:{user?.display_name ?? user?.email} + +
+ {(roles.data?.data ?? []).map((role) => ( + + ))} +
+ + + + +
+
+ ) +} diff --git a/src/routes/_authed/roles.tsx b/src/routes/_authed/roles.tsx new file mode 100644 index 0000000..498974e --- /dev/null +++ b/src/routes/_authed/roles.tsx @@ -0,0 +1,374 @@ +import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import { ChevronRight, Plus } from 'lucide-react' +import { toast } from 'sonner' +import type { AdminRole } from '@/api/types' +import { + createRole, + deleteRole, + fetchPermissionCatalog, + fetchRoles, + syncRolePermissions, +} from '@/api/modules/roles' +import { useCan } from '@/auth/store' +import { handleApiError } from '@/lib/errors' +import { cn } from '@/lib/utils' +import { DataTable } from '@/components/data-table/data-table' +import { PageHeader } from '@/components/page-header' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' + +export const Route = createFileRoute('/_authed/roles')({ + beforeLoad: () => requirePermission(PERM.ROLE_READ), + component: RolesPage, +}) + +function RolesPage() { + const canCreate = useCan(PERM.ROLE_CREATE) + const canUpdate = useCan(PERM.ROLE_UPDATE) + const canDelete = useCan(PERM.ROLE_DELETE) + + const roles = useQuery({ queryKey: ['roles'], queryFn: fetchRoles }) + const [createOpen, setCreateOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const refetch = () => void roles.refetch() + + const remove = async (role: AdminRole) => { + try { + await deleteRole(role.name) + toast.success(`已删除角色 ${role.name}`) + refetch() + } catch (error) { + handleApiError(error) + } + } + + const columns: ColumnDef[] = [ + { + header: '角色', + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.builtin && 内建} +
+ ), + }, + { + header: '权限数', + cell: ({ row }) => + row.original.name === 'super-admin' ? '全部' : row.original.permissions.length, + }, + { + header: '用户数', + cell: ({ row }) => row.original.users_count, + }, + { + header: '操作', + cell: ({ row }) => ( +
+ {canUpdate && row.original.name !== 'super-admin' && ( + + )} + {canDelete && !row.original.builtin && row.original.users_count === 0 && ( + + 删除 + + } + title={`删除角色 ${row.original.name}?`} + confirmLabel="删除" + destructive + onConfirm={() => remove(row.original)} + /> + )} +
+ ), + }, + ] + + return ( +
+ setCreateOpen(true)}> + + 新建角色 + + ) + } + /> + + + setEditing(null)} onSaved={refetch} /> +
+ ) +} + +function CreateRoleDialog({ + open, + onOpenChange, + onCreated, +}: { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: () => void +}) { + const [name, setName] = useState('') + const [busy, setBusy] = useState(false) + + const submit = async () => { + setBusy(true) + try { + await createRole(name.trim()) + toast.success(`已创建角色 ${name.trim()}`) + setName('') + onOpenChange(false) + onCreated() + } catch (error) { + handleApiError(error) + } finally { + setBusy(false) + } + } + + return ( + + + + 新建角色 + +
+ + setName(e.target.value)} + placeholder="如 reviewer" + /> +
+ + + + +
+
+ ) +} + +function PermissionMatrixSheet({ + role, + onClose, + onSaved, +}: { + role: AdminRole | null + onClose: () => void + onSaved: () => void +}) { + const catalog = useQuery({ + queryKey: ['permission-catalog'], + queryFn: fetchPermissionCatalog, + enabled: role !== null, + }) + const [selected, setSelected] = useState>(new Set()) + const [openModules, setOpenModules] = useState>(new Set()) + const [filter, setFilter] = useState('') + const [busy, setBusy] = useState(false) + + const modules = useMemo(() => Object.values(catalog.data?.data ?? {}), [catalog.data]) + + useEffect(() => { + const permissions = role?.permissions ?? [] + setSelected(new Set(permissions)) + setFilter('') + // start with the modules the role already touches expanded + setOpenModules( + new Set( + modules + .filter((mod) => + Object.values(mod.groups).some((perms) => + Object.keys(perms).some((key) => permissions.includes(key)), + ), + ) + .map((mod) => mod.module), + ), + ) + }, [role, modules]) + + // filter matches key or description, case-insensitive; while filtering, + // non-matching entries are dropped and every remaining module is expanded + const visibleModules = useMemo(() => { + const q = filter.trim().toLowerCase() + if (!q) return modules + return modules + .map((mod) => { + const groups: Record> = {} + for (const [group, perms] of Object.entries(mod.groups)) { + const matched = Object.entries(perms).filter( + ([key, description]) => + key.toLowerCase().includes(q) || description.toLowerCase().includes(q), + ) + if (matched.length > 0) groups[group] = Object.fromEntries(matched) + } + return { ...mod, groups } + }) + .filter((mod) => Object.keys(mod.groups).length > 0) + }, [modules, filter]) + + const filtering = filter.trim().length > 0 + + const toggle = (key: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev) + if (checked) next.add(key) + else next.delete(key) + return next + }) + } + + const toggleModule = (module: string) => { + setOpenModules((prev) => { + const next = new Set(prev) + if (next.has(module)) next.delete(module) + else next.add(module) + return next + }) + } + + const selectedInModule = (mod: (typeof modules)[number]) => + Object.values(mod.groups).reduce( + (count, perms) => count + Object.keys(perms).filter((key) => selected.has(key)).length, + 0, + ) + + const totalInModule = (mod: (typeof modules)[number]) => + Object.values(mod.groups).reduce((count, perms) => count + Object.keys(perms).length, 0) + + const save = async () => { + if (!role) return + setBusy(true) + try { + await syncRolePermissions(role.name, [...selected]) + toast.success(`已更新 ${role.name} 的权限(${selected.size} 项)`) + onSaved() + onClose() + } catch (error) { + handleApiError(error) + } finally { + setBusy(false) + } + } + + return ( + !open && onClose()}> + + + 编辑权限:{role?.name} + 已选 {selected.size} 项 + +
+ setFilter(e.target.value)} + placeholder="筛选权限键或描述…" + className="h-8" + /> +
+
+ {visibleModules.length === 0 && ( +

没有匹配的权限

+ )} + {visibleModules.map((mod) => { + const open = filtering || openModules.has(mod.module) + return ( +
+ + {open && ( +
+ {Object.entries(mod.groups).map(([group, perms]) => ( +
+
{group}
+
+ {Object.entries(perms).map(([key, description]) => ( + + ))} +
+
+ ))} +
+ )} +
+ ) + })} +
+ + + + +
+
+ ) +}