权限展示

This commit is contained in:
zhx 2026-07-14 14:34:46 +08:00
parent 54b584c096
commit 0299d57aa4
8 changed files with 1014 additions and 177 deletions

View File

@ -1,7 +1,7 @@
import Checkbox from "@mui/material/Checkbox";
import Drawer from "@mui/material/Drawer";
import TextField from "@mui/material/TextField"; 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({ export function RoleFormDrawer({
abilities, abilities,
@ -15,11 +15,34 @@ export function RoleFormDrawer({
permissionGroups, permissionGroups,
setForm setForm
}) { }) {
const [activeSection, setActiveSection] = useState("profile");
useEffect(() => {
// 沿
if (open) {
setActiveSection("profile");
}
}, [editingRole, open]);
return ( return (
<Drawer anchor="right" open={open} onClose={onClose}> <FormDrawerShell
<form className="form-drawer form-drawer--wide role-permission-drawer" onSubmit={onSubmit}> activeSection={activeSection}
<h2>{editingRole ? "编辑角色" : "新增角色"}</h2> className="role-form-workspace"
<section className="form-drawer__section"> onClose={onClose}
onSectionChange={setActiveSection}
onSubmit={onSubmit}
open={open}
sections={[
{ key: "profile", label: "基础信息" },
...(abilities.canLoadPermissions
? [{ count: form.permissionIds.length, key: "permissions", label: "权限配置" }]
: [])
]}
summary={editingRole ? `角色编码:${form.code}` : "新增后台角色"}
title={editingRole ? "编辑角色" : "新增角色"}
>
{activeSection === "profile" ? (
<section className="form-drawer__section form-drawer__panel">
<div className="form-drawer__section-title">基础信息</div> <div className="form-drawer__section-title">基础信息</div>
<div className="form-drawer__grid"> <div className="form-drawer__grid">
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /> <TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="角色名称" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
@ -27,69 +50,17 @@ export function RoleFormDrawer({
<TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /> <TextField disabled={editingRole ? !abilities.canUpdate : !abilities.canCreate} label="说明" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
</div> </div>
</section> </section>
{abilities.canLoadPermissions ? ( ) : null}
<section className="form-drawer__section role-permission-scope">
<div className="form-drawer__section-title">权限范围</div> {activeSection === "permissions" && abilities.canLoadPermissions ? (
<div className="permission-menu-list role-permission-menu-list"> <RolePermissionEditor
{permissionGroups.map((group) => ( abilities={abilities}
<PermissionMenuGroup onTogglePermission={onTogglePermission}
abilities={abilities} onTogglePermissionGroup={onTogglePermissionGroup}
group={group} permissionGroups={permissionGroups}
key={group.key} permissionIds={form.permissionIds}
permissionIds={form.permissionIds} />
onTogglePermission={onTogglePermission} ) : null}
onTogglePermissionGroup={onTogglePermissionGroup} </FormDrawerShell>
/>
))}
</div>
</section>
) : null}
<div className="form-drawer__actions">
<Button onClick={onClose}>取消</Button>
<Button type="submit" variant="primary">提交</Button>
</div>
</form>
</Drawer>
);
}
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 (
<section className="permission-menu-section">
<div className="permission-menu-header">
<span className="permission-menu-title">{group.title}</span>
<div className="permission-menu-actions">
<span className="permission-menu-count">{selectedCount}/{groupPermissionIds.length}</span>
<label className="permission-menu-toggle">
<Checkbox
checked={allSelected}
disabled={!abilities.canGrant}
indeterminate={partiallySelected}
onChange={(event) => onTogglePermissionGroup(groupPermissionIds, event.target.checked)}
size="small"
/>
<span>全选</span>
</label>
</div>
</div>
<div className="permission-grid permission-grid--compact">
{group.items.map((permission) => (
<label className="permission-check" key={permission.id}>
<Checkbox
checked={permissionIds.includes(permission.id)}
disabled={!abilities.canGrant}
onChange={(event) => onTogglePermission(permission.id, event.target.checked)}
size="small"
/>
<span>{permission.name}</span>
</label>
))}
</div>
</section>
); );
} }

View File

@ -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(
<RoleFormDrawer
abilities={abilities}
editingRole={{ id: 1 }}
form={{ code: "ops", description: "", name: "运营", permissionIds: [1] }}
onClose={vi.fn()}
onSubmit={vi.fn()}
onTogglePermission={vi.fn()}
onTogglePermissionGroup={vi.fn()}
open
permissionGroups={[
{
items: [
{ code: "user:view", id: 1, name: "用户查看" },
{ code: "user:update", id: 2, name: "用户更新" },
],
key: "users",
title: "后台用户",
},
]}
setForm={vi.fn()}
/>,
);
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();
});

View File

@ -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 (
<div className="form-editor-split form-editor-split--permissions">
<nav aria-label="权限分类" className="form-editor-rail">
<div className="form-editor-rail__label">权限分类</div>
{visibleGroups.map((group) => {
const selectedCount = countSelected(group.items, permissionIds);
return (
<button
aria-current={group.key === activeGroup?.key ? "page" : undefined}
className={group.key === activeGroup?.key ? "is-active" : ""}
key={group.key}
onClick={() => setActiveGroupKey(group.key)}
type="button"
>
<span>{group.title}</span>
<span className="form-editor-rail__count">
{selectedCount}/{group.items.length}
</span>
</button>
);
})}
</nav>
<section className="form-editor-panel" aria-label={activeGroup ? `${activeGroup.title}权限` : "权限"}>
<div className="form-editor-toolbar form-editor-toolbar--permissions">
<div>
<strong>{activeGroup?.title || "权限范围"}</strong>
<span>
已选 {permissionIds.length} / {permissionGroups.reduce((total, group) => total + group.items.length, 0)}
</span>
</div>
<TextField
aria-label="搜索权限"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索权限名称或编码"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchOutlined fontSize="small" />
</InputAdornment>
),
},
}}
value={query}
/>
<label className="form-editor-filter">
<Checkbox checked={selectedOnly} onChange={(event) => setSelectedOnly(event.target.checked)} size="small" />
<span>仅看已选</span>
</label>
</div>
{activeGroup ? (
<>
<div className="form-editor-group-actions">
<span>{activeGroup.visibleItems.length} </span>
<label>
<Checkbox
checked={countSelected(activeGroup.items, permissionIds) === activeGroup.items.length}
disabled={!abilities.canGrant}
indeterminate={
countSelected(activeGroup.items, permissionIds) > 0 &&
countSelected(activeGroup.items, permissionIds) < activeGroup.items.length
}
onChange={(event) => onTogglePermissionGroup(activeGroup.items.map((item) => item.id), event.target.checked)}
size="small"
/>
<span>全选本组</span>
</label>
</div>
<div className="form-option-grid form-option-grid--permissions">
{activeGroup.visibleItems.map((permission) => (
<label className="form-option" key={permission.id} title={permission.code}>
<Checkbox
checked={permissionIds.includes(permission.id)}
disabled={!abilities.canGrant}
onChange={(event) => onTogglePermission(permission.id, event.target.checked)}
size="small"
/>
<span>
<strong>{permission.name}</strong>
<small>{permission.code}</small>
</span>
</label>
))}
</div>
</>
) : (
<div className="form-editor-state">当前无匹配权限</div>
)}
</section>
</div>
);
}
function countSelected(items, permissionIds) {
return items.filter((permission) => permissionIds.includes(permission.id)).length;
}

