1941 lines
57 KiB
TypeScript
1941 lines
57 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
||
import {
|
||
Activity,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
CircleHelp,
|
||
Database,
|
||
FilePlus2,
|
||
FolderPlus,
|
||
FolderTree,
|
||
KeyRound,
|
||
LayoutGrid,
|
||
Menu as MenuIcon,
|
||
MoreHorizontal,
|
||
Plus,
|
||
Save,
|
||
Search,
|
||
ShieldCheck,
|
||
Trash2,
|
||
Users,
|
||
} from "lucide-react";
|
||
import "./index.css";
|
||
|
||
type Workspace = "overview" | "domainEntities" | "menuPermissions" | "roles";
|
||
type Domain = {
|
||
id: number;
|
||
code: string;
|
||
name: string;
|
||
parentId: number | null;
|
||
enabled: boolean;
|
||
description: string;
|
||
};
|
||
type Entity = {
|
||
id: number;
|
||
domainId: number;
|
||
code: string;
|
||
name: string;
|
||
view: string;
|
||
save: string;
|
||
key: string;
|
||
type: "table" | "view" | "query";
|
||
enabled: boolean;
|
||
};
|
||
type Field = {
|
||
id: number;
|
||
entityId: number;
|
||
code: string;
|
||
name: string;
|
||
type: string;
|
||
nullable: boolean;
|
||
enabled: boolean;
|
||
};
|
||
type Relation = {
|
||
id: number;
|
||
entityId: number;
|
||
targetEntityId: number;
|
||
relationType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
|
||
foreignField: string;
|
||
linkField: string;
|
||
ownership: "reference" | "owned";
|
||
deletePolicy: "restrict" | "set-null" | "cascade" | "archive";
|
||
enabled: boolean;
|
||
};
|
||
type Menu = {
|
||
id: number;
|
||
parentId: number | null;
|
||
code: string;
|
||
name: string;
|
||
type: "directory" | "page" | "data";
|
||
route: string;
|
||
entityId: number | null;
|
||
domainId: number | null;
|
||
enabled: boolean;
|
||
visible: boolean;
|
||
};
|
||
type Role = { id: number; code: string; name: string; members: number };
|
||
type Power = {
|
||
id: number;
|
||
menuId: number;
|
||
code: string;
|
||
name: string;
|
||
enabled: boolean;
|
||
};
|
||
|
||
const initialDomains: Domain[] = [
|
||
{
|
||
id: 1,
|
||
code: "ocean",
|
||
name: "海运业务",
|
||
parentId: null,
|
||
enabled: true,
|
||
description: "订舱、运输节点与费用相关业务能力",
|
||
},
|
||
{
|
||
id: 2,
|
||
code: "booking",
|
||
name: "订舱管理",
|
||
parentId: 1,
|
||
enabled: true,
|
||
description: "订舱单据及审核流程",
|
||
},
|
||
{
|
||
id: 3,
|
||
code: "master-data",
|
||
name: "基础资料",
|
||
parentId: null,
|
||
enabled: true,
|
||
description: "客户、港口、航线等公共主数据",
|
||
},
|
||
];
|
||
const initialEntities: Entity[] = [
|
||
{
|
||
id: 1,
|
||
domainId: 2,
|
||
code: "ocean_booking",
|
||
name: "订舱单",
|
||
view: "v_ocean_booking_list",
|
||
save: "ocean_booking",
|
||
key: "b_id",
|
||
type: "view",
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 2,
|
||
domainId: 2,
|
||
code: "ocean_booking_container",
|
||
name: "集装箱明细",
|
||
view: "ocean_booking_container",
|
||
save: "ocean_booking_container",
|
||
key: "b_id",
|
||
type: "table",
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 3,
|
||
domainId: 3,
|
||
code: "base_customer",
|
||
name: "客户档案",
|
||
view: "base_customer",
|
||
save: "base_customer",
|
||
key: "b_id",
|
||
type: "table",
|
||
enabled: true,
|
||
},
|
||
];
|
||
const initialFields: Field[] = [
|
||
{
|
||
id: 1,
|
||
entityId: 1,
|
||
code: "b_ywno",
|
||
name: "业务单号",
|
||
type: "input",
|
||
nullable: false,
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 2,
|
||
entityId: 1,
|
||
code: "b_customer_id",
|
||
name: "委托客户",
|
||
type: "select",
|
||
nullable: false,
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 3,
|
||
entityId: 1,
|
||
code: "b_ywtype",
|
||
name: "业务类型",
|
||
type: "select",
|
||
nullable: false,
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 4,
|
||
entityId: 1,
|
||
code: "b_etd",
|
||
name: "预计开船日",
|
||
type: "date",
|
||
nullable: true,
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 5,
|
||
entityId: 1,
|
||
code: "b_state",
|
||
name: "单据状态",
|
||
type: "select",
|
||
nullable: false,
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 6,
|
||
entityId: 3,
|
||
code: "b_code",
|
||
name: "客户编码",
|
||
type: "input",
|
||
nullable: false,
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 7,
|
||
entityId: 3,
|
||
code: "b_name",
|
||
name: "客户名称",
|
||
type: "input",
|
||
nullable: false,
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 8,
|
||
entityId: 2,
|
||
code: "b_booking_id",
|
||
name: "订舱单",
|
||
type: "number",
|
||
nullable: false,
|
||
enabled: true,
|
||
},
|
||
];
|
||
const initialRelations: Relation[] = [
|
||
{
|
||
id: 1,
|
||
entityId: 1,
|
||
targetEntityId: 2,
|
||
relationType: "one-to-many",
|
||
foreignField: "b_booking_id",
|
||
linkField: "b_id",
|
||
ownership: "owned",
|
||
deletePolicy: "cascade",
|
||
enabled: true,
|
||
},
|
||
{
|
||
id: 2,
|
||
entityId: 1,
|
||
targetEntityId: 3,
|
||
relationType: "many-to-one",
|
||
foreignField: "b_customer_id",
|
||
linkField: "b_id",
|
||
ownership: "reference",
|
||
deletePolicy: "restrict",
|
||
enabled: true,
|
||
},
|
||
];
|
||
const initialMenus: Menu[] = [
|
||
{
|
||
id: 1,
|
||
parentId: null,
|
||
code: "ocean",
|
||
name: "海运业务",
|
||
type: "directory",
|
||
route: "",
|
||
entityId: null,
|
||
domainId: 1,
|
||
enabled: true,
|
||
visible: true,
|
||
},
|
||
{
|
||
id: 2,
|
||
parentId: 1,
|
||
code: "booking",
|
||
name: "订舱管理",
|
||
type: "directory",
|
||
route: "",
|
||
entityId: null,
|
||
domainId: 2,
|
||
enabled: true,
|
||
visible: true,
|
||
},
|
||
{
|
||
id: 3,
|
||
parentId: 2,
|
||
code: "booking-list",
|
||
name: "订舱列表",
|
||
type: "data",
|
||
route: "/ocean/booking",
|
||
entityId: 1,
|
||
domainId: 2,
|
||
enabled: true,
|
||
visible: true,
|
||
},
|
||
{
|
||
id: 4,
|
||
parentId: 2,
|
||
code: "booking-audit",
|
||
name: "订舱审核",
|
||
type: "page",
|
||
route: "/ocean/booking/audit",
|
||
entityId: null,
|
||
domainId: 2,
|
||
enabled: true,
|
||
visible: true,
|
||
},
|
||
{
|
||
id: 5,
|
||
parentId: null,
|
||
code: "master-data",
|
||
name: "基础资料",
|
||
type: "directory",
|
||
route: "",
|
||
entityId: null,
|
||
domainId: 3,
|
||
enabled: true,
|
||
visible: true,
|
||
},
|
||
{
|
||
id: 6,
|
||
parentId: 5,
|
||
code: "customer",
|
||
name: "客户档案",
|
||
type: "data",
|
||
route: "/base/customer",
|
||
entityId: 3,
|
||
domainId: 3,
|
||
enabled: true,
|
||
visible: true,
|
||
},
|
||
];
|
||
const initialRoles: Role[] = [
|
||
{ id: 1, code: "ops", name: "运营专员", members: 18 },
|
||
{ id: 2, code: "manager", name: "业务经理", members: 6 },
|
||
{ id: 3, code: "admin", name: "系统管理员", members: 2 },
|
||
];
|
||
const actionNames = ["view", "create", "edit", "delete", "export"];
|
||
const actionLabels: Record<string, string> = {
|
||
view: "查看",
|
||
create: "新增",
|
||
edit: "编辑",
|
||
delete: "删除",
|
||
export: "导出",
|
||
};
|
||
const fieldTypes = [
|
||
"input",
|
||
"select",
|
||
"date",
|
||
"datetime",
|
||
"number",
|
||
"textarea",
|
||
];
|
||
|
||
export default function App() {
|
||
const [workspace, setWorkspace] = useState<Workspace>("overview");
|
||
const [domains, setDomains] = useState(initialDomains);
|
||
const [entities, setEntities] = useState(initialEntities);
|
||
const [fields, setFields] = useState(initialFields);
|
||
const [relations, setRelations] = useState(initialRelations);
|
||
const [menus, setMenus] = useState(initialMenus);
|
||
const [roles] = useState(initialRoles);
|
||
const [powers, setPowers] = useState<Power[]>(() =>
|
||
initialMenus
|
||
.filter((menu) => menu.type !== "directory")
|
||
.flatMap((menu) =>
|
||
actionNames.map((action, index) => ({
|
||
id: menu.id * 10 + index,
|
||
menuId: menu.id,
|
||
code: `${menu.code}.${action}`,
|
||
name: actionLabels[action],
|
||
enabled: action !== "delete",
|
||
})),
|
||
),
|
||
);
|
||
const [selectedDomainId, setSelectedDomainId] = useState(2);
|
||
const [selectedEntityId, setSelectedEntityId] = useState(1);
|
||
const [selectedMenuId, setSelectedMenuId] = useState(3);
|
||
const [selectedRoleId, setSelectedRoleId] = useState(1);
|
||
const [menuTab, setMenuTab] = useState<"basic" | "powers" | "scope">("basic");
|
||
const [dirty, setDirty] = useState(false);
|
||
const [toast, setToast] = useState("");
|
||
const selectedDomain =
|
||
domains.find((item) => item.id === selectedDomainId) ?? domains[0];
|
||
const selectedEntity =
|
||
entities.find(
|
||
(item) =>
|
||
item.id === selectedEntityId && item.domainId === selectedDomain?.id,
|
||
) ?? entities.find((item) => item.domainId === selectedDomain?.id);
|
||
const selectedMenu =
|
||
menus.find((item) => item.id === selectedMenuId) ?? menus[0];
|
||
const selectedRole =
|
||
roles.find((item) => item.id === selectedRoleId) ?? roles[0];
|
||
const selectDomain = (id: number) => {
|
||
setSelectedDomainId(id);
|
||
setSelectedEntityId(0);
|
||
};
|
||
const selectEntity = (id: number) => {
|
||
const entity = entities.find((item) => item.id === id);
|
||
if (!entity) return;
|
||
setSelectedDomainId(entity.domainId);
|
||
setSelectedEntityId(id);
|
||
};
|
||
const markDirty = () => setDirty(true);
|
||
const save = () => {
|
||
setDirty(false);
|
||
setToast("配置已保存到当前工作区");
|
||
window.setTimeout(() => setToast(""), 2600);
|
||
};
|
||
const addDomain = (parentId: number | null) => {
|
||
const id = Math.max(...domains.map((item) => item.id), 0) + 1;
|
||
setDomains((items) => [
|
||
...items,
|
||
{
|
||
id,
|
||
code: `new-domain-${id}`,
|
||
name: "新业务域",
|
||
parentId,
|
||
enabled: true,
|
||
description: "请补充业务域说明",
|
||
},
|
||
]);
|
||
setSelectedDomainId(id);
|
||
setWorkspace("domainEntities");
|
||
markDirty();
|
||
};
|
||
const addEntity = () => {
|
||
const id = Math.max(...entities.map((item) => item.id), 0) + 1;
|
||
setEntities((items) => [
|
||
...items,
|
||
{
|
||
id,
|
||
domainId: selectedDomain.id,
|
||
code: `new_entity_${id}`,
|
||
name: "新实体",
|
||
view: "",
|
||
save: "",
|
||
key: "b_id",
|
||
type: "table",
|
||
enabled: true,
|
||
},
|
||
]);
|
||
setSelectedEntityId(id);
|
||
setWorkspace("domainEntities");
|
||
markDirty();
|
||
};
|
||
const addMenu = (type: Menu["type"], parentId: number | null) => {
|
||
const id = Math.max(...menus.map((item) => item.id), 0) + 1;
|
||
const item: Menu = {
|
||
id,
|
||
parentId,
|
||
code: `new-menu-${id}`,
|
||
name:
|
||
type === "directory"
|
||
? "新目录"
|
||
: type === "data"
|
||
? "新数据页面"
|
||
: "新页面",
|
||
type,
|
||
route: type === "directory" ? "" : "/new-route",
|
||
entityId: type === "data" ? (selectedEntity?.id ?? null) : null,
|
||
domainId: selectedDomain.id,
|
||
enabled: true,
|
||
visible: true,
|
||
};
|
||
setMenus((items) => [...items, item]);
|
||
setSelectedMenuId(id);
|
||
setWorkspace("menuPermissions");
|
||
setMenuTab("basic");
|
||
if (type !== "directory")
|
||
setPowers((items) => [
|
||
...items,
|
||
...actionNames.map((action, index) => ({
|
||
id: id * 10 + index,
|
||
menuId: id,
|
||
code: `${item.code}.${action}`,
|
||
name: actionLabels[action],
|
||
enabled: action !== "delete",
|
||
})),
|
||
]);
|
||
markDirty();
|
||
};
|
||
return (
|
||
<div className="app-shell">
|
||
<header className="topbar">
|
||
<div className="brand">
|
||
<span className="brand-mark">
|
||
<FolderTree size={18} />
|
||
</span>
|
||
<div>
|
||
<strong>FMS 配置中心</strong>
|
||
<span>业务域与实体 · 菜单与权限</span>
|
||
</div>
|
||
</div>
|
||
<div className="top-context">
|
||
<span className="context-dot" />
|
||
生产环境 <ChevronDown size={14} />
|
||
</div>
|
||
<div className="top-actions">
|
||
<span className={`save-state ${dirty ? "dirty" : ""}`}>
|
||
<i />
|
||
{dirty ? "有未保存修改" : "配置已同步"}
|
||
</span>
|
||
<button
|
||
className="button primary-button"
|
||
disabled={!dirty}
|
||
onClick={save}
|
||
>
|
||
<Save size={15} />
|
||
保存
|
||
</button>
|
||
<span className="user-chip">林</span>
|
||
</div>
|
||
</header>
|
||
<div className="app-body">
|
||
<aside className="sidebar">
|
||
<div className="sidebar-label">元数据管理</div>
|
||
<SideNav
|
||
icon={<LayoutGrid size={16} />}
|
||
label="总览"
|
||
active={workspace === "overview"}
|
||
onClick={() => setWorkspace("overview")}
|
||
/>
|
||
<SideNav
|
||
icon={<FolderTree size={16} />}
|
||
label="业务域与实体"
|
||
caption={`${domains.length} 域 · ${entities.length} 实体`}
|
||
active={workspace === "domainEntities"}
|
||
onClick={() => setWorkspace("domainEntities")}
|
||
/>
|
||
<SideNav
|
||
icon={<MenuIcon size={16} />}
|
||
label="菜单与权限"
|
||
caption={`${menus.filter((item) => item.type !== "directory").length} 入口`}
|
||
active={workspace === "menuPermissions"}
|
||
onClick={() => setWorkspace("menuPermissions")}
|
||
/>
|
||
<SideNav
|
||
icon={<Users size={16} />}
|
||
label="角色与用户"
|
||
caption={`${roles.length} 个角色`}
|
||
active={workspace === "roles"}
|
||
onClick={() => setWorkspace("roles")}
|
||
/>
|
||
<div className="sidebar-divider" />
|
||
<div className="sidebar-label">运行状态</div>
|
||
<div className="status-item">
|
||
<span className="status-icon green">
|
||
<Activity size={15} />
|
||
</span>
|
||
<div>
|
||
<strong>元数据编译</strong>
|
||
<small>已是最新版本</small>
|
||
</div>
|
||
<span className="status-live" />
|
||
</div>
|
||
<div className="sidebar-footer">
|
||
<CircleHelp size={14} />
|
||
<span>业务域和实体一起维护,菜单和权限一起维护。</span>
|
||
</div>
|
||
</aside>
|
||
<main className="main-content">
|
||
{workspace === "overview" && (
|
||
<Overview
|
||
domains={domains}
|
||
entities={entities}
|
||
menus={menus}
|
||
roles={roles}
|
||
onNavigate={setWorkspace}
|
||
/>
|
||
)}
|
||
{workspace === "domainEntities" && (
|
||
<DomainEntityWorkspace
|
||
domains={domains}
|
||
entities={entities}
|
||
fields={fields}
|
||
relations={relations}
|
||
selectedDomain={selectedDomain}
|
||
selectedEntity={selectedEntity}
|
||
onSelectDomain={selectDomain}
|
||
onSelectEntity={selectEntity}
|
||
onAddDomain={addDomain}
|
||
onAddEntity={addEntity}
|
||
onDomainChange={(patch) => {
|
||
setDomains((items) =>
|
||
items.map((item) =>
|
||
item.id === selectedDomain.id
|
||
? { ...item, ...patch }
|
||
: item,
|
||
),
|
||
);
|
||
markDirty();
|
||
}}
|
||
onEntityChange={(patch) => {
|
||
if (!selectedEntity) return;
|
||
setEntities((items) =>
|
||
items.map((item) =>
|
||
item.id === selectedEntity.id
|
||
? { ...item, ...patch }
|
||
: item,
|
||
),
|
||
);
|
||
markDirty();
|
||
}}
|
||
onFieldChange={(id, patch) => {
|
||
setFields((items) =>
|
||
items.map((item) =>
|
||
item.id === id ? { ...item, ...patch } : item,
|
||
),
|
||
);
|
||
markDirty();
|
||
}}
|
||
onRelationChange={(id, patch) => {
|
||
setRelations((items) =>
|
||
items.map((item) =>
|
||
item.id === id ? { ...item, ...patch } : item,
|
||
),
|
||
);
|
||
markDirty();
|
||
}}
|
||
onAddField={() => {
|
||
if (!selectedEntity) return;
|
||
const id = Math.max(...fields.map((item) => item.id), 0) + 1;
|
||
setFields((items) => [
|
||
...items,
|
||
{
|
||
id,
|
||
entityId: selectedEntity.id,
|
||
code: `b_field_${id}`,
|
||
name: "新字段",
|
||
type: "input",
|
||
nullable: true,
|
||
enabled: true,
|
||
},
|
||
]);
|
||
markDirty();
|
||
}}
|
||
/>
|
||
)}
|
||
{workspace === "menuPermissions" && (
|
||
<MenuPermissionWorkspace
|
||
menus={menus}
|
||
domains={domains}
|
||
entities={entities}
|
||
roles={roles}
|
||
powers={powers}
|
||
selectedMenu={selectedMenu}
|
||
selectedRole={selectedRole}
|
||
tab={menuTab}
|
||
onTab={setMenuTab}
|
||
onSelectMenu={setSelectedMenuId}
|
||
onSelectRole={setSelectedRoleId}
|
||
onAdd={addMenu}
|
||
onMenuChange={(patch) => {
|
||
setMenus((items) =>
|
||
items.map((item) =>
|
||
item.id === selectedMenu.id ? { ...item, ...patch } : item,
|
||
),
|
||
);
|
||
markDirty();
|
||
}}
|
||
onToggle={(id) => {
|
||
setPowers((items) =>
|
||
items.map((item) =>
|
||
item.id === id ? { ...item, enabled: !item.enabled } : item,
|
||
),
|
||
);
|
||
markDirty();
|
||
}}
|
||
onPublish={() => {
|
||
setDirty(false);
|
||
setToast("菜单权限已发布");
|
||
window.setTimeout(() => setToast(""), 2600);
|
||
}}
|
||
/>
|
||
)}
|
||
{workspace === "roles" && <RoleWorkspace roles={roles} />}
|
||
</main>
|
||
</div>
|
||
{toast && (
|
||
<div className="toast">
|
||
<span className="toast-check">✓</span>
|
||
{toast}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SideNav({
|
||
icon,
|
||
label,
|
||
caption,
|
||
active,
|
||
onClick,
|
||
}: {
|
||
icon: ReactNode;
|
||
label: string;
|
||
caption?: string;
|
||
active: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button className={`side-nav ${active ? "active" : ""}`} onClick={onClick}>
|
||
<span className="side-nav-icon">{icon}</span>
|
||
<span>
|
||
{label}
|
||
{caption && <small>{caption}</small>}
|
||
</span>
|
||
{active && <span className="side-active" />}
|
||
</button>
|
||
);
|
||
}
|
||
function PageHeader({
|
||
eyebrow,
|
||
title,
|
||
description,
|
||
action,
|
||
}: {
|
||
eyebrow: string;
|
||
title: string;
|
||
description: string;
|
||
action?: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="page-header">
|
||
<div>
|
||
<div className="eyebrow">{eyebrow}</div>
|
||
<h1>{title}</h1>
|
||
<p>{description}</p>
|
||
</div>
|
||
{action}
|
||
</div>
|
||
);
|
||
}
|
||
function StatCard({
|
||
icon,
|
||
label,
|
||
value,
|
||
note,
|
||
tone,
|
||
}: {
|
||
icon: ReactNode;
|
||
label: string;
|
||
value: string;
|
||
note: string;
|
||
tone: string;
|
||
}) {
|
||
return (
|
||
<div className="stat-card">
|
||
<span className={`stat-icon ${tone}`}>{icon}</span>
|
||
<div>
|
||
<small>{label}</small>
|
||
<strong>{value}</strong>
|
||
<span>{note}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
function Overview({
|
||
domains,
|
||
entities,
|
||
menus,
|
||
roles,
|
||
onNavigate,
|
||
}: {
|
||
domains: Domain[];
|
||
entities: Entity[];
|
||
menus: Menu[];
|
||
roles: Role[];
|
||
onNavigate: (workspace: Workspace) => void;
|
||
}) {
|
||
return (
|
||
<div className="workspace">
|
||
<PageHeader
|
||
eyebrow="CONFIGURATION OVERVIEW"
|
||
title="配置总览"
|
||
description="按两个核心工作区维护元数据:业务域与实体、菜单与权限。"
|
||
action={
|
||
<button
|
||
className="button primary-button"
|
||
onClick={() => onNavigate("domainEntities")}
|
||
>
|
||
<FolderPlus size={15} />
|
||
开始配置
|
||
</button>
|
||
}
|
||
/>
|
||
<div className="stat-grid">
|
||
<StatCard
|
||
icon={<FolderTree size={18} />}
|
||
label="业务域"
|
||
value={String(domains.length)}
|
||
note="业务归属分组"
|
||
tone="blue"
|
||
/>
|
||
<StatCard
|
||
icon={<Database size={18} />}
|
||
label="实体"
|
||
value={String(entities.length)}
|
||
note="可查询 / 可保存数据对象"
|
||
tone="teal"
|
||
/>
|
||
<StatCard
|
||
icon={<MenuIcon size={18} />}
|
||
label="菜单入口"
|
||
value={String(
|
||
menus.filter((item) => item.type !== "directory").length,
|
||
)}
|
||
note="页面与数据菜单"
|
||
tone="amber"
|
||
/>
|
||
<StatCard
|
||
icon={<Users size={18} />}
|
||
label="角色"
|
||
value={String(roles.length)}
|
||
note="授权主体"
|
||
tone="violet"
|
||
/>
|
||
</div>
|
||
<div className="overview-grid">
|
||
<section className="surface workflow-surface">
|
||
<div className="surface-heading">
|
||
<div>
|
||
<h2>推荐维护方式</h2>
|
||
<p>每个工作区都把高关联对象放在同一屏幕。</p>
|
||
</div>
|
||
<span className="surface-kicker">2 WORKSPACES</span>
|
||
</div>
|
||
<WorkflowStep
|
||
number="01"
|
||
icon={<FolderTree size={17} />}
|
||
title="业务域与实体"
|
||
description="先选业务域,再在右侧维护实体、字段和页面配置。"
|
||
onClick={() => onNavigate("domainEntities")}
|
||
/>
|
||
<WorkflowStep
|
||
number="02"
|
||
icon={<MenuIcon size={17} />}
|
||
title="菜单与权限"
|
||
description="选中菜单后,在同一页面配置入口、动作、角色和数据范围。"
|
||
onClick={() => onNavigate("menuPermissions")}
|
||
/>
|
||
<WorkflowStep
|
||
number="03"
|
||
icon={<Users size={17} />}
|
||
title="角色与用户"
|
||
description="维护角色和人员归属,授权动作在菜单工作区完成。"
|
||
onClick={() => onNavigate("roles")}
|
||
/>
|
||
</section>
|
||
<section className="surface principle-surface">
|
||
<div className="surface-heading">
|
||
<div>
|
||
<h2>底层职责仍然分离</h2>
|
||
<p>页面聚合维护,数据模型保持清晰。</p>
|
||
</div>
|
||
</div>
|
||
<div className="model-chain">
|
||
<ModelNode
|
||
icon={<FolderTree size={16} />}
|
||
label="业务域"
|
||
detail="s_module"
|
||
tone="blue"
|
||
/>
|
||
<span className="chain-line" />
|
||
<ModelNode
|
||
icon={<Database size={16} />}
|
||
label="实体"
|
||
detail="s_entity"
|
||
tone="teal"
|
||
/>
|
||
<span className="chain-line" />
|
||
<ModelNode
|
||
icon={<MenuIcon size={16} />}
|
||
label="菜单"
|
||
detail="s_menu"
|
||
tone="amber"
|
||
/>
|
||
<span className="chain-line" />
|
||
<ModelNode
|
||
icon={<KeyRound size={16} />}
|
||
label="权限"
|
||
detail="s_power"
|
||
tone="violet"
|
||
/>
|
||
</div>
|
||
<div className="model-note">
|
||
<CircleHelp size={15} />
|
||
<span>
|
||
管理界面按操作关联度合并,底层仍由实体、菜单、权限表分别持久化。
|
||
</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
function WorkflowStep({
|
||
number,
|
||
icon,
|
||
title,
|
||
description,
|
||
onClick,
|
||
}: {
|
||
number: string;
|
||
icon: ReactNode;
|
||
title: string;
|
||
description: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button className="workflow-step" onClick={onClick}>
|
||
<span className="step-number">{number}</span>
|
||
<span className="step-icon">{icon}</span>
|
||
<span className="step-copy">
|
||
<strong>{title}</strong>
|
||
<small>{description}</small>
|
||
</span>
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
);
|
||
}
|
||
function ModelNode({
|
||
icon,
|
||
label,
|
||
detail,
|
||
tone,
|
||
}: {
|
||
icon: ReactNode;
|
||
label: string;
|
||
detail: string;
|
||
tone: string;
|
||
}) {
|
||
return (
|
||
<div className="model-node">
|
||
<span className={`model-icon ${tone}`}>{icon}</span>
|
||
<strong>{label}</strong>
|
||
<code>{detail}</code>
|
||
</div>
|
||
);
|
||
}
|
||
function DetailTitle({
|
||
icon,
|
||
eyebrow,
|
||
title,
|
||
badge,
|
||
}: {
|
||
icon: ReactNode;
|
||
eyebrow: string;
|
||
title: string;
|
||
badge: string;
|
||
}) {
|
||
return (
|
||
<div className="detail-title">
|
||
<span className="detail-title-icon">{icon}</span>
|
||
<div>
|
||
<div className="eyebrow">{eyebrow}</div>
|
||
<h2>{title}</h2>
|
||
</div>
|
||
<span className="status-badge">{badge}</span>
|
||
</div>
|
||
);
|
||
}
|
||
function LabeledInput({
|
||
label,
|
||
value,
|
||
onChange,
|
||
readOnly = false,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange?: (value: string) => void;
|
||
readOnly?: boolean;
|
||
}) {
|
||
return (
|
||
<label>
|
||
{label}
|
||
<input
|
||
value={value}
|
||
readOnly={readOnly}
|
||
onChange={(event) => onChange?.(event.target.value)}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
function Toggle({ value, onChange }: { value: boolean; onChange: () => void }) {
|
||
return (
|
||
<button
|
||
className={`toggle ${value ? "on" : ""}`}
|
||
onClick={onChange}
|
||
type="button"
|
||
aria-label={value ? "关闭" : "开启"}
|
||
>
|
||
<i />
|
||
</button>
|
||
);
|
||
}
|
||
function DomainEntityWorkspace({
|
||
domains,
|
||
entities,
|
||
fields,
|
||
relations,
|
||
selectedDomain,
|
||
selectedEntity,
|
||
onSelectDomain,
|
||
onSelectEntity,
|
||
onAddDomain,
|
||
onAddEntity,
|
||
onDomainChange,
|
||
onEntityChange,
|
||
onFieldChange,
|
||
onRelationChange,
|
||
onAddField,
|
||
}: {
|
||
domains: Domain[];
|
||
entities: Entity[];
|
||
fields: Field[];
|
||
relations: Relation[];
|
||
selectedDomain: Domain;
|
||
selectedEntity?: Entity;
|
||
onSelectDomain: (id: number) => void;
|
||
onSelectEntity: (id: number) => void;
|
||
onAddDomain: (parentId: number | null) => void;
|
||
onAddEntity: () => void;
|
||
onDomainChange: (patch: Partial<Domain>) => void;
|
||
onEntityChange: (patch: Partial<Entity>) => void;
|
||
onFieldChange: (id: number, patch: Partial<Field>) => void;
|
||
onRelationChange: (id: number, patch: Partial<Relation>) => void;
|
||
onAddField: () => void;
|
||
}) {
|
||
const visibleEntities = entities.filter(
|
||
(item) => item.domainId === selectedDomain.id,
|
||
);
|
||
const entityFields = selectedEntity
|
||
? fields.filter((item) => item.entityId === selectedEntity.id)
|
||
: [];
|
||
const entityRelations = selectedEntity
|
||
? relations.filter((item) => item.entityId === selectedEntity.id)
|
||
: [];
|
||
return (
|
||
<div className="workspace">
|
||
<PageHeader
|
||
eyebrow="01 · BUSINESS MODEL"
|
||
title="业务域与实体"
|
||
description="业务域和实体在同一棵配置树中展示;树表示归属与主从结构,普通引用仍在实体详情中维护。"
|
||
action={
|
||
<div className="button-group">
|
||
<button
|
||
className="button outline-button"
|
||
onClick={() => onAddDomain(null)}
|
||
>
|
||
<FolderPlus size={15} />
|
||
新建业务域
|
||
</button>
|
||
<button className="button primary-button" onClick={onAddEntity}>
|
||
<Plus size={15} />
|
||
新建实体
|
||
</button>
|
||
</div>
|
||
}
|
||
/>
|
||
<div className="domain-entity-layout">
|
||
<section className="surface domain-panel">
|
||
<div className="surface-heading">
|
||
<div>
|
||
<h2>业务模型树</h2>
|
||
<p>
|
||
{domains.length} 个业务域 · {entities.length} 个实体
|
||
</p>
|
||
</div>
|
||
<button
|
||
className="icon-button"
|
||
title="新建子业务域"
|
||
onClick={() => onAddDomain(selectedDomain.id)}
|
||
>
|
||
<Plus size={16} />
|
||
</button>
|
||
</div>
|
||
<div className="search-box">
|
||
<Search size={14} />
|
||
<input placeholder="搜索业务域或实体" />
|
||
</div>
|
||
<div className="domain-tree">
|
||
{renderBusinessTree(
|
||
domains,
|
||
entities,
|
||
relations,
|
||
null,
|
||
0,
|
||
selectedDomain.id,
|
||
selectedEntity?.id ?? 0,
|
||
onSelectDomain,
|
||
onSelectEntity,
|
||
)}
|
||
</div>
|
||
<div className="domain-quick">
|
||
<strong>{selectedDomain.name}</strong>
|
||
<span>{selectedDomain.description}</span>
|
||
<button
|
||
className="button outline-button"
|
||
onClick={() => onAddDomain(selectedDomain.id)}
|
||
>
|
||
<Plus size={14} />
|
||
添加子域
|
||
</button>
|
||
</div>
|
||
</section>
|
||
<section className="surface entity-panel">
|
||
<div className="surface-heading">
|
||
<div>
|
||
<h2>
|
||
{selectedEntity ? selectedEntity.name : selectedDomain.name}
|
||
</h2>
|
||
<p>
|
||
{selectedEntity
|
||
? "实体数据边界、字段与关系配置"
|
||
: `${visibleEntities.length} 个直接归属实体`}
|
||
</p>
|
||
</div>
|
||
<span className="surface-kicker">
|
||
{selectedEntity ? "S_ENTITY" : "S_MODULE"}
|
||
</span>
|
||
</div>
|
||
{!selectedEntity && (
|
||
<>
|
||
<DetailTitle
|
||
icon={<FolderTree size={17} />}
|
||
eyebrow="S_MODULE"
|
||
title={selectedDomain.name}
|
||
badge={selectedDomain.enabled ? "启用中" : "已停用"}
|
||
/>
|
||
<div className="form-grid">
|
||
<LabeledInput
|
||
label="业务域编码"
|
||
value={selectedDomain.code}
|
||
onChange={(value) => onDomainChange({ code: value })}
|
||
/>
|
||
<LabeledInput
|
||
label="业务域名称"
|
||
value={selectedDomain.name}
|
||
onChange={(value) => onDomainChange({ name: value })}
|
||
/>
|
||
<label className="full-field">
|
||
业务域说明
|
||
<textarea
|
||
value={selectedDomain.description}
|
||
onChange={(event) =>
|
||
onDomainChange({ description: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="domain-detail-note">
|
||
<CircleHelp size={15} />
|
||
<span>
|
||
业务域节点只表达分类和归属;在左侧树中选择实体后维护数据源、字段和实体关系。
|
||
</span>
|
||
<button className="button outline-button" onClick={onAddEntity}>
|
||
<Plus size={14} />
|
||
在此域新建实体
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{selectedEntity && (
|
||
<>
|
||
<div className="section-divider" />
|
||
<DetailTitle
|
||
icon={<Database size={17} />}
|
||
eyebrow="S_ENTITY"
|
||
title={selectedEntity.name}
|
||
badge={selectedEntity.enabled ? "启用中" : "已停用"}
|
||
/>
|
||
<div className="form-grid">
|
||
<LabeledInput
|
||
label="实体编码"
|
||
value={selectedEntity.code}
|
||
onChange={(value) => onEntityChange({ code: value })}
|
||
/>
|
||
<LabeledInput
|
||
label="实体名称"
|
||
value={selectedEntity.name}
|
||
onChange={(value) => onEntityChange({ name: value })}
|
||
/>
|
||
<LabeledInput
|
||
label="所属业务域"
|
||
value={selectedDomain.name}
|
||
readOnly
|
||
/>
|
||
<label>
|
||
实体类型
|
||
<select
|
||
value={selectedEntity.type}
|
||
onChange={(event) =>
|
||
onEntityChange({
|
||
type: event.target.value as Entity["type"],
|
||
})
|
||
}
|
||
>
|
||
<option value="table">table · 真实表</option>
|
||
<option value="view">view · 视图 + 保存表</option>
|
||
<option value="query">query · 纯查询</option>
|
||
</select>
|
||
</label>
|
||
<LabeledInput
|
||
label="查询视图 / 表"
|
||
value={selectedEntity.view}
|
||
onChange={(value) => onEntityChange({ view: value })}
|
||
/>
|
||
<LabeledInput
|
||
label="保存表"
|
||
value={selectedEntity.save}
|
||
onChange={(value) => onEntityChange({ save: value })}
|
||
/>
|
||
<LabeledInput
|
||
label="主键字段"
|
||
value={selectedEntity.key}
|
||
onChange={(value) => onEntityChange({ key: value })}
|
||
/>
|
||
<LabeledInput
|
||
label="默认排序"
|
||
value="b_inputdatetime desc"
|
||
readOnly
|
||
/>
|
||
</div>
|
||
<div className="subsection-heading">
|
||
<div>
|
||
<h3>
|
||
字段定义 <span>{entityFields.length}</span>
|
||
</h3>
|
||
<p>字段定义独立于列表、表单和查询配置。</p>
|
||
</div>
|
||
<button className="button outline-button" onClick={onAddField}>
|
||
<Plus size={14} />
|
||
新增字段
|
||
</button>
|
||
</div>
|
||
<div className="field-table">
|
||
<div className="field-head">
|
||
<span>物理列</span>
|
||
<span>字段名称</span>
|
||
<span>控件类型</span>
|
||
<span>可空</span>
|
||
<span>启用</span>
|
||
<span />
|
||
</div>
|
||
{entityFields.map((field) => (
|
||
<div className="field-row" key={field.id}>
|
||
<code>{field.code}</code>
|
||
<input
|
||
value={field.name}
|
||
onChange={(event) =>
|
||
onFieldChange(field.id, { name: event.target.value })
|
||
}
|
||
/>
|
||
<select
|
||
value={field.type}
|
||
onChange={(event) =>
|
||
onFieldChange(field.id, { type: event.target.value })
|
||
}
|
||
>
|
||
{fieldTypes.map((type) => (
|
||
<option key={type}>{type}</option>
|
||
))}
|
||
</select>
|
||
<Toggle
|
||
value={field.nullable}
|
||
onChange={() =>
|
||
onFieldChange(field.id, { nullable: !field.nullable })
|
||
}
|
||
/>
|
||
<Toggle
|
||
value={field.enabled}
|
||
onChange={() =>
|
||
onFieldChange(field.id, { enabled: !field.enabled })
|
||
}
|
||
/>
|
||
<button className="icon-button subtle" title="删除字段">
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="subsection-heading relation-heading">
|
||
<div>
|
||
<h3>
|
||
实体关系 <span>{entityRelations.length}</span>
|
||
</h3>
|
||
<p>主从关系使用 owned;普通外键引用使用 reference。</p>
|
||
</div>
|
||
</div>
|
||
<div className="relation-table">
|
||
<div className="relation-head">
|
||
<span>目标实体</span>
|
||
<span>关系</span>
|
||
<span>来源字段</span>
|
||
<span>目标字段</span>
|
||
<span>所有权</span>
|
||
<span>删除策略</span>
|
||
<span>启用</span>
|
||
</div>
|
||
{entityRelations.map((relation) => (
|
||
<div className="relation-row" key={relation.id}>
|
||
<select
|
||
value={relation.targetEntityId}
|
||
onChange={(event) =>
|
||
onRelationChange(relation.id, {
|
||
targetEntityId: Number(event.target.value),
|
||
})
|
||
}
|
||
>
|
||
{entities
|
||
.filter((item) => item.id !== selectedEntity.id)
|
||
.map((entity) => (
|
||
<option key={entity.id} value={entity.id}>
|
||
{entity.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={relation.relationType}
|
||
onChange={(event) =>
|
||
onRelationChange(relation.id, {
|
||
relationType: event.target
|
||
.value as Relation["relationType"],
|
||
})
|
||
}
|
||
>
|
||
<option value="one-to-one">1:1</option>
|
||
<option value="one-to-many">1:N</option>
|
||
<option value="many-to-one">N:1</option>
|
||
<option value="many-to-many">N:N</option>
|
||
</select>
|
||
<input
|
||
value={relation.foreignField}
|
||
onChange={(event) =>
|
||
onRelationChange(relation.id, {
|
||
foreignField: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
<input
|
||
value={relation.linkField}
|
||
onChange={(event) =>
|
||
onRelationChange(relation.id, {
|
||
linkField: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
<select
|
||
value={relation.ownership}
|
||
onChange={(event) =>
|
||
onRelationChange(relation.id, {
|
||
ownership: event.target
|
||
.value as Relation["ownership"],
|
||
})
|
||
}
|
||
>
|
||
<option value="reference">引用</option>
|
||
<option value="owned">主从</option>
|
||
</select>
|
||
<select
|
||
value={relation.deletePolicy}
|
||
onChange={(event) =>
|
||
onRelationChange(relation.id, {
|
||
deletePolicy: event.target
|
||
.value as Relation["deletePolicy"],
|
||
})
|
||
}
|
||
>
|
||
<option value="restrict">限制</option>
|
||
<option value="set-null">置空</option>
|
||
<option value="cascade">级联</option>
|
||
<option value="archive">归档</option>
|
||
</select>
|
||
<Toggle
|
||
value={relation.enabled}
|
||
onChange={() =>
|
||
onRelationChange(relation.id, {
|
||
enabled: !relation.enabled,
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
function renderBusinessTree(
|
||
domains: Domain[],
|
||
entities: Entity[],
|
||
relations: Relation[],
|
||
parentId: number | null,
|
||
depth: number,
|
||
selectedDomainId: number,
|
||
selectedEntityId: number,
|
||
onSelectDomain: (id: number) => void,
|
||
onSelectEntity: (id: number) => void,
|
||
): ReactNode[] {
|
||
return domains
|
||
.filter((item) => item.parentId === parentId)
|
||
.flatMap((item) => {
|
||
const ownedTargets = new Set(
|
||
relations
|
||
.filter(
|
||
(relation) =>
|
||
relation.ownership === "owned" &&
|
||
entities.find((entity) => entity.id === relation.entityId)
|
||
?.domainId === item.id,
|
||
)
|
||
.map((relation) => relation.targetEntityId),
|
||
);
|
||
const domainEntities = entities.filter(
|
||
(entity) => entity.domainId === item.id && !ownedTargets.has(entity.id),
|
||
);
|
||
const hasChildren =
|
||
domains.some((child) => child.parentId === item.id) ||
|
||
domainEntities.length > 0;
|
||
return [
|
||
<button
|
||
key={`domain-${item.id}`}
|
||
className={`domain-row ${item.id === selectedDomainId && !selectedEntityId ? "selected" : ""}`}
|
||
style={{ paddingLeft: 12 + depth * 18 }}
|
||
onClick={() => onSelectDomain(item.id)}
|
||
>
|
||
<span className="tree-caret">
|
||
{hasChildren ? <ChevronDown size={14} /> : <span />}
|
||
</span>
|
||
<FolderTree size={15} />
|
||
<span>
|
||
{item.name}
|
||
<small>业务域 · {item.code}</small>
|
||
</span>
|
||
<MoreHorizontal className="row-more" size={15} />
|
||
</button>,
|
||
...domainEntities.flatMap((entity) =>
|
||
renderEntityBranch(
|
||
entity,
|
||
entities,
|
||
relations,
|
||
depth + 1,
|
||
selectedEntityId,
|
||
onSelectEntity,
|
||
new Set(),
|
||
),
|
||
),
|
||
...renderBusinessTree(
|
||
domains,
|
||
entities,
|
||
relations,
|
||
item.id,
|
||
depth + 1,
|
||
selectedDomainId,
|
||
selectedEntityId,
|
||
onSelectDomain,
|
||
onSelectEntity,
|
||
),
|
||
];
|
||
});
|
||
}
|
||
function renderEntityBranch(
|
||
entity: Entity,
|
||
entities: Entity[],
|
||
relations: Relation[],
|
||
depth: number,
|
||
selectedEntityId: number,
|
||
onSelectEntity: (id: number) => void,
|
||
visited: Set<number>,
|
||
): ReactNode[] {
|
||
if (visited.has(entity.id)) return [];
|
||
const nextVisited = new Set(visited).add(entity.id);
|
||
const ownedChildren = relations
|
||
.filter(
|
||
(relation) =>
|
||
relation.entityId === entity.id && relation.ownership === "owned",
|
||
)
|
||
.map((relation) =>
|
||
entities.find((item) => item.id === relation.targetEntityId),
|
||
)
|
||
.filter((item): item is Entity => Boolean(item));
|
||
return [
|
||
<button
|
||
key={`entity-${entity.id}`}
|
||
className={`domain-row entity-tree-row ${entity.id === selectedEntityId ? "selected" : ""}`}
|
||
style={{ paddingLeft: 12 + depth * 18 }}
|
||
onClick={() => onSelectEntity(entity.id)}
|
||
>
|
||
<span className="tree-caret">
|
||
{ownedChildren.length ? <ChevronDown size={14} /> : <span />}
|
||
</span>
|
||
<Database size={14} />
|
||
<span>
|
||
{entity.name}
|
||
<small>
|
||
实体 · {entity.code} ·{" "}
|
||
{ownedChildren.length
|
||
? `${ownedChildren.length} 个主从实体`
|
||
: "无主从实体"}
|
||
</small>
|
||
</span>
|
||
</button>,
|
||
...ownedChildren.flatMap((child) =>
|
||
renderEntityBranch(
|
||
child,
|
||
entities,
|
||
relations,
|
||
depth + 1,
|
||
selectedEntityId,
|
||
onSelectEntity,
|
||
nextVisited,
|
||
),
|
||
),
|
||
];
|
||
}
|
||
function MenuPermissionWorkspace({
|
||
menus,
|
||
domains,
|
||
entities,
|
||
roles,
|
||
powers,
|
||
selectedMenu,
|
||
selectedRole,
|
||
tab,
|
||
onTab,
|
||
onSelectMenu,
|
||
onSelectRole,
|
||
onAdd,
|
||
onMenuChange,
|
||
onToggle,
|
||
onPublish,
|
||
}: {
|
||
menus: Menu[];
|
||
domains: Domain[];
|
||
entities: Entity[];
|
||
roles: Role[];
|
||
powers: Power[];
|
||
selectedMenu: Menu;
|
||
selectedRole: Role;
|
||
tab: "basic" | "powers" | "scope";
|
||
onTab: (tab: "basic" | "powers" | "scope") => void;
|
||
onSelectMenu: (id: number) => void;
|
||
onSelectRole: (id: number) => void;
|
||
onAdd: (type: Menu["type"], parentId: number | null) => void;
|
||
onMenuChange: (patch: Partial<Menu>) => void;
|
||
onToggle: (id: number) => void;
|
||
onPublish: () => void;
|
||
}) {
|
||
const menuEntries = menus.filter((item) => item.type !== "directory");
|
||
const menuPowers = powers.filter((item) => item.menuId === selectedMenu.id);
|
||
return (
|
||
<div className="workspace">
|
||
<PageHeader
|
||
eyebrow="02 · ACCESS MODEL"
|
||
title="菜单与权限"
|
||
description="菜单是访问入口,权限、角色和数据范围都在当前菜单工作区内维护。"
|
||
action={
|
||
<div className="button-group">
|
||
<button
|
||
className="button outline-button"
|
||
onClick={() =>
|
||
onAdd(
|
||
"directory",
|
||
selectedMenu.type === "directory"
|
||
? selectedMenu.id
|
||
: selectedMenu.parentId,
|
||
)
|
||
}
|
||
>
|
||
<FolderPlus size={15} />
|
||
新建目录
|
||
</button>
|
||
<button
|
||
className="button primary-button"
|
||
onClick={() =>
|
||
onAdd(
|
||
"data",
|
||
selectedMenu.type === "directory"
|
||
? selectedMenu.id
|
||
: selectedMenu.parentId,
|
||
)
|
||
}
|
||
>
|
||
<FilePlus2 size={15} />
|
||
新建数据菜单
|
||
</button>
|
||
<button className="button outline-button" onClick={onPublish}>
|
||
<ShieldCheck size={15} />
|
||
发布
|
||
</button>
|
||
</div>
|
||
}
|
||
/>
|
||
<div className="menu-permission-layout">
|
||
<section className="surface menu-panel">
|
||
<div className="surface-heading">
|
||
<div>
|
||
<h2>菜单树</h2>
|
||
<p>{menuEntries.length} 个可授权入口</p>
|
||
</div>
|
||
<button
|
||
className="icon-button"
|
||
title="新建页面菜单"
|
||
onClick={() =>
|
||
onAdd(
|
||
"page",
|
||
selectedMenu.type === "directory"
|
||
? selectedMenu.id
|
||
: selectedMenu.parentId,
|
||
)
|
||
}
|
||
>
|
||
<Plus size={16} />
|
||
</button>
|
||
</div>
|
||
<div className="search-box">
|
||
<Search size={14} />
|
||
<input placeholder="搜索菜单编码或名称" />
|
||
</div>
|
||
<div className="menu-tree">
|
||
{renderMenuTree(menus, null, 0, selectedMenu.id, onSelectMenu)}
|
||
</div>
|
||
<div className="tree-note">
|
||
<CircleHelp size={14} />
|
||
<span>目录只组织导航;数据菜单和页面菜单才生成动作权限。</span>
|
||
</div>
|
||
</section>
|
||
<section className="surface menu-detail">
|
||
<DetailTitle
|
||
icon={<MenuIcon size={17} />}
|
||
eyebrow="S_MENU"
|
||
title={selectedMenu.name}
|
||
badge={selectedMenu.enabled ? "启用中" : "已停用"}
|
||
/>
|
||
<div className="tab-bar">
|
||
<button
|
||
className={tab === "basic" ? "active" : ""}
|
||
onClick={() => onTab("basic")}
|
||
>
|
||
基本信息
|
||
</button>
|
||
{selectedMenu.type !== "directory" && (
|
||
<>
|
||
<button
|
||
className={tab === "powers" ? "active" : ""}
|
||
onClick={() => onTab("powers")}
|
||
>
|
||
操作权限
|
||
</button>
|
||
<button
|
||
className={tab === "scope" ? "active" : ""}
|
||
onClick={() => onTab("scope")}
|
||
>
|
||
角色与数据范围
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
{tab === "basic" && (
|
||
<div>
|
||
<div className="form-grid">
|
||
<LabeledInput
|
||
label="菜单编码"
|
||
value={selectedMenu.code}
|
||
onChange={(value) => onMenuChange({ code: value })}
|
||
/>
|
||
<LabeledInput
|
||
label="菜单名称"
|
||
value={selectedMenu.name}
|
||
onChange={(value) => onMenuChange({ name: value })}
|
||
/>
|
||
<label>
|
||
菜单类型
|
||
<select
|
||
value={selectedMenu.type}
|
||
onChange={(event) =>
|
||
onMenuChange({ type: event.target.value as Menu["type"] })
|
||
}
|
||
>
|
||
<option value="directory">directory · 目录</option>
|
||
<option value="data">data · 数据菜单</option>
|
||
<option value="page">page · 页面菜单</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
所属业务域
|
||
<select
|
||
value={selectedMenu.domainId ?? ""}
|
||
onChange={(event) =>
|
||
onMenuChange({
|
||
domainId: Number(event.target.value) || null,
|
||
})
|
||
}
|
||
>
|
||
<option value="">不指定</option>
|
||
{domains.map((domain) => (
|
||
<option key={domain.id} value={domain.id}>
|
||
{domain.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{selectedMenu.type !== "directory" && (
|
||
<LabeledInput
|
||
label="页面路由"
|
||
value={selectedMenu.route}
|
||
onChange={(value) => onMenuChange({ route: value })}
|
||
/>
|
||
)}
|
||
{selectedMenu.type === "data" && (
|
||
<label>
|
||
绑定实体
|
||
<select
|
||
value={selectedMenu.entityId ?? ""}
|
||
onChange={(event) =>
|
||
onMenuChange({
|
||
entityId: Number(event.target.value) || null,
|
||
})
|
||
}
|
||
>
|
||
<option value="">请选择实体</option>
|
||
{entities.map((entity) => (
|
||
<option key={entity.id} value={entity.id}>
|
||
{entity.name} · {entity.code}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
</div>
|
||
<div className="menu-rule">
|
||
<div className="rule-icon">
|
||
<ShieldCheck size={17} />
|
||
</div>
|
||
<div>
|
||
<strong>权限资源自动跟随菜单</strong>
|
||
<p>
|
||
当前菜单保存后,系统会为它生成查看、新增、编辑、删除、导出动作。
|
||
</p>
|
||
</div>
|
||
<span className="status-badge">
|
||
{selectedMenu.type === "directory"
|
||
? "无动作权限"
|
||
: "5 个动作"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{tab === "powers" && (
|
||
<div className="permission-tab">
|
||
<div className="subsection-heading">
|
||
<div>
|
||
<h3>
|
||
动作权限 <span>{menuPowers.length}</span>
|
||
</h3>
|
||
<p>先定义这个菜单允许执行的操作。</p>
|
||
</div>
|
||
</div>
|
||
<div className="action-grid">
|
||
{menuPowers.map((power) => (
|
||
<div className="action-row" key={power.id}>
|
||
<span className="action-symbol">
|
||
<KeyRound size={14} />
|
||
</span>
|
||
<span>
|
||
<strong>{power.name}</strong>
|
||
<small>{power.code}</small>
|
||
</span>
|
||
<Toggle
|
||
value={power.enabled}
|
||
onChange={() => onToggle(power.id)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{tab === "scope" && (
|
||
<div className="permission-tab">
|
||
<div className="subsection-heading">
|
||
<div>
|
||
<h3>角色授权与数据范围</h3>
|
||
<p>权限跟着菜单维护,数据范围跟着角色维护。</p>
|
||
</div>
|
||
</div>
|
||
<div className="role-tabs">
|
||
{roles.map((role) => (
|
||
<button
|
||
key={role.id}
|
||
className={selectedRole.id === role.id ? "active" : ""}
|
||
onClick={() => onSelectRole(role.id)}
|
||
>
|
||
<span className="role-avatar">{role.name.slice(0, 1)}</span>
|
||
<span>
|
||
{role.name}
|
||
<small>{role.members} 人</small>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="role-matrix">
|
||
<div className="matrix-header">
|
||
<span>{selectedRole.name}</span>
|
||
{menuPowers.slice(0, 5).map((power) => (
|
||
<span key={power.id}>{power.name}</span>
|
||
))}
|
||
</div>
|
||
<div className="matrix-row">
|
||
<span>{selectedMenu.name}</span>
|
||
{menuPowers.slice(0, 5).map((power, index) => (
|
||
<button
|
||
key={power.id}
|
||
className={`matrix-check ${power.enabled && index < 3 ? "checked" : ""}`}
|
||
onClick={() => onToggle(power.id)}
|
||
aria-label={`${selectedRole.name}${power.name}权限`}
|
||
>
|
||
<span>{power.enabled && index < 3 ? "✓" : ""}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="scope-grid">
|
||
<label>
|
||
数据范围
|
||
<select>
|
||
<option>本人创建</option>
|
||
<option>本部门</option>
|
||
<option>本公司</option>
|
||
<option>全部数据</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
范围字段
|
||
<select>
|
||
<option>创建人(b_createby)</option>
|
||
<option>所属部门(b_deptid)</option>
|
||
<option>所属公司(b_companyid)</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="role-summary">
|
||
<Users size={15} />
|
||
<span>
|
||
<strong>{selectedRole.name}</strong> 当前可使用{" "}
|
||
{menuPowers.filter((item) => item.enabled).length}{" "}
|
||
个菜单动作;数据范围为“本人创建”。
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
function renderMenuTree(
|
||
menus: Menu[],
|
||
parentId: number | null,
|
||
depth: number,
|
||
selectedId: number,
|
||
onSelect: (id: number) => void,
|
||
): ReactNode[] {
|
||
return menus
|
||
.filter((item) => item.parentId === parentId)
|
||
.flatMap((item) => [
|
||
<button
|
||
key={item.id}
|
||
className={`domain-row menu-row ${item.id === selectedId ? "selected" : ""}`}
|
||
style={{ paddingLeft: 12 + depth * 18 }}
|
||
onClick={() => onSelect(item.id)}
|
||
>
|
||
<span className="tree-caret">
|
||
{menus.some((child) => child.parentId === item.id) ? (
|
||
<ChevronDown size={14} />
|
||
) : (
|
||
<span />
|
||
)}
|
||
</span>
|
||
<span className={`menu-type-icon ${item.type}`}>
|
||
<MenuIcon size={14} />
|
||
</span>
|
||
<span>
|
||
{item.name}
|
||
<small>{item.code}</small>
|
||
</span>
|
||
<span className="row-meta">
|
||
{item.type === "directory"
|
||
? "目录"
|
||
: item.type === "data"
|
||
? "数据"
|
||
: "页面"}
|
||
</span>
|
||
</button>,
|
||
...renderMenuTree(menus, item.id, depth + 1, selectedId, onSelect),
|
||
]);
|
||
}
|
||
|
||
function RoleWorkspace({ roles }: { roles: Role[] }) {
|
||
return (
|
||
<div className="workspace">
|
||
<PageHeader
|
||
eyebrow="03 · PRINCIPALS"
|
||
title="角色与用户"
|
||
description="维护授权主体和人员归属;具体菜单权限与数据范围在菜单与权限工作区完成。"
|
||
action={
|
||
<button className="button primary-button">
|
||
<Plus size={15} />
|
||
新建角色
|
||
</button>
|
||
}
|
||
/>
|
||
<div className="role-admin-grid">
|
||
<section className="surface">
|
||
<div className="surface-heading">
|
||
<div>
|
||
<h2>角色</h2>
|
||
<p>{roles.length} 个授权主体</p>
|
||
</div>
|
||
</div>
|
||
{roles.map((role) => (
|
||
<div className="admin-role-row" key={role.id}>
|
||
<span className="role-avatar">{role.name.slice(0, 1)}</span>
|
||
<div>
|
||
<strong>{role.name}</strong>
|
||
<small>
|
||
{role.code} · {role.members} 人
|
||
</small>
|
||
</div>
|
||
<button className="button outline-button">维护成员</button>
|
||
</div>
|
||
))}
|
||
</section>
|
||
<section className="surface role-explain">
|
||
<div className="surface-heading">
|
||
<div>
|
||
<h2>授权方式</h2>
|
||
<p>角色管理和菜单授权分开定位,维护时仍然保持连贯。</p>
|
||
</div>
|
||
</div>
|
||
<div className="model-note">
|
||
<ShieldCheck size={15} />
|
||
<span>
|
||
在“菜单与权限”中选中菜单,再选择角色并勾选动作;数据范围也按角色在同一处设置。
|
||
</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|