Files
workspace/code/test-demo/src/App.tsx
T
2026-08-20 22:38:37 +08:00

1941 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}