View File

@ -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 <div className="form-editor-state">加载中</div>;
}
if (catalog?.error) {
return <div className="form-editor-state form-editor-state--error">{catalog.error}</div>;
}
if (!apps.length) {
return <div className="form-editor-state">当前无数据</div>;
}
const allRegionsChecked = hasScope(checkedScopes, activeApp.appCode, 0);
return (
<div className="form-editor-split form-editor-split--finance">
<nav aria-label="应用范围" className="form-editor-rail">
<div className="form-editor-rail__label">应用</div>
{apps.map((app) => {
const selectedCount = countAppScopes(checkedScopes, app.appCode);
return (
<button
aria-current={app.appCode === activeApp.appCode ? "page" : undefined}
className={app.appCode === activeApp.appCode ? "is-active" : ""}
key={app.appCode}
onClick={() => {
setActiveAppCode(app.appCode);
setQuery("");
}}
type="button"
>
<AppIdentity app={app} size="small" />
<span className="form-editor-rail__count">{selectedCount}</span>
</button>
);
})}
</nav>
<section className="form-editor-panel" aria-label={`${activeApp.appName || activeApp.appCode}财务范围`}>
<div className="form-editor-toolbar">
<div>
<strong>{activeApp.appName || activeApp.appCode}</strong>
<span>已选 {countAppScopes(checkedScopes, activeApp.appCode)} </span>
</div>
<TextField
aria-label="搜索区域"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索区域名称或编码"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchOutlined fontSize="small" />
</InputAdornment>
),
},
}}
value={query}
/>
<label className="form-editor-filter">
<Checkbox checked={selectedOnly} onChange={(event) => setSelectedOnly(event.target.checked)} size="small" />
<span>仅看已选</span>
</label>
</div>
<div className="form-option-grid form-option-grid--finance">
{(!selectedOnly || allRegionsChecked) && !query ? (
<FinanceScopeOption
checked={allRegionsChecked}
code="ALL"
label="全部区域"
onChange={(checked) => onChecked(activeApp.appCode, 0, checked)}
/>
) : null}
{activeRegions.map((region) => (
<FinanceScopeOption
checked={hasScope(checkedScopes, activeApp.appCode, region.regionId)}
code={region.regionCode}
key={region.regionId}
label={region.name}
onChange={(checked) => onChecked(activeApp.appCode, region.regionId, checked)}
/>
))}
</div>
{!activeRegions.length && (query || selectedOnly) && !(selectedOnly && allRegionsChecked) ? (
<div className="form-editor-state">当前无匹配区域</div>
) : null}
</section>
</div>
);
}
function FinanceScopeOption({ checked, code, label, onChange }) {
return (
<label className="form-option">
<Checkbox checked={checked} onChange={(event) => onChange(event.target.checked)} size="small" />
<span>
<strong>{label}</strong>
{code ? <small>{code}</small> : null}
</span>
</label>
);
}
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;
}

