diff --git a/src/features/roles/components/RoleFormDrawer.jsx b/src/features/roles/components/RoleFormDrawer.jsx
index 91d920e..1d9e6fa 100644
--- a/src/features/roles/components/RoleFormDrawer.jsx
+++ b/src/features/roles/components/RoleFormDrawer.jsx
@@ -1,7 +1,7 @@
-import Checkbox from "@mui/material/Checkbox";
-import Drawer from "@mui/material/Drawer";
import TextField from "@mui/material/TextField";
-import { Button } from "@/shared/ui/Button.jsx";
+import { useEffect, useState } from "react";
+import { FormDrawerShell } from "@/shared/ui/FormDrawerShell.jsx";
+import { RolePermissionEditor } from "@/features/roles/components/RolePermissionEditor.jsx";
export function RoleFormDrawer({
abilities,
@@ -15,11 +15,34 @@ export function RoleFormDrawer({
permissionGroups,
setForm
}) {
+ const [activeSection, setActiveSection] = useState("profile");
+
+ useEffect(() => {
+ // 新开角色表单时先展示身份字段,避免沿用上一角色的权限分类上下文。
+ if (open) {
+ setActiveSection("profile");
+ }
+ }, [editingRole, open]);
+
return (
-
-
-
- );
-}
-
-function PermissionMenuGroup({ abilities, group, onTogglePermission, onTogglePermissionGroup, permissionIds }) {
- const groupPermissionIds = group.items.map((permission) => permission.id);
- const selectedCount = groupPermissionIds.filter((permissionId) => permissionIds.includes(permissionId)).length;
- const allSelected = selectedCount === groupPermissionIds.length;
- const partiallySelected = selectedCount > 0 && !allSelected;
-
- return (
-
-
-
{group.title}
-
- {selectedCount}/{groupPermissionIds.length}
-
-
-
-
- {group.items.map((permission) => (
-
- ))}
-
-
+ ) : null}
+
+ {activeSection === "permissions" && abilities.canLoadPermissions ? (
+
+ ) : null}
+
);
}
diff --git a/src/features/roles/components/RoleFormDrawer.test.jsx b/src/features/roles/components/RoleFormDrawer.test.jsx
new file mode 100644
index 0000000..2dcfa16
--- /dev/null
+++ b/src/features/roles/components/RoleFormDrawer.test.jsx
@@ -0,0 +1,40 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { expect, test, vi } from "vitest";
+import { RoleFormDrawer } from "./RoleFormDrawer.jsx";
+
+const abilities = { canCreate: true, canGrant: true, canLoadPermissions: true, canUpdate: true };
+
+test("shows role permissions as a searchable category workspace", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "权限配置1" }));
+
+ expect(screen.getByRole("navigation", { name: "权限分类" })).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("搜索权限名称或编码")).toBeInTheDocument();
+ expect(screen.getByText("user:view")).toBeInTheDocument();
+ expect(screen.getByText("已选 1 / 2 项")).toBeInTheDocument();
+});
diff --git a/src/features/roles/components/RolePermissionEditor.jsx b/src/features/roles/components/RolePermissionEditor.jsx
new file mode 100644
index 0000000..2288fbd
--- /dev/null
+++ b/src/features/roles/components/RolePermissionEditor.jsx
@@ -0,0 +1,147 @@
+import SearchOutlined from "@mui/icons-material/SearchOutlined";
+import Checkbox from "@mui/material/Checkbox";
+import InputAdornment from "@mui/material/InputAdornment";
+import TextField from "@mui/material/TextField";
+import { useEffect, useMemo, useState } from "react";
+
+export function RolePermissionEditor({
+ abilities,
+ onTogglePermission,
+ onTogglePermissionGroup,
+ permissionGroups,
+ permissionIds,
+}) {
+ const [activeGroupKey, setActiveGroupKey] = useState("");
+ const [query, setQuery] = useState("");
+ const [selectedOnly, setSelectedOnly] = useState(false);
+
+ const visibleGroups = useMemo(() => {
+ const keyword = query.trim().toLowerCase();
+ // 全局搜索先收窄分类再展示命中项,权限 ID 集合不随筛选变化,避免隐藏项被意外清空。
+ return permissionGroups
+ .map((group) => ({
+ ...group,
+ visibleItems: group.items.filter((permission) => {
+ if (selectedOnly && !permissionIds.includes(permission.id)) {
+ return false;
+ }
+ return (
+ !keyword ||
+ [permission.name, permission.code].some((value) =>
+ String(value || "")
+ .toLowerCase()
+ .includes(keyword),
+ )
+ );
+ }),
+ }))
+ .filter((group) => group.visibleItems.length > 0 || (!query && !selectedOnly));
+ }, [permissionGroups, permissionIds, query, selectedOnly]);
+
+ useEffect(() => {
+ // 当前分类被搜索条件过滤掉时定位首个命中分类,让右侧始终对应左侧可见导航。
+ if (!visibleGroups.some((group) => group.key === activeGroupKey)) {
+ setActiveGroupKey(visibleGroups[0]?.key || "");
+ }
+ }, [activeGroupKey, visibleGroups]);
+
+ const activeGroup = visibleGroups.find((group) => group.key === activeGroupKey) || visibleGroups[0];
+
+ return (
+
+
+
+
+
+
+ {activeGroup?.title || "权限范围"}
+
+ 已选 {permissionIds.length} / {permissionGroups.reduce((total, group) => total + group.items.length, 0)} 项
+
+
+
setQuery(event.target.value)}
+ placeholder="搜索权限名称或编码"
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ }}
+ value={query}
+ />
+
+
+
+ {activeGroup ? (
+ <>
+
+ {activeGroup.visibleItems.length} 项
+
+
+
+ {activeGroup.visibleItems.map((permission) => (
+
+ ))}
+
+ >
+ ) : (
+ 当前无匹配权限
+ )}
+
+
+ );
+}
+
+function countSelected(items, permissionIds) {
+ return items.filter((permission) => permissionIds.includes(permission.id)).length;
+}
diff --git a/src/features/users/components/UserFinanceScopeEditor.jsx b/src/features/users/components/UserFinanceScopeEditor.jsx
new file mode 100644
index 0000000..ad7a5aa
--- /dev/null
+++ b/src/features/users/components/UserFinanceScopeEditor.jsx
@@ -0,0 +1,147 @@
+import SearchOutlined from "@mui/icons-material/SearchOutlined";
+import Checkbox from "@mui/material/Checkbox";
+import InputAdornment from "@mui/material/InputAdornment";
+import TextField from "@mui/material/TextField";
+import { useEffect, useMemo, useState } from "react";
+import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
+
+export function UserFinanceScopeEditor({ catalog, checkedScopes, onChecked }) {
+ const apps = useMemo(() => catalog?.apps || [], [catalog?.apps]);
+ const [activeAppCode, setActiveAppCode] = useState("");
+ const [query, setQuery] = useState("");
+ const [selectedOnly, setSelectedOnly] = useState(false);
+
+ useEffect(() => {
+ // 目录刷新后主动回退到第一个有效应用,防止权限变化导致右侧仍引用已移除的应用。
+ if (!apps.some((app) => app.appCode === activeAppCode)) {
+ setActiveAppCode(apps[0]?.appCode || "");
+ }
+ }, [activeAppCode, apps]);
+
+ const activeApp = apps.find((app) => app.appCode === activeAppCode) || apps[0];
+ const activeRegions = useMemo(() => {
+ const keyword = query.trim().toLowerCase();
+ // 搜索与仅看已选只影响展示集合,原始勾选状态始终保留,切换应用或筛选不会丢失授权。
+ return (catalog?.regions || []).filter((region) => {
+ if (region.appCode !== activeApp?.appCode) {
+ return false;
+ }
+ const checked = hasScope(checkedScopes, region.appCode, region.regionId);
+ if (selectedOnly && !checked) {
+ return false;
+ }
+ return !keyword || [region.name, region.regionCode].some((value) => String(value || "").toLowerCase().includes(keyword));
+ });
+ }, [activeApp?.appCode, catalog?.regions, checkedScopes, query, selectedOnly]);
+
+ if (catalog?.loading) {
+ return 加载中
;
+ }
+ if (catalog?.error) {
+ return {catalog.error}
;
+ }
+ if (!apps.length) {
+ return 当前无数据
;
+ }
+
+ const allRegionsChecked = hasScope(checkedScopes, activeApp.appCode, 0);
+
+ return (
+
+
+
+
+
+
+ {activeApp.appName || activeApp.appCode}
+ 已选 {countAppScopes(checkedScopes, activeApp.appCode)} 项
+
+
setQuery(event.target.value)}
+ placeholder="搜索区域名称或编码"
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ }}
+ value={query}
+ />
+
+
+
+
+ {(!selectedOnly || allRegionsChecked) && !query ? (
+ onChecked(activeApp.appCode, 0, checked)}
+ />
+ ) : null}
+ {activeRegions.map((region) => (
+ onChecked(activeApp.appCode, region.regionId, checked)}
+ />
+ ))}
+
+ {!activeRegions.length && (query || selectedOnly) && !(selectedOnly && allRegionsChecked) ? (
+ 当前无匹配区域
+ ) : null}
+
+
+ );
+}
+
+function FinanceScopeOption({ checked, code, label, onChange }) {
+ return (
+
+ );
+}
+
+function hasScope(scopes, appCode, regionId) {
+ return (scopes || []).some(
+ (scope) => scope.appCode === appCode && Number(scope.regionId) === Number(regionId),
+ );
+}
+
+function countAppScopes(scopes, appCode) {
+ return (scopes || []).filter((scope) => scope.appCode === appCode).length;
+}
diff --git a/src/features/users/components/UserFormDrawer.jsx b/src/features/users/components/UserFormDrawer.jsx
index ea19dda..94e715c 100644
--- a/src/features/users/components/UserFormDrawer.jsx
+++ b/src/features/users/components/UserFormDrawer.jsx
@@ -1,9 +1,10 @@
-import Drawer from "@mui/material/Drawer";
+import Checkbox from "@mui/material/Checkbox";
import MenuItem from "@mui/material/MenuItem";
-import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
-import { Button } from "@/shared/ui/Button.jsx";
-import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
+import { useEffect, useState } from "react";
+import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
+import { FormDrawerShell } from "@/shared/ui/FormDrawerShell.jsx";
+import { UserFinanceScopeEditor } from "@/features/users/components/UserFinanceScopeEditor.jsx";
export function UserFormDrawer({
editingUser,
@@ -18,71 +19,91 @@ export function UserFormDrawer({
setForm,
teams = []
}) {
+ const [activeSection, setActiveSection] = useState("profile");
+
+ useEffect(() => {
+ // 每次切换编辑对象都回到账号资料,避免复用抽屉时停留在上一位用户的授权页造成误操作。
+ if (open) {
+ setActiveSection("profile");
+ }
+ }, [editingUser, open]);
+
+ const sections = [
+ { key: "profile", label: "账号资料" },
+ { count: form.financeScopes?.length || 0, key: "finance", label: "财务范围" },
+ { count: form.roleIds?.length || 0, key: "access", label: "角色与安全" }
+ ];
+
return (
-
-