权限展示
This commit is contained in:
parent
54b584c096
commit
0299d57aa4
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/features/roles/components/RoleFormDrawer.test.jsx
Normal file
40
src/features/roles/components/RoleFormDrawer.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
147
src/features/roles/components/RolePermissionEditor.jsx
Normal file
147
src/features/roles/components/RolePermissionEditor.jsx
Normal 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;
|
||||||
|
}
|
||||||
147
src/features/users/components/UserFinanceScopeEditor.jsx
Normal file
147
src/features/users/components/UserFinanceScopeEditor.jsx
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: "提交" }));
|
||||||
});
|
});
|
||||||
|
|||||||
64
src/shared/ui/FormDrawerShell.jsx
Normal file
64
src/shared/ui/FormDrawerShell.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user