View File

@ -1,9 +1,10 @@
import Drawer from "@mui/material/Drawer"; import Checkbox from "@mui/material/Checkbox";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx"; import { useEffect, useState } from "react";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx"; 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({ export function UserFormDrawer({
editingUser, editingUser,
@ -18,71 +19,91 @@ export function UserFormDrawer({
setForm, setForm,
teams = [] 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 ( return (
<Drawer anchor="right" open={open} onClose={onClose}> <FormDrawerShell
<form className="form-drawer form-drawer--sticky-actions" onSubmit={onSubmit}> activeSection={activeSection}
<h2>{editingUser ? "编辑用户" : "新增用户"}</h2> className="user-form-workspace"
<div className="form-drawer__content"> onClose={onClose}
onSectionChange={setActiveSection}
onSubmit={onSubmit}
open={open}
sections={sections}
summary={editingUser ? `账号:${form.username}` : "新增后台用户"}
title={editingUser ? "编辑用户" : "新增用户"}
>
{activeSection === "profile" ? (
<section className="form-drawer__section form-drawer__panel">
<div className="form-drawer__section-title">基础信息</div>
<div className="form-drawer__grid">
<TextField
disabled={Boolean(editingUser)}
label="账号"
required
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
/>
<TextField label="姓名" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
<TextField
label="团队"
select
value={form.teamId ?? ""}
onChange={(event) => {
const teamId = event.target.value;
const team = teams.find((item) => String(item.id) === String(teamId));
setForm({ ...form, team: team?.name || "", teamId });
}}
>
<MenuItem value="">未选择</MenuItem>
{teams.map((team) => (
<MenuItem key={team.id} value={team.id}>
{team.parentName ? `${team.parentName} / ${team.name}` : team.name}
</MenuItem>
))}
</TextField>
{!editingUser ? (
<TextField label="初始密码" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
) : null}
</div>
</section>
) : null}
{activeSection === "finance" ? (
<UserFinanceScopeEditor
catalog={financeScopeCatalog}
checkedScopes={form.financeScopes || []}
onChecked={onFinanceScopeChecked}
/>
) : null}
{activeSection === "access" ? (
<div className="form-drawer__panel form-drawer__access-panel">
<section className="form-drawer__section"> <section className="form-drawer__section">
<div className="form-drawer__section-title">基础信息</div> <div className="form-drawer__section-heading">
<div className="form-drawer__grid"> <div className="form-drawer__section-title">角色</div>
<TextField <span>已选 {form.roleIds.length} </span>
disabled={Boolean(editingUser)}
label="账号"
required
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
/>
<TextField label="姓名" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
<TextField
label="团队"
select
value={form.teamId ?? ""}
onChange={(event) => {
const teamId = event.target.value;
const team = teams.find((item) => String(item.id) === String(teamId));
setForm({ ...form, team: team?.name || "", teamId });
}}
>
<MenuItem value="">未选择</MenuItem>
{teams.map((team) => (
<MenuItem key={team.id} value={team.id}>
{team.parentName ? `${team.parentName} / ${team.name}` : team.name}
</MenuItem>
))}
</TextField>
{!editingUser ? (
<TextField label="初始密码" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
) : null}
</div> </div>
</section> <div className="form-option-grid form-option-grid--roles">
<section className="form-drawer__section">
<div className="form-drawer__section-title">财务范围</div>
<div className="permission-grid permission-grid--finance">
{financeScopeCatalog?.loading ? <span className="permission-check">加载中</span> : null}
{financeScopeCatalog?.error ? <span className="permission-check">{financeScopeCatalog.error}</span> : null}
{!financeScopeCatalog?.loading && !financeScopeCatalog?.error
? (financeScopeCatalog?.apps || []).map((app) => (
<FinanceScopeGroup
app={app}
checkedScopes={form.financeScopes || []}
key={app.appCode}
onChecked={onFinanceScopeChecked}
regions={(financeScopeCatalog?.regions || []).filter((region) => region.appCode === app.appCode)}
/>
))
: null}
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">角色</div>
<div className="permission-grid">
{roles.map((role) => ( {roles.map((role) => (
<label className="permission-check" key={role.id}> <label className="form-option form-option--single-line" key={role.id}>
<input <Checkbox
checked={form.roleIds.includes(role.id)} checked={form.roleIds.includes(role.id)}
onChange={(event) => onRoleChecked(role.id, event.target.checked)} onChange={(event) => onRoleChecked(role.id, event.target.checked)}
type="checkbox" size="small"
/> />
<span>{role.name}</span> <span>{role.name}</span>
</label> </label>
@ -91,46 +112,22 @@ export function UserFormDrawer({
</section> </section>
<section className="form-drawer__section"> <section className="form-drawer__section">
<div className="form-drawer__section-title">安全设置</div> <div className="form-drawer__section-title">安全设置</div>
<AdminSwitch <div className="form-setting-row">
checked={form.mfaEnabled} <div>
checkedLabel="开启" <strong>MFA 状态</strong>
label="MFA 状态" <span>登录时进行多因素认证</span>
uncheckedLabel="关闭" </div>
onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })} <AdminSwitch
/> checked={form.mfaEnabled}
checkedLabel="开启"
label="MFA 状态"
uncheckedLabel="关闭"
onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })}
/>
</div>
</section> </section>
</div> </div>
<div className="form-drawer__actions"> ) : null}
<Button onClick={onClose}>取消</Button> </FormDrawerShell>
<Button type="submit" variant="primary">提交</Button>
</div>
</form>
</Drawer>
);
}
function FinanceScopeGroup({ app, checkedScopes, onChecked, regions }) {
const appCode = app.appCode;
const isChecked = (regionId) =>
checkedScopes.some((scope) => scope.appCode === appCode && Number(scope.regionId) === Number(regionId));
return (
<div className="permission-scope-group">
<strong><AppIdentity app={app} size="small" /></strong>
<label className="permission-check" key={`${appCode}:0`}>
<input checked={isChecked(0)} onChange={(event) => onChecked(appCode, 0, event.target.checked)} type="checkbox" />
<span>全部区域</span>
</label>
{regions.map((region) => (
<label className="permission-check" key={`${appCode}:${region.regionId}`}>
<input
checked={isChecked(region.regionId)}
onChange={(event) => onChecked(appCode, region.regionId, event.target.checked)}
type="checkbox"
/>
<span>{[region.name, region.regionCode].filter(Boolean).join(" · ")}</span>
</label>
))}
</div>
); );
} }

View File

@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest"; import { expect, test, vi } from "vitest";
import { UserFormDrawer } from "./UserFormDrawer.jsx"; import { UserFormDrawer } from "./UserFormDrawer.jsx";
@ -19,7 +20,8 @@ const baseFinanceScopeCatalog = {
regions: [{ appCode: "Huwaa", name: "菲律宾", regionCode: "PHILIPPINES", regionId: 8 }] regions: [{ appCode: "Huwaa", name: "菲律宾", regionCode: "PHILIPPINES", regionId: 8 }]
}; };
test("keeps edit user drawer actions docked outside the scrollable content", () => { test("organizes edit user fields into dense workspace sections with docked actions", async () => {
const user = userEvent.setup();
render( render(
<UserFormDrawer <UserFormDrawer
editingUser={{ id: 3 }} editingUser={{ id: 3 }}
@ -37,12 +39,15 @@ test("keeps edit user drawer actions docked outside the scrollable content", ()
); );
const form = document.querySelector("form.form-drawer"); const form = document.querySelector("form.form-drawer");
const content = form.querySelector(".form-drawer__content"); const content = form.querySelector(".form-drawer__workspace");
const actions = form.querySelector(".form-drawer__actions"); const actions = form.querySelector(".form-drawer__actions");
expect(screen.getByRole("heading", { name: "编辑用户" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "编辑用户" })).toBeInTheDocument();
expect(form).toHaveClass("form-drawer--sticky-actions"); expect(form).toHaveClass("form-drawer--workspace");
expect(content).toContainElement(screen.getByText("财务范围").closest(".form-drawer__section")); expect(content).toContainElement(screen.getByRole("textbox", { name: /账号/ }));
await user.click(screen.getByRole("button", { name: "财务范围0" }));
expect(content).toContainElement(screen.getByRole("navigation", { name: "应用范围" }));
expect(screen.getByPlaceholderText("搜索区域名称或编码")).toBeInTheDocument();
expect(actions).toContainElement(screen.getByRole("button", { name: "取消" })); expect(actions).toContainElement(screen.getByRole("button", { name: "取消" }));
expect(actions).toContainElement(screen.getByRole("button", { name: "提交" })); expect(actions).toContainElement(screen.getByRole("button", { name: "提交" }));
}); });

View File

@ -0,0 +1,64 @@
import CloseOutlined from "@mui/icons-material/CloseOutlined";
import Drawer from "@mui/material/Drawer";
import { Button } from "@/shared/ui/Button.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
export function FormDrawerShell({
activeSection,
children,
className = "",
onClose,
onSectionChange,
onSubmit,
open,
sections,
summary,
title,
}) {
return (
<Drawer anchor="right" open={open} onClose={onClose}>
<form
aria-label={title}
className={["form-drawer", "form-drawer--workspace", className].filter(Boolean).join(" ")}
onSubmit={onSubmit}
>
<header className="form-drawer__header">
<div className="form-drawer__heading">
<h2>{title}</h2>
{summary ? <span>{summary}</span> : null}
</div>
<IconButton label="关闭" onClick={onClose}>
<CloseOutlined fontSize="small" />
</IconButton>
</header>
<nav aria-label={`${title}分区`} className="form-drawer__tabs">
{sections.map((section) => {
const active = section.key === activeSection;
return (
<button
aria-current={active ? "page" : undefined}
className={active ? "is-active" : ""}
key={section.key}
onClick={() => onSectionChange(section.key)}
type="button"
>
<span>{section.label}</span>
{section.count !== undefined ? <small>{section.count}</small> : null}
</button>
);
})}
</nav>
<div className="form-drawer__workspace">{children}</div>
<div className="form-drawer__actions">
<Button onClick={onClose}>取消</Button>
<Button type="submit" variant="primary">
提交
</Button>
</div>
</form>
</Drawer>
);
}

View File

@ -939,6 +939,472 @@
padding-top: var(--space-2); padding-top: var(--space-2);
} }
/* 高密度编辑统一使用固定标题、分区导航和操作区,避免长表单把提交按钮推离视口。 */
.form-drawer--workspace {
box-sizing: border-box;
width: min(900px, calc(100vw - 48px));
height: 100vh;
max-height: 100vh;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 0;
overflow: hidden;
padding: var(--space-5);
background: var(--bg-card);
}
.role-form-workspace {
width: min(1180px, calc(100vw - 48px));
}
.form-drawer__header {
display: flex;
min-height: 44px;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.form-drawer__heading {
display: grid;
min-width: 0;
gap: 2px;
}
.form-drawer__heading h2 {
margin: 0;
}
.form-drawer__heading > span {
overflow: hidden;
color: var(--text-tertiary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-drawer__tabs {
display: flex;
min-height: 42px;
align-items: flex-end;
gap: var(--space-5);
border-bottom: 1px solid var(--border);
}
.form-drawer__tabs button {
position: relative;
display: inline-flex;
min-height: 42px;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-1);
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-weight: 650;
transition: color var(--motion-fast) var(--ease-standard);
}
.form-drawer__tabs button::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background: var(--primary);
content: "";
opacity: 0;
transform: scaleX(0.65);
transition:
opacity var(--motion-fast) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.form-drawer__tabs button:hover,
.form-drawer__tabs button.is-active {
color: var(--primary);
}
.form-drawer__tabs button.is-active::after {
opacity: 1;
transform: scaleX(1);
}
.form-drawer__tabs small {
display: grid;
min-width: 20px;
height: 20px;
padding: 0 6px;
place-items: center;
border-radius: var(--radius-pill);
background: var(--neutral-surface);
color: var(--text-tertiary);
line-height: 20px;
}
.form-drawer__tabs button.is-active small {
background: var(--primary-surface);
color: var(--primary);
}
.form-drawer__workspace {
min-height: 0;
overflow: auto;
padding: var(--space-4) 2px;
}
.form-drawer--workspace .form-drawer__panel {
align-content: start;
min-height: 100%;
}
.form-drawer--workspace .form-drawer__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-drawer--workspace .form-drawer__actions {
padding-top: var(--space-3);
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.form-drawer__section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.form-drawer__section-heading > span {
color: var(--text-tertiary);
font-size: 12px;
}
.form-drawer__access-panel {
display: grid;
align-content: start;
gap: var(--space-5);
}
.form-setting-row {
display: flex;
min-height: 56px;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: 0 var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
}
.form-setting-row > div {
display: grid;
gap: 2px;
}
.form-setting-row strong {
color: var(--text-primary);
}
.form-setting-row span {
color: var(--text-tertiary);
font-size: 12px;
}
/* 主从分栏让用户先定位应用或权限分类,再编辑单个范围,内容量增长时仍保持稳定扫读路径。 */
.form-editor-split {
display: grid;
min-height: 100%;
grid-template-columns: 220px minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
}
.form-editor-split--permissions {
grid-template-columns: 210px minmax(0, 1fr);
}
.form-editor-rail {
min-width: 0;
overflow: auto;
border-right: 1px solid var(--border);
background: var(--bg-card-strong);
}
.form-editor-rail__label {
padding: var(--space-3);
color: var(--text-tertiary);
font-size: 12px;
font-weight: 700;
}
.form-editor-rail button {
display: flex;
width: 100%;
min-height: 42px;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 0;
border-left: 2px solid transparent;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
transition:
background var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.form-editor-rail button:hover {
background: var(--bg-hover);
}
.form-editor-rail button.is-active {
border-left-color: var(--primary);
background: var(--primary-surface);
color: var(--primary);
font-weight: 700;
}
.form-editor-rail button > :first-child {
min-width: 0;
}
.form-editor-rail__count {
flex: 0 0 auto;
color: var(--text-tertiary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.form-editor-panel {
display: grid;
min-width: 0;
min-height: 0;
align-content: start;
overflow: auto;
}
.form-editor-toolbar {
position: sticky;
z-index: 1;
top: 0;
display: grid;
min-height: 60px;
grid-template-columns: minmax(120px, 1fr) minmax(220px, 300px) auto;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-bottom: 1px solid var(--border);
background: var(--bg-card);
}
.form-editor-toolbar > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.form-editor-toolbar strong {
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.form-editor-toolbar > div:first-child > span {
color: var(--text-tertiary);
font-size: 12px;
}
.form-editor-filter,
.form-editor-group-actions label {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-secondary);
white-space: nowrap;
}
.form-editor-filter .MuiCheckbox-root,
.form-editor-group-actions .MuiCheckbox-root,
.form-option .MuiCheckbox-root {
padding: 0;
}
.form-editor-group-actions {
display: flex;
min-height: 40px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 0 var(--space-3);
border-bottom: 1px solid var(--border-soft);
color: var(--text-tertiary);
font-size: 12px;
}
.form-option-grid {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
}
.form-option-grid--finance,
.form-option-grid--roles {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-option-grid--permissions {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-option {
display: flex;
min-width: 0;
min-height: 48px;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
color: var(--text-secondary);
cursor: pointer;
transition:
background var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.form-option:hover {
border-color: var(--primary-border-strong);
background: var(--primary-hover);
}
.form-option:active {
transform: scale(0.99);
}
.form-option > span {
display: grid;
min-width: 0;
gap: 2px;
}
.form-option strong,
.form-option small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-option strong {
color: var(--text-primary);
font-size: var(--control-font-size);
font-weight: 650;
}
.form-option small {
color: var(--text-tertiary);
font-size: 11px;
}
.form-option--single-line {
min-height: 40px;
}
.form-option--single-line > span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-editor-state {
display: grid;
min-height: 160px;
place-items: center;
color: var(--text-tertiary);
}
.form-editor-state--error {
color: var(--danger);
}
@media (max-width: 980px) {
.form-drawer--workspace,
.role-form-workspace {
width: 100vw;
padding: var(--space-4);
}
.form-editor-split,
.form-editor-split--permissions {
grid-template-columns: 180px minmax(0, 1fr);
}
.form-option-grid--permissions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.form-drawer--workspace .form-drawer__grid,
.form-option-grid--finance,
.form-option-grid--roles,
.form-option-grid--permissions {
grid-template-columns: 1fr;
}
.form-editor-split,
.form-editor-split--permissions {
grid-template-columns: 1fr;
overflow: visible;
}
.form-editor-rail {
display: flex;
max-height: none;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.form-editor-rail__label {
display: none;
}
.form-editor-rail button {
width: auto;
min-width: max-content;
border-bottom: 2px solid transparent;
border-left: 0;
}
.form-editor-rail button.is-active {
border-bottom-color: var(--primary);
}
.form-editor-toolbar {
position: static;
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.form-drawer__tabs button::after,
.form-editor-rail button,
.form-option {
transition: none;
}
}
.reward-tier-editor-table { .reward-tier-editor-table {
display: grid; display: grid;
min-width: 0; min-width: 0;