权限展示
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 { 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 (
|
||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||
<form className="form-drawer form-drawer--wide role-permission-drawer" onSubmit={onSubmit}>
|
||||
<h2>{editingRole ? "编辑角色" : "新增角色"}</h2>
|
||||
<section className="form-drawer__section">
|
||||
<FormDrawerShell
|
||||
activeSection={activeSection}
|
||||
className="role-form-workspace"
|
||||
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__grid">
|
||||
<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 })} />
|
||||
</div>
|
||||
</section>
|
||||
{abilities.canLoadPermissions ? (
|
||||
<section className="form-drawer__section role-permission-scope">
|
||||
<div className="form-drawer__section-title">权限范围</div>
|
||||
<div className="permission-menu-list role-permission-menu-list">
|
||||
{permissionGroups.map((group) => (
|
||||
<PermissionMenuGroup
|
||||
abilities={abilities}
|
||||
group={group}
|
||||
key={group.key}
|
||||
permissionIds={form.permissionIds}
|
||||
onTogglePermission={onTogglePermission}
|
||||
onTogglePermissionGroup={onTogglePermissionGroup}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
{activeSection === "permissions" && abilities.canLoadPermissions ? (
|
||||
<RolePermissionEditor
|
||||
abilities={abilities}
|
||||
onTogglePermission={onTogglePermission}
|
||||
onTogglePermissionGroup={onTogglePermissionGroup}
|
||||
permissionGroups={permissionGroups}
|
||||
permissionIds={form.permissionIds}
|
||||
/>
|
||||
) : null}
|
||||
</FormDrawerShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 { 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 (
|
||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||
<form className="form-drawer form-drawer--sticky-actions" onSubmit={onSubmit}>
|
||||
<h2>{editingUser ? "编辑用户" : "新增用户"}</h2>
|
||||
<div className="form-drawer__content">
|
||||
<FormDrawerShell
|
||||
activeSection={activeSection}
|
||||
className="user-form-workspace"
|
||||
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">
|
||||
<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 className="form-drawer__section-heading">
|
||||
<div className="form-drawer__section-title">角色</div>
|
||||
<span>已选 {form.roleIds.length} 项</span>
|
||||
</div>
|
||||
</section>
|
||||
<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">
|
||||
<div className="form-option-grid form-option-grid--roles">
|
||||
{roles.map((role) => (
|
||||
<label className="permission-check" key={role.id}>
|
||||
<input
|
||||
<label className="form-option form-option--single-line" key={role.id}>
|
||||
<Checkbox
|
||||
checked={form.roleIds.includes(role.id)}
|
||||
onChange={(event) => onRoleChecked(role.id, event.target.checked)}
|
||||
type="checkbox"
|
||||
size="small"
|
||||
/>
|
||||
<span>{role.name}</span>
|
||||
</label>
|
||||
@ -91,46 +112,22 @@ export function UserFormDrawer({
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">安全设置</div>
|
||||
<AdminSwitch
|
||||
checked={form.mfaEnabled}
|
||||
checkedLabel="开启"
|
||||
label="MFA 状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })}
|
||||
/>
|
||||
<div className="form-setting-row">
|
||||
<div>
|
||||
<strong>MFA 状态</strong>
|
||||
<span>登录时进行多因素认证</span>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={form.mfaEnabled}
|
||||
checkedLabel="开启"
|
||||
label="MFA 状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="form-drawer__actions">
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<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>
|
||||
) : null}
|
||||
</FormDrawerShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { UserFormDrawer } from "./UserFormDrawer.jsx";
|
||||
|
||||
@ -19,7 +20,8 @@ const baseFinanceScopeCatalog = {
|
||||
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(
|
||||
<UserFormDrawer
|
||||
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 content = form.querySelector(".form-drawer__content");
|
||||
const content = form.querySelector(".form-drawer__workspace");
|
||||
const actions = form.querySelector(".form-drawer__actions");
|
||||
|
||||
expect(screen.getByRole("heading", { name: "编辑用户" })).toBeInTheDocument();
|
||||
expect(form).toHaveClass("form-drawer--sticky-actions");
|
||||
expect(content).toContainElement(screen.getByText("财务范围").closest(".form-drawer__section"));
|
||||
expect(form).toHaveClass("form-drawer--workspace");
|
||||
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: "提交" }));
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/* 高密度编辑统一使用固定标题、分区导航和操作区,避免长表单把提交按钮推离视口。 */
|
||||
.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 {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user