20260820173056
This commit is contained in:
1 parent
8e75c7989a
commit
68ac5db038
24 files changed
+975
-6035
No files matched your search
+251
-190
@@ -1,202 +1,263 @@
|
||||
// FMS 新系统元数据配置 Demo —— 主布局
|
||||
// 主视图:统一元数据树(菜单 → 模块 → 实体 → 字段/分组/编码)全链路维护
|
||||
// 辅助 Tab:权限(RBAC)
|
||||
import { useMemo, useState } from "react";
|
||||
import type {
|
||||
Autocode,
|
||||
Entity,
|
||||
Field,
|
||||
FieldGroup,
|
||||
I18n,
|
||||
Menu,
|
||||
Module,
|
||||
ModuleDependency,
|
||||
Power,
|
||||
Role,
|
||||
RolePower,
|
||||
UserRole,
|
||||
} from "./types";
|
||||
import { useMemo, useState, type ComponentType, type ReactNode } from "react";
|
||||
import {
|
||||
initialAutocodes,
|
||||
initialDependencies,
|
||||
initialEntities,
|
||||
initialFieldGroups,
|
||||
initialFields,
|
||||
initialI18ns,
|
||||
initialMenus,
|
||||
initialModules,
|
||||
initialPowers,
|
||||
initialRolePowers,
|
||||
initialRoles,
|
||||
initialUserRoles,
|
||||
} from "./data/mock";
|
||||
import MetaTree, { type TreeSelection } from "./components/MetaTree";
|
||||
import NodeEditor from "./components/NodeEditor";
|
||||
import PermissionPanel from "./components/PermissionPanel";
|
||||
Braces,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
Database,
|
||||
FileCode2,
|
||||
FolderTree,
|
||||
Languages,
|
||||
List,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Search,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Table2,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import "./index.css";
|
||||
|
||||
type NodeType = "directory" | "page" | "data";
|
||||
type ModuleNode = {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
type: NodeType;
|
||||
parent: number | null;
|
||||
route?: string;
|
||||
component?: string;
|
||||
view?: string;
|
||||
save?: string;
|
||||
key?: string;
|
||||
dataNodeId?: number;
|
||||
enabled: boolean;
|
||||
showInMenu: boolean;
|
||||
};
|
||||
|
||||
type Field = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const initialNodes: ModuleNode[] = [
|
||||
{ id: 1, name: "海运业务", code: "ocean", type: "directory", parent: null, enabled: true, showInMenu: true },
|
||||
{ id: 2, name: "订舱管理", code: "booking", type: "directory", parent: 1, enabled: true, showInMenu: true },
|
||||
{ id: 3, name: "订舱列表", code: "booking_list", type: "page", parent: 2, route: "/ocean/booking", component: "ocean/booking/index.vue", dataNodeId: 4, enabled: true, showInMenu: true },
|
||||
{ id: 4, name: "订舱数据", code: "booking_data", type: "data", parent: 2, view: "v_ocean_booking_list", save: "ocean_booking", key: "b_id", enabled: true, showInMenu: false },
|
||||
{ id: 5, name: "订舱审核", code: "booking_audit", type: "page", parent: 2, route: "/ocean/booking/audit", component: "ocean/booking/audit.vue", dataNodeId: 4, enabled: true, showInMenu: true },
|
||||
{ id: 6, name: "集装箱明细", code: "booking_container", type: "data", parent: 2, view: "ocean_booking_container", save: "ocean_booking_container", key: "b_id", enabled: true, showInMenu: false },
|
||||
{ id: 7, name: "航线报价", code: "rate_query", type: "page", parent: 1, route: "/ocean/rate/query", component: "ocean/rate/index.vue", enabled: true, showInMenu: true },
|
||||
{ id: 8, name: "基础资料", code: "master_data", type: "directory", parent: null, enabled: true, showInMenu: true },
|
||||
{ id: 9, name: "客户档案", code: "customer", type: "page", parent: 8, route: "/base/customer", component: "base/customer/index.vue", dataNodeId: 10, enabled: true, showInMenu: true },
|
||||
{ id: 10, name: "客户数据", code: "customer_data", type: "data", parent: 8, view: "base_customer", save: "base_customer", key: "b_id", enabled: true, showInMenu: false },
|
||||
];
|
||||
|
||||
const initialFields: Field[] = [
|
||||
{ id: 1, code: "b_ywno", name: "业务单号", type: "input", defaultValue: "自动编码", enabled: true },
|
||||
{ id: 2, code: "b_customer_id", name: "委托客户", type: "select", defaultValue: "", enabled: true },
|
||||
{ id: 3, code: "b_ywtype", name: "业务类型", type: "select", defaultValue: "整箱出口", enabled: true },
|
||||
{ id: 4, code: "b_sales_id", name: "业务员", type: "select", defaultValue: "当前用户", enabled: true },
|
||||
{ id: 5, code: "b_loadport_id", name: "起运港", type: "select", defaultValue: "", enabled: true },
|
||||
{ id: 6, code: "b_disport_id", name: "目的港", type: "select", defaultValue: "", enabled: true },
|
||||
{ id: 7, code: "b_etd", name: "预计开船日", type: "date", defaultValue: "", enabled: true },
|
||||
{ id: 8, code: "b_eta", name: "预计到港日", type: "date", defaultValue: "", enabled: true },
|
||||
{ id: 9, code: "b_freight", name: "运费总额", type: "money", defaultValue: "费用汇总", enabled: true },
|
||||
{ id: 10, code: "b_state", name: "单据状态", type: "select", defaultValue: "草稿", enabled: true },
|
||||
];
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
basic: "基本信息",
|
||||
fields: "字段定义",
|
||||
list: "列表配置",
|
||||
edit: "编辑配置",
|
||||
query: "查询配置",
|
||||
autocode: "自动编码",
|
||||
power: "权限配置",
|
||||
i18n: "多语言",
|
||||
};
|
||||
|
||||
const typeLabels: Record<NodeType, string> = { directory: "目录", page: "页面", data: "数据" };
|
||||
const fieldTypes = [
|
||||
["input", "输入框"], ["textarea", "文本域"], ["number", "数字"], ["money", "金额"],
|
||||
["date", "日期"], ["datetime", "日期时间"], ["select", "下拉选择"],
|
||||
];
|
||||
|
||||
const sectionIcons: Record<string, ComponentType<{ size?: number }>> = {
|
||||
basic: Settings2,
|
||||
fields: Table2,
|
||||
list: List,
|
||||
edit: Workflow,
|
||||
query: Search,
|
||||
autocode: Braces,
|
||||
power: ShieldCheck,
|
||||
i18n: Languages,
|
||||
};
|
||||
|
||||
function Toggle({ value, onChange }: { value: boolean; onChange: () => void }) {
|
||||
return <button className={`toggle ${value ? "on" : ""}`} onClick={onChange} type="button" aria-label={value ? "关闭" : "开启"}><i /></button>;
|
||||
}
|
||||
|
||||
function PanelHeader({ title, desc, action }: { title: string; desc: string; action?: ReactNode }) {
|
||||
return <div className="panel-header"><div><h2>{title}</h2><p>{desc}</p></div>{action}</div>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState<"tree" | "permission">("tree");
|
||||
const [selection, setSelection] = useState<TreeSelection | null>(null);
|
||||
const [modules, setModules] = useState<Module[]>(initialModules);
|
||||
const [dependencies, setDependencies] = useState<ModuleDependency[]>(initialDependencies);
|
||||
const [entities, setEntities] = useState<Entity[]>(initialEntities);
|
||||
const [fields, setFields] = useState<Field[]>(initialFields);
|
||||
const [groups, setGroups] = useState<FieldGroup[]>(initialFieldGroups);
|
||||
const [menus, setMenus] = useState<Menu[]>(initialMenus);
|
||||
const [autocodes, setAutocodes] = useState<Autocode[]>(initialAutocodes);
|
||||
const [i18ns, setI18ns] = useState<I18n[]>(initialI18ns);
|
||||
const [roles, setRoles] = useState<Role[]>(initialRoles);
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>(initialUserRoles);
|
||||
const [powers, setPowers] = useState<Power[]>(initialPowers);
|
||||
const [rolePowers, setRolePowers] = useState<RolePower[]>(initialRolePowers);
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [nodes, setNodes] = useState(initialNodes);
|
||||
const [fields, setFields] = useState(initialFields);
|
||||
const [selectedId, setSelectedId] = useState(3);
|
||||
const [section, setSection] = useState("basic");
|
||||
const [treeSearch, setTreeSearch] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const stats = useMemo(
|
||||
() => [
|
||||
{ label: "模块", value: modules.length, table: "s_module" },
|
||||
{ label: "实体", value: entities.length, table: "s_entity" },
|
||||
{ label: "字段", value: fields.length, table: "s_field" },
|
||||
{ label: "菜单", value: menus.length, table: "s_menu" },
|
||||
{ label: "编码", value: autocodes.length, table: "s_autocode" },
|
||||
{ label: "角色", value: roles.length, table: "s_role" },
|
||||
{ label: "权限", value: powers.length, table: "s_power" },
|
||||
{ label: "多语言", value: i18ns.length, table: "s_i18n" },
|
||||
],
|
||||
[modules, entities, fields, menus, autocodes, roles, powers, i18ns],
|
||||
);
|
||||
const selected = nodes.find((node) => node.id === selectedId) ?? nodes[0];
|
||||
const filteredNodes = useMemo(() => {
|
||||
const keyword = treeSearch.trim().toLowerCase();
|
||||
if (!keyword) return nodes;
|
||||
const matches = new Set(nodes.filter((node) => `${node.name}${node.code}`.toLowerCase().includes(keyword)).map((node) => node.id));
|
||||
nodes.forEach((node) => { if (matches.has(node.id) && node.parent) matches.add(node.parent); });
|
||||
return nodes.filter((node) => matches.has(node.id));
|
||||
}, [nodes, treeSearch]);
|
||||
|
||||
const resetAll = () => {
|
||||
setModules(initialModules);
|
||||
setDependencies(initialDependencies);
|
||||
setEntities(initialEntities);
|
||||
setFields(initialFields);
|
||||
setGroups(initialFieldGroups);
|
||||
setMenus(initialMenus);
|
||||
setAutocodes(initialAutocodes);
|
||||
setI18ns(initialI18ns);
|
||||
setRoles(initialRoles);
|
||||
setUserRoles(initialUserRoles);
|
||||
setPowers(initialPowers);
|
||||
setRolePowers(initialRolePowers);
|
||||
setSelection(null);
|
||||
setResetKey((k) => k + 1);
|
||||
const sections = selected.type === "data"
|
||||
? ["basic", "fields", "list", "edit", "query", "autocode", "i18n"]
|
||||
: selected.type === "page" ? ["basic", "power", "i18n"] : ["basic", "i18n"];
|
||||
|
||||
const selectNode = (node: ModuleNode) => { setSelectedId(node.id); setSection("basic"); };
|
||||
const updateSelected = (patch: Partial<ModuleNode>) => {
|
||||
setNodes((items) => items.map((node) => node.id === selected.id ? { ...node, ...patch } : node));
|
||||
setDirty(true);
|
||||
};
|
||||
const updateField = (id: number, patch: Partial<Field>) => {
|
||||
setFields((items) => items.map((field) => field.id === id ? { ...field, ...patch } : field));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 text-slate-800">
|
||||
{/* 顶栏 */}
|
||||
<header className="sticky top-0 z-10 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-800">FMS 新系统 · 元数据配置 Demo</h1>
|
||||
<p className="text-xs text-slate-400">
|
||||
统一树维护:菜单 → 模块 → 数据(实体) → 编码 · 字段/分组在实体面板内维护 · 对应 17 张表 · 命名风格 s_表 / b_字段
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="rounded bg-slate-100 px-2 py-1 text-center" title={s.table}>
|
||||
<div className="text-sm font-semibold text-slate-700">{s.value}</div>
|
||||
<div className="text-[10px] text-slate-400">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={resetAll}
|
||||
className="rounded border border-slate-300 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
重置演示数据
|
||||
</button>
|
||||
</div>
|
||||
return <div className="app">
|
||||
<header className="topbar">
|
||||
<div className="brand"><span className="logo"><FolderTree size={17} /></span><div><b>FMS 模块管理</b><small>菜单、页面与数据配置</small></div></div>
|
||||
<div className="top-module"><span>当前系统</span><b>国际货运管理系统</b></div>
|
||||
<div className="top-actions"><span className={`save-state ${dirty ? "dirty" : ""}`}><i />{dirty ? "有未保存修改" : "所有修改已保存"}</span><button className="save" disabled={!dirty} onClick={() => setDirty(false)}>保存修改</button><span className="avatar">林</span></div>
|
||||
</header>
|
||||
|
||||
<div className="layout">
|
||||
<aside className="tree-panel">
|
||||
<div className="tree-title">
|
||||
<div><b>模块与菜单</b><small>按实际菜单层级组织</small></div>
|
||||
<div className="add-wrap"><button className="icon-btn" title="添加节点" onClick={() => setAddOpen(!addOpen)}><Plus size={16} /></button>{addOpen && <div className="add-menu"><button><FolderTree size={14} />新建目录</button><button><FileCode2 size={14} />新建页面</button><button><Database size={14} />新建数据</button></div>}</div>
|
||||
</div>
|
||||
{/* 视图切换 */}
|
||||
<nav className="mx-auto flex max-w-7xl gap-1 px-4 pb-2">
|
||||
<button
|
||||
onClick={() => setView("tree")}
|
||||
className={`rounded-t px-4 py-1.5 text-sm transition-colors ${
|
||||
view === "tree"
|
||||
? "border-b-2 border-sky-500 bg-sky-50 font-medium text-sky-700"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
元数据维护(统一树)
|
||||
<span className="ml-1.5 text-[10px] text-slate-400">菜单 → 模块 → 数据 → 编码</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("permission")}
|
||||
className={`rounded-t px-4 py-1.5 text-sm transition-colors ${
|
||||
view === "permission"
|
||||
? "border-b-2 border-sky-500 bg-sky-50 font-medium text-sky-700"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
权限
|
||||
<span className="ml-1.5 text-[10px] text-slate-400">s_role / s_user_role / s_power / s_role_power</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<label className="tree-search"><Search size={14} /><input value={treeSearch} onChange={(event) => setTreeSearch(event.target.value)} placeholder="搜索菜单、页面或编码" />{treeSearch && <button onClick={() => setTreeSearch("")}><X size={13} /></button>}</label>
|
||||
<ModuleTree nodes={filteredNodes} selectedId={selectedId} onSelect={selectNode} />
|
||||
<div className="tree-legend"><span><i className="dot directory" />目录</span><span><i className="dot page" />页面</span><span><i className="dot data" />数据</span></div>
|
||||
<div className="tree-help"><CircleHelp size={14} /><span>页面由开发人员编写;这里维护菜单、路由以及页面所使用的数据配置。</span></div>
|
||||
</aside>
|
||||
|
||||
{/* 内容区 */}
|
||||
<main key={resetKey} className="mx-auto max-w-7xl px-4 py-4">
|
||||
{view === "tree" ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
||||
<MetaTree
|
||||
modules={modules}
|
||||
entities={entities}
|
||||
menus={menus}
|
||||
autocodes={autocodes}
|
||||
selection={selection}
|
||||
onSelect={setSelection}
|
||||
/>
|
||||
<div>
|
||||
{selection ? (
|
||||
<NodeEditor
|
||||
key={`${selection.type}-${selection.id}`}
|
||||
selection={selection}
|
||||
modules={modules}
|
||||
dependencies={dependencies}
|
||||
entities={entities}
|
||||
fields={fields}
|
||||
groups={groups}
|
||||
menus={menus}
|
||||
autocodes={autocodes}
|
||||
onChangeModules={setModules}
|
||||
onChangeDependencies={setDependencies}
|
||||
onChangeEntities={setEntities}
|
||||
onChangeFields={setFields}
|
||||
onChangeGroups={setGroups}
|
||||
onChangeMenus={setMenus}
|
||||
onChangeAutocodes={setAutocodes}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-10 text-center text-sm text-slate-400">
|
||||
请在左侧树中选择一个节点开始配置
|
||||
<div className="mt-2 text-xs">
|
||||
顺着导航维护:菜单 → 模块 → 数据(实体) → 编码 · 字段/分组在实体面板内维护
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PermissionPanel
|
||||
roles={roles}
|
||||
userRoles={userRoles}
|
||||
powers={powers}
|
||||
rolePowers={rolePowers}
|
||||
menus={menus}
|
||||
onChangeRoles={setRoles}
|
||||
onChangeUserRoles={setUserRoles}
|
||||
onChangePowers={setPowers}
|
||||
onChangeRolePowers={setRolePowers}
|
||||
/>
|
||||
)}
|
||||
<main className="detail">
|
||||
<div className="detail-head">
|
||||
<div><div className="crumb">{getPath(nodes, selected).join(" / ")}</div><div className="title-line"><h1>{selected.name}</h1><span className={`node-badge ${selected.type}`}>{typeLabels[selected.type]}</span><code>{selected.code}</code></div></div>
|
||||
<div className="head-actions"><label>启用 <Toggle value={selected.enabled} onChange={() => updateSelected({ enabled: !selected.enabled })} /></label>{selected.type !== "data" && <label>显示菜单 <Toggle value={selected.showInMenu} onChange={() => updateSelected({ showInMenu: !selected.showInMenu })} /></label>}<button className="primary" disabled={!dirty} onClick={() => setDirty(false)}>保存</button><button className="icon-btn more-button" title="更多操作"><MoreHorizontal size={16} /></button></div>
|
||||
</div>
|
||||
|
||||
<div className="detail-body">
|
||||
<nav className="section-nav">{sections.map((key) => { const SectionIcon = sectionIcons[key]; return <button key={key} className={section === key ? "active" : ""} onClick={() => setSection(key)}><SectionIcon size={15} />{labels[key]}</button>; })}</nav>
|
||||
<section className="content">
|
||||
{section === "basic" && <BasicPanel node={selected} nodes={nodes} onChange={updateSelected} onSelectNode={selectNode} />}
|
||||
{section === "fields" && <FieldsPanel fields={fields} update={updateField} />}
|
||||
{section === "list" && <ListPanel fields={fields} />}
|
||||
{section === "edit" && <EditPanel fields={fields} />}
|
||||
{section === "query" && <QueryPanel fields={fields} />}
|
||||
{section === "autocode" && <AutoCodePanel />}
|
||||
{section === "power" && <PowerPanel />}
|
||||
{section === "i18n" && <I18nPanel node={selected} fields={fields} />}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="mx-auto max-w-7xl px-4 pb-6 text-center text-xs text-slate-400">
|
||||
演示环境:仅前端内存状态,改动不落库。生产环境由服务层校验后触发 Schema Compiler 同步 DDL。
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
</div>;
|
||||
}
|
||||
|
||||
function getPath(nodes: ModuleNode[], selected: ModuleNode) {
|
||||
const path = [selected.name];
|
||||
let parent = nodes.find((node) => node.id === selected.parent);
|
||||
while (parent) { path.unshift(parent.name); parent = nodes.find((node) => node.id === parent?.parent); }
|
||||
return path;
|
||||
}
|
||||
|
||||
function ModuleTree({ nodes, selectedId, onSelect }: { nodes: ModuleNode[]; selectedId: number; onSelect: (node: ModuleNode) => void }) {
|
||||
const children = (parent: number | null) => nodes.filter((node) => node.parent === parent);
|
||||
const render = (parent: number | null, depth = 0): ReactNode[] => children(parent).flatMap((node) => [
|
||||
<button key={node.id} className={`tree-row ${selectedId === node.id ? "selected" : ""}`} style={{ paddingLeft: 10 + depth * 17 }} onClick={() => onSelect(node)}>
|
||||
<span className={`tree-icon ${node.type}`}>{node.type === "directory" ? <ChevronDown size={14} /> : node.type === "page" ? <FileCode2 size={14} /> : <Database size={14} />}</span>
|
||||
<span className="tree-name">{node.name}<small>{node.code}</small></span>
|
||||
<span className="tree-type">{typeLabels[node.type]}</span><MoreHorizontal className="row-more" size={14} />
|
||||
</button>,
|
||||
...render(node.id, depth + 1),
|
||||
]);
|
||||
return <div className="module-tree">{render(null)}</div>;
|
||||
}
|
||||
|
||||
function BasicPanel({ node, nodes, onChange, onSelectNode }: { node: ModuleNode; nodes: ModuleNode[]; onChange: (patch: Partial<ModuleNode>) => void; onSelectNode: (node: ModuleNode) => void }) {
|
||||
const dataNodes = nodes.filter((item) => item.type === "data");
|
||||
const relatedData = dataNodes.find((item) => item.id === node.dataNodeId);
|
||||
return <div className="panel">
|
||||
<PanelHeader title="基本信息" desc="当前节点最常用、最需要确认的配置。" />
|
||||
<div className="form-grid">
|
||||
<label>节点名称<input value={node.name} onChange={(event) => onChange({ name: event.target.value })} /></label>
|
||||
<label>节点编码<input value={node.code} onChange={(event) => onChange({ code: event.target.value })} /></label>
|
||||
<label>节点类型<select value={node.type} disabled><option value="directory">目录</option><option value="page">页面</option><option value="data">数据</option></select></label>
|
||||
<label>上级节点<input value={node.parent ? getPath(nodes, nodes.find((item) => item.id === node.parent)!).join(" / ") : "顶级目录"} readOnly /></label>
|
||||
{node.type === "page" && <>
|
||||
<label>页面路由<input value={node.route ?? ""} onChange={(event) => onChange({ route: event.target.value })} placeholder="/ocean/booking" /></label>
|
||||
<label>前端组件<input value={node.component ?? ""} onChange={(event) => onChange({ component: event.target.value })} placeholder="ocean/booking/index.vue" /></label>
|
||||
<label className="related-field">关联数据节点<select value={node.dataNodeId ?? ""} onChange={(event) => onChange({ dataNodeId: Number(event.target.value) || undefined })}><option value="">不关联</option>{dataNodes.map((data) => <option key={data.id} value={data.id}>{data.name} ({data.code})</option>)}</select>{relatedData && <button className="link-button" onClick={() => onSelectNode(relatedData)}>查看数据配置</button>}</label>
|
||||
<label>菜单图标<select defaultValue="clipboard-list"><option value="clipboard-list">订舱单</option><option value="ship">海运</option><option value="search">查询</option></select></label>
|
||||
</>}
|
||||
{node.type === "data" && <>
|
||||
<label>查询视图<input value={node.view ?? ""} onChange={(event) => onChange({ view: event.target.value })} /></label>
|
||||
<label>保存表<input value={node.save ?? ""} onChange={(event) => onChange({ save: event.target.value })} /></label>
|
||||
<label>主键字段<input value={node.key ?? ""} onChange={(event) => onChange({ key: event.target.value })} /></label>
|
||||
<label>默认排序<input defaultValue="b_inputdatetime desc" /></label>
|
||||
</>}
|
||||
</div>
|
||||
<div className="info-strip"><CircleHelp size={14} /><span>{node.type === "page" ? "这里只登记已开发页面的入口与关联数据,不会生成或改写页面代码。" : node.type === "data" ? "数据节点集中维护字段、列表、表单和查询规则,可被一个或多个页面使用。" : "目录与用户实际看到的菜单层级保持一致。"}</span></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FieldsPanel({ fields, update }: { fields: Field[]; update: (id: number, patch: Partial<Field>) => void }) {
|
||||
return <div className="panel"><PanelHeader title="字段定义" desc="从查询视图同步字段后,维护业务名称与控件类型。" action={<button className="outline">同步字段</button>} /><div className="data-table"><div className="table-head fields"><span>字段名</span><span>显示名称</span><span>控件类型</span><span>默认值</span><span>启用</span></div>{fields.map((field) => <div className="table-row fields" key={field.id}><code>{field.code}</code><input value={field.name} onChange={(event) => update(field.id, { name: event.target.value })} /><select value={field.type} onChange={(event) => update(field.id, { type: event.target.value })}>{fieldTypes.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select><input value={field.defaultValue} onChange={(event) => update(field.id, { defaultValue: event.target.value })} /><Toggle value={field.enabled} onChange={() => update(field.id, { enabled: !field.enabled })} /></div>)}</div></div>;
|
||||
}
|
||||
|
||||
function ListPanel({ fields }: { fields: Field[] }) {
|
||||
return <ConfigTable title="列表配置" desc="决定已开发表格默认显示的列、顺序和宽度。" heads={["字段", "显示名称", "显示", "固定", "宽度"]}>{fields.slice(0, 8).map((field, index) => <div className="table-row config" key={field.id}><code>{field.code}</code><span>{field.name}</span><Toggle value={index < 7} onChange={() => undefined} /><select defaultValue={index === 0 ? "left" : "none"}><option value="none">不固定</option><option value="left">左侧</option><option value="right">右侧</option></select><input defaultValue={index === 0 ? "150" : "120"} /></div>)}</ConfigTable>;
|
||||
}
|
||||
|
||||
function EditPanel({ fields }: { fields: Field[] }) {
|
||||
return <ConfigTable title="编辑配置" desc="决定已开发表单中的字段规则与布局分组。" heads={["字段", "显示名称", "必填", "只读", "表单分组"]}>{fields.slice(0, 8).map((field, index) => <div className="table-row config" key={field.id}><code>{field.code}</code><span>{field.name}</span><Toggle value={[0, 1, 2, 4, 5].includes(index)} onChange={() => undefined} /><Toggle value={index === 0} onChange={() => undefined} /><select defaultValue={index > 5 ? "运输信息" : "基本信息"}><option>基本信息</option><option>运输信息</option><option>费用信息</option></select></div>)}</ConfigTable>;
|
||||
}
|
||||
|
||||
function QueryPanel({ fields }: { fields: Field[] }) {
|
||||
return <ConfigTable title="查询配置" desc="售后人员可直接增加条件或调整查询方式。" action={<button className="primary"><Plus size={13} />新增条件</button>} heads={["查询字段", "查询组件", "操作符", "默认值", "启用"]}>{fields.filter((field) => field.code !== "b_freight").slice(0, 7).map((field, index) => <div className="table-row config" key={field.id}><span><b>{field.name}</b><code>{field.code}</code></span><select defaultValue={field.type === "date" ? "日期范围" : field.type === "select" ? "下拉选择" : "输入框"}><option>输入框</option><option>下拉选择</option><option>日期范围</option></select><select defaultValue={field.type === "date" ? "范围" : index === 0 ? "包含" : "等于"}><option>等于</option><option>包含</option><option>范围</option></select><input placeholder="无" /><Toggle value onChange={() => undefined} /></div>)}</ConfigTable>;
|
||||
}
|
||||
|
||||
function ConfigTable({ title, desc, action, heads, children }: { title: string; desc: string; action?: ReactNode; heads: string[]; children: ReactNode }) {
|
||||
return <div className="panel"><PanelHeader title={title} desc={desc} action={action} /><div className="data-table"><div className="table-head config">{heads.map((head) => <span key={head}>{head}</span>)}</div>{children}</div></div>;
|
||||
}
|
||||
|
||||
function AutoCodePanel() {
|
||||
return <div className="panel"><PanelHeader title="自动编码" desc="配置业务单号等字段的生成规则。" /><div className="rule-summary"><div><span>目标字段</span><b>b_ywno · 业务单号</b></div><div><span>示例结果</span><code>NBSE2608200001</code></div><Toggle value onChange={() => undefined} /></div><div className="rule-builder"><div className="rule-step"><i>1</i><select defaultValue="固定文本"><option>固定文本</option></select><input defaultValue="NBSE" /><button><MoreHorizontal size={15} /></button></div><div className="rule-step"><i>2</i><select defaultValue="当前日期"><option>当前日期</option></select><select defaultValue="yyMMdd"><option>yyMMdd</option><option>yyyyMMdd</option></select><button><MoreHorizontal size={15} /></button></div><div className="rule-step"><i>3</i><select defaultValue="流水号"><option>流水号</option></select><div className="inline-inputs"><input defaultValue="4" /><span>位,每日重置</span></div><button><MoreHorizontal size={15} /></button></div><button className="add-rule"><Plus size={14} />添加规则段</button></div></div>;
|
||||
}
|
||||
|
||||
function PowerPanel() {
|
||||
const powers = [["查看订舱", "booking.view"], ["新增订舱", "booking.create"], ["编辑订舱", "booking.update"], ["审核订舱", "booking.audit"], ["导出订舱", "booking.export"]];
|
||||
return <div className="panel"><PanelHeader title="权限配置" desc="维护页面已有操作对应的权限标识。" action={<button className="primary"><Plus size={13} />新增权限</button>} /><div className="power-list">{powers.map(([name, code], index) => <div key={code}><ShieldCheck size={17} /><span><b>{name}</b><small>{code}</small></span><Toggle value={index !== 3} onChange={() => undefined} /><button className="more"><MoreHorizontal size={16} /></button></div>)}</div></div>;
|
||||
}
|
||||
|
||||
function I18nPanel({ node, fields }: { node: ModuleNode; fields: Field[] }) {
|
||||
return <div className="panel"><PanelHeader title="多语言" desc="维护菜单、节点和字段名称,不涉及页面代码。" action={<button className="outline">批量翻译</button>} /><div className="i18n-table"><div className="table-head i18n"><span>资源键</span><span>简体中文</span><span>English</span><span>日本語</span></div><div className="table-row i18n"><code>module.{node.code}</code><input defaultValue={node.name} /><input defaultValue="Ocean Booking" /><input defaultValue="海上予約" /></div>{fields.slice(0, 5).map((field) => <div className="table-row i18n" key={field.id}><code>field.{node.code}.{field.code}</code><input defaultValue={field.name} /><input defaultValue="" placeholder="待填写" /><input defaultValue="" placeholder="待填写" /></div>)}</div></div>;
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
// 自动编码 s_autocode + 多语言 s_i18n 配置
|
||||
import { useState } from "react";
|
||||
import type { Autocode, Entity, I18n } from "../types";
|
||||
import { Badge, Card, EmptyRow, Field, Input, Select, Td, Th } from "./ui";
|
||||
|
||||
interface Props {
|
||||
autocodes: Autocode[];
|
||||
i18ns: I18n[];
|
||||
entities: Entity[];
|
||||
onChangeAutocodes: (a: Autocode[]) => void;
|
||||
onChangeI18ns: (i: I18n[]) => void;
|
||||
}
|
||||
|
||||
const RESET_TYPES = [
|
||||
{ v: "none", label: "none 不重置" },
|
||||
{ v: "day", label: "day 按天" },
|
||||
{ v: "month", label: "month 按月" },
|
||||
{ v: "year", label: "year 按年" },
|
||||
] as const;
|
||||
|
||||
export default function AutoCodePanel({ autocodes, i18ns, entities, onChangeAutocodes, onChangeI18ns }: Props) {
|
||||
const [i18nFilter, setI18nFilter] = useState<string>("all");
|
||||
|
||||
const addAutocode = () => {
|
||||
const id = Math.max(0, ...autocodes.map((a) => a.b_id)) + 1;
|
||||
onChangeAutocodes([
|
||||
...autocodes,
|
||||
{
|
||||
b_id: id,
|
||||
b_entity_id: entities[0]?.b_id ?? 0,
|
||||
b_field: "b_ywno",
|
||||
b_prefix: "BK",
|
||||
b_dateformat: "yyyyMM",
|
||||
b_separator: "-",
|
||||
b_seqwidth: 5,
|
||||
b_resettype: "month",
|
||||
b_startvalue: 1,
|
||||
b_currentperiod: null,
|
||||
b_currentvalue: 0,
|
||||
b_canuse: 1,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateAutocode = (aid: number, patch: Partial<Autocode>) => {
|
||||
onChangeAutocodes(autocodes.map((a) => (a.b_id === aid ? { ...a, ...patch } : a)));
|
||||
};
|
||||
|
||||
const removeAutocode = (aid: number) => onChangeAutocodes(autocodes.filter((a) => a.b_id !== aid));
|
||||
|
||||
const addI18n = () => {
|
||||
const id = Math.max(0, ...i18ns.map((i) => i.b_id)) + 1;
|
||||
onChangeI18ns([...i18ns, { b_id: id, b_key: "new.key", b_locale: "zh-CN", b_value: "", b_i18ntype: "module", b_canuse: 1 }]);
|
||||
};
|
||||
|
||||
const updateI18n = (iid: number, patch: Partial<I18n>) => {
|
||||
onChangeI18ns(i18ns.map((i) => (i.b_id === iid ? { ...i, ...patch } : i)));
|
||||
};
|
||||
|
||||
const removeI18n = (iid: number) => onChangeI18ns(i18ns.filter((i) => i.b_id !== iid));
|
||||
|
||||
const filteredI18ns = i18nFilter === "all" ? i18ns : i18ns.filter((i) => i.b_i18ntype === i18nFilter);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* 自动编码 */}
|
||||
<Card
|
||||
title="自动编码 s_autocode"
|
||||
extra={
|
||||
<button onClick={addAutocode} className="rounded bg-sky-500 px-2 py-1 text-xs text-white hover:bg-sky-600">
|
||||
+ 新增规则
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>实体</Th>
|
||||
<Th>目标字段</Th>
|
||||
<Th>规则</Th>
|
||||
<Th>当前流水</Th>
|
||||
<Th>操作</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{autocodes.length === 0 && <EmptyRow colSpan={5} text="暂无自动编码规则" />}
|
||||
{autocodes.map((a) => (
|
||||
<tr key={a.b_id}>
|
||||
<Td>
|
||||
<Select value={a.b_entity_id} onChange={(e) => updateAutocode(a.b_id, { b_entity_id: Number(e.target.value) })}>
|
||||
{entities.map((e) => (
|
||||
<option key={e.b_id} value={e.b_id}>
|
||||
{e.b_name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Td>
|
||||
<Td className="font-mono text-xs">{a.b_field}</Td>
|
||||
<Td className="text-xs">
|
||||
<span className="font-mono text-sky-600">
|
||||
{a.b_prefix}
|
||||
{a.b_separator}
|
||||
{a.b_dateformat}
|
||||
{a.b_separator}
|
||||
{"0".repeat(a.b_seqwidth)}
|
||||
</span>
|
||||
<span className="ml-1 text-slate-400">(重置:{a.b_resettype})</span>
|
||||
</Td>
|
||||
<Td className="text-xs">
|
||||
{a.b_currentperiod ?? "-"} / {a.b_currentvalue}
|
||||
</Td>
|
||||
<Td>
|
||||
<button onClick={() => removeAutocode(a.b_id)} className="text-xs text-rose-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{autocodes.length > 0 && (
|
||||
<div className="mt-3 rounded bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
取号使用<b>原子自增</b>(update ... set b_currentvalue = b_currentvalue + 1 output inserted.b_currentvalue),
|
||||
与业务保存同一事务,避免并发重号。
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 自动编码规则明细(选中第一项展开编辑) */}
|
||||
{autocodes[0] && (
|
||||
<Card title={`规则明细(${autocodes[0].b_field})`}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="b_prefix 前缀">
|
||||
<Input value={autocodes[0].b_prefix} onChange={(e) => updateAutocode(autocodes[0].b_id, { b_prefix: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_dateformat 日期格式">
|
||||
<Input
|
||||
value={autocodes[0].b_dateformat}
|
||||
onChange={(e) => updateAutocode(autocodes[0].b_id, { b_dateformat: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_separator 分隔符">
|
||||
<Input
|
||||
value={autocodes[0].b_separator}
|
||||
onChange={(e) => updateAutocode(autocodes[0].b_id, { b_separator: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_seqwidth 流水位宽">
|
||||
<Input
|
||||
type="number"
|
||||
value={autocodes[0].b_seqwidth}
|
||||
onChange={(e) => updateAutocode(autocodes[0].b_id, { b_seqwidth: Number(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_resettype 重置周期">
|
||||
<Select
|
||||
value={autocodes[0].b_resettype}
|
||||
onChange={(e) => updateAutocode(autocodes[0].b_id, { b_resettype: e.target.value as Autocode["b_resettype"] })}
|
||||
>
|
||||
{RESET_TYPES.map((t) => (
|
||||
<option key={t.v} value={t.v}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_startvalue 起始值">
|
||||
<Input
|
||||
type="number"
|
||||
value={autocodes[0].b_startvalue}
|
||||
onChange={(e) => updateAutocode(autocodes[0].b_id, { b_startvalue: Number(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_currentperiod 当前周期">
|
||||
<Input
|
||||
value={autocodes[0].b_currentperiod ?? ""}
|
||||
onChange={(e) => updateAutocode(autocodes[0].b_id, { b_currentperiod: e.target.value || null })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_currentvalue 当前流水值">
|
||||
<Input
|
||||
type="number"
|
||||
value={autocodes[0].b_currentvalue}
|
||||
onChange={(e) => updateAutocode(autocodes[0].b_id, { b_currentvalue: Number(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-500">
|
||||
下一单号预览:{autocodes[0].b_prefix}
|
||||
{autocodes[0].b_separator}
|
||||
{autocodes[0].b_dateformat}
|
||||
{autocodes[0].b_separator}
|
||||
{String(autocodes[0].b_currentvalue + 1).padStart(autocodes[0].b_seqwidth, "0")}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 多语言 */}
|
||||
<Card
|
||||
title="多语言 s_i18n"
|
||||
extra={
|
||||
<button onClick={addI18n} className="rounded bg-sky-500 px-2 py-1 text-xs text-white hover:bg-sky-600">
|
||||
+ 新增资源
|
||||
</button>
|
||||
}
|
||||
className="lg:col-span-2"
|
||||
>
|
||||
<div className="mb-2 flex gap-2">
|
||||
{["all", "module", "entity", "field", "menu"].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setI18nFilter(t)}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
i18nFilter === t ? "bg-sky-500 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>b_key 资源 key</Th>
|
||||
<Th>b_locale 语言</Th>
|
||||
<Th>b_value 文本</Th>
|
||||
<Th>b_i18ntype 类型</Th>
|
||||
<Th>操作</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredI18ns.length === 0 && <EmptyRow colSpan={5} text="暂无匹配的资源" />}
|
||||
{filteredI18ns.map((i) => (
|
||||
<tr key={i.b_id}>
|
||||
<Td className="font-mono text-xs">{i.b_key}</Td>
|
||||
<Td>
|
||||
<Badge tone={i.b_locale === "zh-CN" ? "green" : "blue"}>{i.b_locale}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Input value={i.b_value} onChange={(e) => updateI18n(i.b_id, { b_value: e.target.value })} />
|
||||
</Td>
|
||||
<Td>
|
||||
<Select value={i.b_i18ntype ?? ""} onChange={(e) => updateI18n(i.b_id, { b_i18ntype: e.target.value || null })}>
|
||||
<option value="">-</option>
|
||||
{["module", "entity", "field", "menu", "action"].map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Td>
|
||||
<Td>
|
||||
<button onClick={() => removeI18n(i.b_id)} className="text-xs text-rose-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
资源 key 全局唯一(b_key + b_locale 唯一约束);模块/实体/字段/菜单通过 b_i18n 引用这里的 key。
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
// 实体配置:s_entity(数据所有者)+ s_field(字段定义合并单表)+ s_field_group(分组)
|
||||
import { useState } from "react";
|
||||
import type { Entity, Field as FieldMeta, FieldGroup, Module } from "../types";
|
||||
import { parseOptions } from "../types";
|
||||
import { Badge, Card, EmptyRow, Field, Input, Select, Td, Th, TextArea, Toggle } from "./ui";
|
||||
|
||||
interface Props {
|
||||
modules: Module[];
|
||||
entities: Entity[];
|
||||
fields: FieldMeta[];
|
||||
groups: FieldGroup[];
|
||||
onChangeEntities: (e: Entity[]) => void;
|
||||
onChangeFields: (f: FieldMeta[]) => void;
|
||||
onChangeGroups: (g: FieldGroup[]) => void;
|
||||
}
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ v: "table", label: "table 简单实体" },
|
||||
{ v: "view", label: "view 复杂列表(视图)" },
|
||||
{ v: "query", label: "query 纯查询(只读)" },
|
||||
] as const;
|
||||
|
||||
const DELETE_POLICIES = [
|
||||
{ v: "restrict", label: "restrict 有引用拒绝删除" },
|
||||
{ v: "lifecycle", label: "lifecycle 草稿可删,正式作废" },
|
||||
{ v: "cascade", label: "cascade 随主表级联删除" },
|
||||
{ v: "hard", label: "hard 校验后物理删除" },
|
||||
] as const;
|
||||
|
||||
const EDIT_TYPES = ["input", "select", "date", "datetime", "textarea", "number", "checkbox", "radio"] as const;
|
||||
const QUERY_OPS = ["eq", "like", "gt", "ge", "lt", "le", "in", "between"] as const;
|
||||
|
||||
export default function EntityPanel({
|
||||
modules,
|
||||
entities,
|
||||
fields,
|
||||
groups,
|
||||
onChangeEntities,
|
||||
onChangeFields,
|
||||
onChangeGroups,
|
||||
}: Props) {
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<number>(2); // ocean
|
||||
const [selectedEntityId, setSelectedEntityId] = useState<number>(101);
|
||||
const [expandedField, setExpandedField] = useState<number | null>(null);
|
||||
|
||||
const moduleEntities = entities.filter((e) => e.b_module_id === selectedModuleId);
|
||||
const selectedEntity = entities.find((e) => e.b_id === selectedEntityId) ?? moduleEntities[0];
|
||||
|
||||
const entityFields = selectedEntity ? fields.filter((f) => f.b_entity_id === selectedEntity.b_id) : [];
|
||||
const entityGroups = selectedEntity ? groups.filter((g) => g.b_entity_id === selectedEntity.b_id) : [];
|
||||
|
||||
const updateEntity = (patch: Partial<Entity>) => {
|
||||
if (!selectedEntity) return;
|
||||
onChangeEntities(entities.map((e) => (e.b_id === selectedEntity.b_id ? { ...e, ...patch } : e)));
|
||||
};
|
||||
|
||||
const addEntity = () => {
|
||||
const id = Math.max(0, ...entities.map((e) => e.b_id)) + 1;
|
||||
const e: Entity = {
|
||||
b_id: id,
|
||||
b_module_id: selectedModuleId,
|
||||
b_parent_id: null,
|
||||
b_code: `new_entity_${id}`,
|
||||
b_name: "新实体",
|
||||
b_i18n: `entity.new_entity_${id}`,
|
||||
b_viewtable: `new_entity_${id}`,
|
||||
b_savetable: `new_entity_${id}`,
|
||||
b_keyfield: "b_id",
|
||||
b_entity_type: "table",
|
||||
b_delete_policy: "lifecycle",
|
||||
b_canuse: 1,
|
||||
b_xh: moduleEntities.length + 1,
|
||||
b_bz: null,
|
||||
};
|
||||
onChangeEntities([...entities, e]);
|
||||
setSelectedEntityId(id);
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
if (!selectedEntity) return;
|
||||
const id = Math.max(0, ...fields.map((f) => f.b_id)) + 1;
|
||||
const nf: FieldMeta = {
|
||||
b_id: id,
|
||||
b_entity_id: selectedEntity.b_id,
|
||||
b_field: `b_new_field_${id}`,
|
||||
b_name: "新字段",
|
||||
b_i18n: `field.b_new_field_${id}`,
|
||||
b_type: "varchar",
|
||||
b_length: 50,
|
||||
b_precision: null,
|
||||
b_scale: null,
|
||||
b_nullable: 1,
|
||||
b_default_value: null,
|
||||
b_options: null,
|
||||
b_edittype: "input",
|
||||
b_required: 0,
|
||||
b_readonly: 0,
|
||||
b_disabled: 0,
|
||||
b_listvisible: 1,
|
||||
b_listwidth: 100,
|
||||
b_queryable: 0,
|
||||
b_querytype: null,
|
||||
b_queryop: null,
|
||||
b_group_id: null,
|
||||
b_xh: entityFields.length + 1,
|
||||
b_canuse: 1,
|
||||
};
|
||||
onChangeFields([...fields, nf]);
|
||||
setExpandedField(id);
|
||||
};
|
||||
|
||||
const addGroup = () => {
|
||||
if (!selectedEntity) return;
|
||||
const id = Math.max(0, ...groups.map((g) => g.b_id)) + 1;
|
||||
onChangeGroups([
|
||||
...groups,
|
||||
{
|
||||
b_id: id,
|
||||
b_entity_id: selectedEntity.b_id,
|
||||
b_parent_id: null,
|
||||
b_title: "新分组",
|
||||
b_i18n: `group.new_${id}`,
|
||||
b_xh: entityGroups.length,
|
||||
b_canuse: 1,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateField = (fid: number, patch: Partial<FieldMeta>) => {
|
||||
onChangeFields(fields.map((f) => (f.b_id === fid ? { ...f, ...patch } : f)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
|
||||
{/* 左:模块 -> 实体列表 */}
|
||||
<Card title="实体列表 s_entity">
|
||||
<Field label="选择模块">
|
||||
<Select value={selectedModuleId} onChange={(e) => setSelectedModuleId(Number(e.target.value))}>
|
||||
{modules.map((m) => (
|
||||
<option key={m.b_id} value={m.b_id}>
|
||||
{m.b_name}({m.b_code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="mt-2 space-y-1">
|
||||
{moduleEntities.map((e) => {
|
||||
const active = e.b_id === selectedEntityId;
|
||||
return (
|
||||
<button
|
||||
key={e.b_id}
|
||||
onClick={() => setSelectedEntityId(e.b_id)}
|
||||
className={`w-full rounded border px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
active ? "border-sky-300 bg-sky-50 text-sky-700" : "border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate font-medium">{e.b_name}</span>
|
||||
<Badge tone={e.b_entity_type === "table" ? "green" : e.b_entity_type === "view" ? "blue" : "amber"}>
|
||||
{e.b_entity_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-slate-400">
|
||||
view: {e.b_viewtable ?? "-"} · save: {e.b_savetable ?? "-"}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={addEntity}
|
||||
className="mt-2 w-full rounded border border-dashed border-sky-300 py-1.5 text-sm text-sky-600 hover:bg-sky-50"
|
||||
>
|
||||
+ 新增实体
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
{/* 右:实体详情 + 字段 */}
|
||||
{selectedEntity ? (
|
||||
<div className="space-y-4">
|
||||
{/* 实体基本信息 */}
|
||||
<Card title={`实体基本信息 s_entity #${selectedEntity.b_id}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<Field label="b_code 实体编码">
|
||||
<Input value={selectedEntity.b_code} onChange={(e) => updateEntity({ b_code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_name 实体名称">
|
||||
<Input value={selectedEntity.b_name} onChange={(e) => updateEntity({ b_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_entity_type 实体类型">
|
||||
<Select
|
||||
value={selectedEntity.b_entity_type}
|
||||
onChange={(e) => updateEntity({ b_entity_type: e.target.value as Entity["b_entity_type"] })}
|
||||
>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<option key={t.v} value={t.v}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_viewtable 查询数据源">
|
||||
<Input value={selectedEntity.b_viewtable ?? ""} onChange={(e) => updateEntity({ b_viewtable: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_savetable 保存目标">
|
||||
<Input
|
||||
value={selectedEntity.b_savetable ?? ""}
|
||||
placeholder="纯查询实体留空"
|
||||
onChange={(e) => updateEntity({ b_savetable: e.target.value || null })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_keyfield 保存主键字段">
|
||||
<Input value={selectedEntity.b_keyfield ?? ""} onChange={(e) => updateEntity({ b_keyfield: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_delete_policy 删除策略">
|
||||
<Select
|
||||
value={selectedEntity.b_delete_policy}
|
||||
onChange={(e) => updateEntity({ b_delete_policy: e.target.value as Entity["b_delete_policy"] })}
|
||||
>
|
||||
{DELETE_POLICIES.map((p) => (
|
||||
<option key={p.v} value={p.v}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_parent_id 父实体(主子表)">
|
||||
<Select
|
||||
value={selectedEntity.b_parent_id ?? ""}
|
||||
onChange={(e) => updateEntity({ b_parent_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">(无)</option>
|
||||
{moduleEntities
|
||||
.filter((x) => x.b_id !== selectedEntity.b_id)
|
||||
.map((x) => (
|
||||
<option key={x.b_id} value={x.b_id}>
|
||||
{x.b_name}({x.b_code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_canuse 是否启用">
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Toggle on={selectedEntity.b_canuse === 1} onChange={(v) => updateEntity({ b_canuse: v ? 1 : 0 })} />
|
||||
<span className="text-sm text-slate-500">{selectedEntity.b_canuse === 1 ? "启用" : "停用"}</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Field label="b_bz 实体描述">
|
||||
<TextArea rows={2} value={selectedEntity.b_bz ?? ""} onChange={(e) => updateEntity({ b_bz: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 字段分组 */}
|
||||
<Card
|
||||
title="字段分组 s_field_group"
|
||||
extra={
|
||||
<button onClick={addGroup} className="rounded bg-sky-500 px-2 py-1 text-xs text-white hover:bg-sky-600">
|
||||
+ 新增分组
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{entityGroups.length === 0 && <span className="text-sm text-slate-400">暂无分组</span>}
|
||||
{entityGroups.map((g) => (
|
||||
<span key={g.b_id} className="inline-flex items-center gap-1 rounded border border-slate-200 bg-slate-50 px-2 py-1 text-xs text-slate-600">
|
||||
{g.b_title}
|
||||
<span className="text-slate-300">xh={g.b_xh}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 字段配置 */}
|
||||
<Card
|
||||
title={`字段配置 s_field(共 ${entityFields.length} 个)`}
|
||||
extra={
|
||||
<button onClick={addField} className="rounded bg-sky-500 px-2 py-1 text-xs text-white hover:bg-sky-600">
|
||||
+ 新增字段
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>b_field 列名</Th>
|
||||
<Th>b_name 名称</Th>
|
||||
<Th>b_type 类型</Th>
|
||||
<Th>编辑组件</Th>
|
||||
<Th>必填</Th>
|
||||
<Th>列表</Th>
|
||||
<Th>查询</Th>
|
||||
<Th>分组</Th>
|
||||
<Th>操作</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entityFields.length === 0 && <EmptyRow colSpan={9} text="暂无字段,点击右上角新增" />}
|
||||
{entityFields.map((f) => (
|
||||
<FieldRow
|
||||
key={f.b_id}
|
||||
f={f}
|
||||
groups={entityGroups}
|
||||
expanded={expandedField === f.b_id}
|
||||
onToggleExpand={() => setExpandedField(expandedField === f.b_id ? null : f.b_id)}
|
||||
onChange={(patch) => updateField(f.b_id, patch)}
|
||||
onRemove={() => onChangeFields(fields.filter((x) => x.b_id !== f.b_id))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card title="提示">请先在左侧选择一个实体</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
f,
|
||||
groups,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
f: FieldMeta;
|
||||
groups: FieldGroup[];
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onChange: (patch: Partial<FieldMeta>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const groupName = groups.find((g) => g.b_id === f.b_group_id)?.b_title;
|
||||
const options = parseOptions(f.b_options);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={expanded ? "bg-sky-50/50" : ""}>
|
||||
<Td className="font-mono text-xs">{f.b_field}</Td>
|
||||
<Td>
|
||||
<button onClick={onToggleExpand} className="text-sky-600 hover:underline">
|
||||
{f.b_name}
|
||||
</button>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge tone="slate">{f.b_type}</Badge>
|
||||
</Td>
|
||||
<Td className="text-xs">{f.b_edittype ?? "-"}</Td>
|
||||
<Td>
|
||||
<Toggle on={f.b_required === 1} onChange={(v) => onChange({ b_required: v ? 1 : 0 })} label="必填" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Toggle on={f.b_listvisible === 1} onChange={(v) => onChange({ b_listvisible: v ? 1 : 0 })} label="列表显示" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Toggle on={f.b_queryable === 1} onChange={(v) => onChange({ b_queryable: v ? 1 : 0 })} label="可查询" />
|
||||
</Td>
|
||||
<Td className="text-xs text-slate-500">{groupName ?? "未分组"}</Td>
|
||||
<Td>
|
||||
<button onClick={onRemove} className="text-xs text-rose-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
</Td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="bg-sky-50/30">
|
||||
<Td colSpan={9} className="!bg-transparent">
|
||||
<div className="grid grid-cols-2 gap-3 py-1 md:grid-cols-4">
|
||||
<Field label="b_i18n key">
|
||||
<Input value={f.b_i18n ?? ""} onChange={(e) => onChange({ b_i18n: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_length 长度">
|
||||
<Input
|
||||
type="number"
|
||||
value={f.b_length ?? ""}
|
||||
onChange={(e) => onChange({ b_length: e.target.value ? Number(e.target.value) : null })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_precision / b_scale">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="precision"
|
||||
value={f.b_precision ?? ""}
|
||||
onChange={(e) => onChange({ b_precision: e.target.value ? Number(e.target.value) : null })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="scale"
|
||||
value={f.b_scale ?? ""}
|
||||
onChange={(e) => onChange({ b_scale: e.target.value ? Number(e.target.value) : null })}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="b_edittype 编辑组件">
|
||||
<Select value={f.b_edittype ?? ""} onChange={(e) => onChange({ b_edittype: e.target.value || null })}>
|
||||
<option value="">(默认)</option>
|
||||
{EDIT_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_group_id 分组">
|
||||
<Select
|
||||
value={f.b_group_id ?? ""}
|
||||
onChange={(e) => onChange({ b_group_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">未分组</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.b_id} value={g.b_id}>
|
||||
{g.b_title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_querytype / b_queryop">
|
||||
<div className="flex gap-1">
|
||||
<Select value={f.b_querytype ?? ""} onChange={(e) => onChange({ b_querytype: e.target.value || null })}>
|
||||
<option value="">-</option>
|
||||
<option value="input">input</option>
|
||||
<option value="select">select</option>
|
||||
<option value="date">date</option>
|
||||
</Select>
|
||||
<Select value={f.b_queryop ?? ""} onChange={(e) => onChange({ b_queryop: e.target.value || null })}>
|
||||
<option value="">-</option>
|
||||
{QUERY_OPS.map((op) => (
|
||||
<option key={op} value={op}>
|
||||
{op}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="b_default_value 默认值">
|
||||
<Input
|
||||
value={f.b_default_value ?? ""}
|
||||
onChange={(e) => onChange({ b_default_value: e.target.value || null })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_nullable 可空">
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Toggle on={f.b_nullable === 1} onChange={(v) => onChange({ b_nullable: v ? 1 : 0 })} />
|
||||
<span className="text-sm text-slate-500">{f.b_nullable === 1 ? "可空" : "非空"}</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="b_readonly 只读">
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Toggle on={f.b_readonly === 1} onChange={(v) => onChange({ b_readonly: v ? 1 : 0 })} />
|
||||
<span className="text-sm text-slate-500">{f.b_readonly === 1 ? "只读" : "可写"}</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="b_listwidth 列宽(px)" className="col-span-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={f.b_listwidth ?? ""}
|
||||
onChange={(e) => onChange({ b_listwidth: e.target.value ? Number(e.target.value) : null })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_options 下拉选项(JSON)" className="col-span-2">
|
||||
<TextArea
|
||||
rows={2}
|
||||
value={f.b_options ?? ""}
|
||||
placeholder='[{"label":"宁波","value":"NGB"}]'
|
||||
onChange={(e) => onChange({ b_options: e.target.value || null })}
|
||||
/>
|
||||
{options.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{options.map((o) => (
|
||||
<span key={o.value} className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
|
||||
{o.label}={o.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</Td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
// 菜单配置:s_menu(纯导航树,挂模块 + 引用实体)
|
||||
import { useState } from "react";
|
||||
import type { Entity, Menu, Module } from "../types";
|
||||
import { Badge, Card, Field, Input, Select, Toggle } from "./ui";
|
||||
|
||||
interface Props {
|
||||
menus: Menu[];
|
||||
entities: Entity[];
|
||||
modules: Module[];
|
||||
onChangeMenus: (m: Menu[]) => void;
|
||||
}
|
||||
|
||||
const MENU_TYPES = [
|
||||
{ v: "directory", label: "directory 目录" },
|
||||
{ v: "page", label: "page 固定页面" },
|
||||
{ v: "data", label: "data 数据模块" },
|
||||
{ v: "external", label: "external 外链" },
|
||||
] as const;
|
||||
|
||||
export default function MenuPanel({ menus, entities, modules, onChangeMenus }: Props) {
|
||||
const [selectedId, setSelectedId] = useState<number>(menus[0]?.b_id ?? 402);
|
||||
const selected = menus.find((m) => m.b_id === selectedId) ?? menus[0];
|
||||
|
||||
const update = (patch: Partial<Menu>) => {
|
||||
if (!selected) return;
|
||||
onChangeMenus(menus.map((m) => (m.b_id === selected.b_id ? { ...m, ...patch } : m)));
|
||||
};
|
||||
|
||||
const addMenu = () => {
|
||||
const id = Math.max(0, ...menus.map((m) => m.b_id)) + 1;
|
||||
const m: Menu = {
|
||||
b_id: id,
|
||||
b_parent_id: selected?.b_parent_id ?? null,
|
||||
b_module_id: selected?.b_module_id ?? null,
|
||||
b_code: `new_menu_${id}`,
|
||||
b_name: "新菜单",
|
||||
b_i18n: `menu.new_menu_${id}`,
|
||||
b_menu_type: "data",
|
||||
b_route: null,
|
||||
b_entity_id: entities[0]?.b_id ?? null,
|
||||
b_power_code: null,
|
||||
b_icon: null,
|
||||
b_canmenu: 1,
|
||||
b_xh: 99,
|
||||
b_canuse: 1,
|
||||
};
|
||||
onChangeMenus([...menus, m]);
|
||||
setSelectedId(id);
|
||||
};
|
||||
|
||||
const remove = (id: number) => {
|
||||
onChangeMenus(menus.filter((m) => m.b_id !== id));
|
||||
if (selectedId === id) setSelectedId(menus[0]?.b_id ?? 0);
|
||||
};
|
||||
|
||||
const roots = menus.filter((m) => m.b_parent_id === null);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[300px_1fr]">
|
||||
{/* 左:菜单树 */}
|
||||
<Card
|
||||
title="菜单树 s_menu"
|
||||
extra={
|
||||
<button onClick={addMenu} className="rounded bg-sky-500 px-2 py-1 text-xs text-white hover:bg-sky-600">
|
||||
+ 新增
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{roots.map((root) => (
|
||||
<MenuNode
|
||||
key={root.b_id}
|
||||
m={root}
|
||||
menus={menus}
|
||||
entities={entities}
|
||||
modules={modules}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onRemove={remove}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs leading-5 text-slate-400">
|
||||
b_parent_id 表达<b>导航层级</b>;b_module_id 表达<b>所属模块</b>(组织维度);目录仅在存在可访问子页面时显示。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* 右:选中菜单配置 */}
|
||||
{selected ? (
|
||||
<Card title={`菜单/页面配置 s_menu #${selected.b_id}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<Field label="b_code 菜单编码">
|
||||
<Input value={selected.b_code} onChange={(e) => update({ b_code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_name 菜单名称">
|
||||
<Input value={selected.b_name} onChange={(e) => update({ b_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_menu_type 菜单类型">
|
||||
<Select
|
||||
value={selected.b_menu_type}
|
||||
onChange={(e) => update({ b_menu_type: e.target.value as Menu["b_menu_type"] })}
|
||||
>
|
||||
{MENU_TYPES.map((t) => (
|
||||
<option key={t.v} value={t.v}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_parent_id 父菜单">
|
||||
<Select
|
||||
value={selected.b_parent_id ?? ""}
|
||||
onChange={(e) => update({ b_parent_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">(顶级)</option>
|
||||
{menus
|
||||
.filter((m) => m.b_id !== selected.b_id)
|
||||
.map((m) => (
|
||||
<option key={m.b_id} value={m.b_id}>
|
||||
{m.b_name}({m.b_code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_module_id 所属模块">
|
||||
<Select
|
||||
value={selected.b_module_id ?? ""}
|
||||
onChange={(e) => update({ b_module_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">(公共/无模块)</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.b_id} value={m.b_id}>
|
||||
{m.b_name}({m.b_code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_route 路由(固定页面/外链)">
|
||||
<Input
|
||||
value={selected.b_route ?? ""}
|
||||
placeholder="如 /ocean/booking/detail"
|
||||
onChange={(e) => update({ b_route: e.target.value || null })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_entity_id 绑定实体(data 类型)">
|
||||
<Select
|
||||
value={selected.b_entity_id ?? ""}
|
||||
onChange={(e) => update({ b_entity_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">(无)</option>
|
||||
{entities.map((e) => (
|
||||
<option key={e.b_id} value={e.b_id}>
|
||||
{e.b_name}({e.b_code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_power_code 访问权限编码">
|
||||
<Input
|
||||
value={selected.b_power_code ?? ""}
|
||||
placeholder="如 ocean.booking.view"
|
||||
onChange={(e) => update({ b_power_code: e.target.value || null })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_icon 图标">
|
||||
<Input value={selected.b_icon ?? ""} onChange={(e) => update({ b_icon: e.target.value || null })} />
|
||||
</Field>
|
||||
<Field label="b_xh 排序号">
|
||||
<Input type="number" value={selected.b_xh} onChange={(e) => update({ b_xh: Number(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="b_canmenu 是否显示在菜单">
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Toggle on={selected.b_canmenu === 1} onChange={(v) => update({ b_canmenu: v ? 1 : 0 })} />
|
||||
<span className="text-sm text-slate-500">{selected.b_canmenu === 1 ? "显示" : "隐藏"}</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="b_canuse 是否启用">
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Toggle on={selected.b_canuse === 1} onChange={(v) => update({ b_canuse: v ? 1 : 0 })} />
|
||||
<span className="text-sm text-slate-500">{selected.b_canuse === 1 ? "启用" : "停用"}</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-4 rounded bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
类型约束:directory 不填路由/实体;page 必填 <b>b_route</b>;data 必填 <b>b_entity_id</b>(查询走实体
|
||||
b_viewtable,保存走 b_savetable);external 必填 <b>b_route</b> 与 <b>b_power_code</b>。前端只控制显示,后端必须再次校验同一权限编码。
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card title="提示">请选择左侧菜单</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuNode({
|
||||
m,
|
||||
menus,
|
||||
entities,
|
||||
modules,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onRemove,
|
||||
depth,
|
||||
}: {
|
||||
m: Menu;
|
||||
menus: Menu[];
|
||||
entities: Entity[];
|
||||
modules: Module[];
|
||||
selectedId: number;
|
||||
onSelect: (id: number) => void;
|
||||
onRemove: (id: number) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const children = menus.filter((x) => x.b_parent_id === m.b_id);
|
||||
const active = selectedId === m.b_id;
|
||||
const entity = entities.find((e) => e.b_id === m.b_entity_id);
|
||||
const module = modules.find((mo) => mo.b_id === m.b_module_id);
|
||||
return (
|
||||
<li>
|
||||
<div
|
||||
className={`group flex items-center gap-2 rounded px-2 py-1.5 text-sm transition-colors ${
|
||||
active ? "bg-sky-50 font-medium text-sky-700" : "text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
<button onClick={() => onSelect(m.b_id)} className="flex flex-1 items-center gap-2 text-left">
|
||||
<span className="text-slate-400">{m.b_icon ?? (m.b_menu_type === "directory" ? "📁" : "📄")}</span>
|
||||
<span className="flex-1 truncate">{m.b_name}</span>
|
||||
<Badge tone={m.b_menu_type === "data" ? "green" : m.b_menu_type === "page" ? "blue" : m.b_menu_type === "directory" ? "amber" : "slate"}>
|
||||
{m.b_menu_type}
|
||||
</Badge>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove(m.b_id)}
|
||||
className="hidden text-xs text-rose-400 hover:text-rose-600 group-hover:block"
|
||||
>
|
||||
删
|
||||
</button>
|
||||
</div>
|
||||
{module && <div className="ml-7 text-xs text-slate-400">模块: {module.b_name}</div>}
|
||||
{entity && <div className="ml-7 text-xs text-slate-400">→ 实体: {entity.b_name}</div>}
|
||||
{children.length > 0 && (
|
||||
<ul className="mt-0.5 space-y-0.5">
|
||||
{children.map((c) => (
|
||||
<MenuNode
|
||||
key={c.b_id}
|
||||
m={c}
|
||||
menus={menus}
|
||||
entities={entities}
|
||||
modules={modules}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onRemove={onRemove}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
// 统一元数据树:顺着导航维护 —— 菜单 → 模块 → 数据(实体) → 编码
|
||||
// ① 主链:菜单(s_menu) 为根,菜单下挂它所属的模块(s_module),模块下挂该菜单绑定的数据实体(s_entity),实体下挂编码(s_autocode)
|
||||
// ② 兜底:底部列出「未挂菜单的模块 / 未被菜单绑定的实体」,防止孤儿数据维护不到
|
||||
// 字段(s_field)/分组(s_field_group) 是实体内部明细,不进树,在实体面板中维护
|
||||
import type { Autocode, Entity, Menu, Module } from "../types";
|
||||
import { Badge } from "./ui";
|
||||
|
||||
export type TreeNodeType = "module" | "menu" | "entity" | "autocode";
|
||||
|
||||
export interface TreeSelection {
|
||||
type: TreeNodeType;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modules: Module[];
|
||||
entities: Entity[];
|
||||
menus: Menu[];
|
||||
autocodes: Autocode[];
|
||||
selection: TreeSelection | null;
|
||||
onSelect: (s: TreeSelection) => void;
|
||||
}
|
||||
|
||||
export default function MetaTree({ modules, entities, menus, autocodes, selection, onSelect }: Props) {
|
||||
const menuRoots = menus.filter((m) => m.b_parent_id === null || !menus.some((x) => x.b_id === m.b_parent_id));
|
||||
const referencedModuleIds = new Set(menus.map((m) => m.b_module_id).filter((id): id is number => id != null));
|
||||
const referencedEntityIds = new Set(menus.map((m) => m.b_entity_id).filter((id): id is number => id != null));
|
||||
const orphanModules = modules.filter((m) => !referencedModuleIds.has(m.b_id));
|
||||
const orphanEntities = entities.filter((e) => !referencedEntityIds.has(e.b_id));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<header className="border-b border-slate-100 px-4 py-2.5">
|
||||
<h3 className="text-sm font-semibold text-slate-700">元数据树(全链路)</h3>
|
||||
<p className="text-[11px] text-slate-400">顺着导航维护:菜单 → 模块 → 数据(实体) → 编码 · 字段/分组在实体面板内维护</p>
|
||||
</header>
|
||||
<div className="max-h-[calc(100vh-220px)] overflow-y-auto p-2">
|
||||
{/* ① 主链:菜单 → 模块 → 数据 → 编码 */}
|
||||
<GroupLabel icon="🧭" title="菜单 → 模块 → 数据" subtitle="顺着导航维护" />
|
||||
<ul className="space-y-0.5">
|
||||
{menuRoots.map((menu) => (
|
||||
<MenuBranch
|
||||
key={menu.b_id}
|
||||
menu={menu}
|
||||
allMenus={menus}
|
||||
modules={modules}
|
||||
entities={entities}
|
||||
autocodes={autocodes}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
{menuRoots.length === 0 && <p className="px-2 py-1 text-xs text-slate-400">暂无菜单</p>}
|
||||
</ul>
|
||||
|
||||
{/* ② 兜底:未挂菜单的模块 / 未被菜单绑定的实体 */}
|
||||
{(orphanModules.length > 0 || orphanEntities.length > 0) && (
|
||||
<>
|
||||
<GroupLabel icon="🗃️" title="未挂菜单的模块 / 数据" subtitle="孤儿数据兜底" />
|
||||
<ul className="space-y-0.5">
|
||||
{orphanModules.map((m) => (
|
||||
<ModuleBranch
|
||||
key={m.b_id}
|
||||
module={m}
|
||||
entities={entities}
|
||||
autocodes={autocodes}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
{orphanEntities
|
||||
.filter((e) => !orphanModules.some((m) => m.b_id === e.b_module_id))
|
||||
.reduce<{ module: Module; list: Entity[] }[]>((acc, e) => {
|
||||
const mod = modules.find((m) => m.b_id === e.b_module_id);
|
||||
const key = mod?.b_id ?? -1;
|
||||
const g = acc.find((x) => x.module.b_id === key);
|
||||
if (g) g.list.push(e);
|
||||
else acc.push({ module: mod ?? ({ b_id: key, b_code: "#", b_name: `#${e.b_module_id}` } as Module), list: [e] });
|
||||
return acc;
|
||||
}, [])
|
||||
.map((g) => (
|
||||
<li key={g.module.b_id}>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-sm text-slate-600">
|
||||
<span className="text-xs">🗃️</span>
|
||||
<span className="flex-1 truncate">{g.module.b_name} ({g.module.b_code})</span>
|
||||
</div>
|
||||
<ul className="mt-0.5 space-y-0.5">
|
||||
{g.list.map((e) => (
|
||||
<EntityNode key={e.b_id} entity={e} autocodes={autocodes} selection={selection} onSelect={onSelect} depth={1} />
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupLabel({ icon, title, subtitle }: { icon: string; title: string; subtitle: string }) {
|
||||
return (
|
||||
<div className="mt-2 flex items-baseline gap-1.5 px-2 py-1 first:mt-0">
|
||||
<span className="text-xs">{icon}</span>
|
||||
<span className="text-xs font-semibold text-slate-500">{title}</span>
|
||||
<span className="text-[10px] text-slate-400">{subtitle}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
label,
|
||||
icon,
|
||||
badge,
|
||||
tone,
|
||||
selected,
|
||||
onClick,
|
||||
depth,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
icon: string;
|
||||
badge?: string;
|
||||
tone?: "slate" | "green" | "blue" | "amber" | "red";
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
depth: number;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-sm transition-colors ${
|
||||
selected ? "bg-sky-50 font-medium text-sky-700" : "text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-xs">{icon}</span>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{badge && <Badge tone={tone ?? "slate"}>{badge}</Badge>}
|
||||
</div>
|
||||
{children && <ul className="mt-0.5 space-y-0.5">{children}</ul>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- ① 主链:菜单分支 ----------
|
||||
function MenuBranch({
|
||||
menu,
|
||||
allMenus,
|
||||
modules,
|
||||
entities,
|
||||
autocodes,
|
||||
selection,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
menu: Menu;
|
||||
allMenus: Menu[];
|
||||
modules: Module[];
|
||||
entities: Entity[];
|
||||
autocodes: Autocode[];
|
||||
selection: TreeSelection | null;
|
||||
onSelect: (s: TreeSelection) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const children = allMenus.filter((m) => m.b_parent_id === menu.b_id);
|
||||
const linkedModule = menu.b_module_id != null ? modules.find((m) => m.b_id === menu.b_module_id) : undefined;
|
||||
const linkedEntity = menu.b_entity_id != null ? entities.find((e) => e.b_id === menu.b_entity_id) : undefined;
|
||||
const showLink = menu.b_menu_type !== "directory"; // 目录只做导航容器,业务链路由子菜单各自挂
|
||||
|
||||
return (
|
||||
<TreeNode
|
||||
label={menu.b_name}
|
||||
icon={menu.b_menu_type === "directory" ? "📁" : menu.b_menu_type === "data" ? "🗂️" : menu.b_menu_type === "page" ? "📄" : "🔗"}
|
||||
selected={selection?.type === "menu" && selection.id === menu.b_id}
|
||||
onClick={() => onSelect({ type: "menu", id: menu.b_id })}
|
||||
depth={depth}
|
||||
>
|
||||
{/* 子菜单 */}
|
||||
{children.map((child) => (
|
||||
<MenuBranch
|
||||
key={child.b_id}
|
||||
menu={child}
|
||||
allMenus={allMenus}
|
||||
modules={modules}
|
||||
entities={entities}
|
||||
autocodes={autocodes}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 菜单 → 模块 → 数据(实体) → 编码 */}
|
||||
{showLink && linkedModule && (
|
||||
<TreeNode
|
||||
label={`${linkedModule.b_name} (${linkedModule.b_code})`}
|
||||
icon="🗃️"
|
||||
selected={selection?.type === "module" && selection.id === linkedModule.b_id}
|
||||
onClick={() => onSelect({ type: "module", id: linkedModule.b_id })}
|
||||
depth={depth + 1}
|
||||
>
|
||||
{linkedEntity ? (
|
||||
<EntityNode entity={linkedEntity} autocodes={autocodes} selection={selection} onSelect={onSelect} depth={depth + 2} />
|
||||
) : (
|
||||
<li>
|
||||
<div className="px-2 py-0.5 text-xs text-slate-400" style={{ paddingLeft: 8 + (depth + 2) * 14 }}>
|
||||
未绑定数据实体
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</TreeNode>
|
||||
)}
|
||||
</TreeNode>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- ② 兜底:模块分支(模块 → 全部实体 → 编码) ----------
|
||||
function ModuleBranch({
|
||||
module,
|
||||
entities,
|
||||
autocodes,
|
||||
selection,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
module: Module;
|
||||
entities: Entity[];
|
||||
autocodes: Autocode[];
|
||||
selection: TreeSelection | null;
|
||||
onSelect: (s: TreeSelection) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const moduleEntities = entities.filter((e) => e.b_module_id === module.b_id);
|
||||
return (
|
||||
<TreeNode
|
||||
label={`${module.b_name} (${module.b_code})`}
|
||||
icon={module.b_icon ?? "▦"}
|
||||
badge={module.b_canuse === 1 ? "启用" : "停用"}
|
||||
tone={module.b_canuse === 1 ? "green" : "red"}
|
||||
selected={selection?.type === "module" && selection.id === module.b_id}
|
||||
onClick={() => onSelect({ type: "module", id: module.b_id })}
|
||||
depth={depth}
|
||||
>
|
||||
{moduleEntities.map((e) => (
|
||||
<EntityNode key={e.b_id} entity={e} autocodes={autocodes} selection={selection} onSelect={onSelect} depth={depth + 1} />
|
||||
))}
|
||||
{moduleEntities.length === 0 && (
|
||||
<li>
|
||||
<div className="px-2 py-0.5 text-xs text-slate-400" style={{ paddingLeft: 8 + (depth + 1) * 14 }}>
|
||||
暂无实体
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</TreeNode>
|
||||
);
|
||||
}
|
||||
|
||||
// 实体节点(含编码子树)
|
||||
function EntityNode({
|
||||
entity,
|
||||
autocodes,
|
||||
selection,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
entity: Entity;
|
||||
autocodes: Autocode[];
|
||||
selection: TreeSelection | null;
|
||||
onSelect: (s: TreeSelection) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const entityAutocodes = autocodes.filter((a) => a.b_entity_id === entity.b_id);
|
||||
return (
|
||||
<TreeNode
|
||||
label={`${entity.b_name} (${entity.b_code})`}
|
||||
icon={entity.b_entity_type === "view" ? "👁️" : "📋"}
|
||||
badge={entity.b_entity_type === "table" ? "表" : entity.b_entity_type === "view" ? "视图" : "查询"}
|
||||
tone={entity.b_entity_type === "table" ? "green" : entity.b_entity_type === "view" ? "blue" : "amber"}
|
||||
selected={selection?.type === "entity" && selection.id === entity.b_id}
|
||||
onClick={() => onSelect({ type: "entity", id: entity.b_id })}
|
||||
depth={depth}
|
||||
>
|
||||
{entityAutocodes.length > 0 && (
|
||||
<li>
|
||||
<div className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-400" style={{ paddingLeft: 8 + (depth + 1) * 14 }}>
|
||||
编码 s_autocode
|
||||
</div>
|
||||
<ul className="mt-0.5 space-y-0.5">
|
||||
{entityAutocodes.map((a) => (
|
||||
<TreeNode
|
||||
key={a.b_id}
|
||||
label={`${a.b_field} → ${a.b_prefix}-${a.b_dateformat}-${"0".repeat(a.b_seqwidth)}`}
|
||||
icon="🔢"
|
||||
selected={selection?.type === "autocode" && selection.id === a.b_id}
|
||||
onClick={() => onSelect({ type: "autocode", id: a.b_id })}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</TreeNode>
|
||||
);
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
// 模块配置:s_module(模块基本信息 + 业务分组树)+ s_module_dependency(依赖 DAG)
|
||||
import { useState } from "react";
|
||||
import type { Module, ModuleDependency } from "../types";
|
||||
import { Badge, Card, EmptyRow, Field, Input, Select, Td, Th, TextArea, Toggle } from "./ui";
|
||||
|
||||
interface Props {
|
||||
modules: Module[];
|
||||
dependencies: ModuleDependency[];
|
||||
onChangeModules: (m: Module[]) => void;
|
||||
onChangeDependencies: (d: ModuleDependency[]) => void;
|
||||
}
|
||||
|
||||
const MODULE_TYPES = [
|
||||
{ v: "platform", label: "platform 平台内置" },
|
||||
{ v: "tenant", label: "tenant 租户定制" },
|
||||
{ v: "business", label: "business 业务" },
|
||||
] as const;
|
||||
|
||||
export default function ModulePanel({ modules, dependencies, onChangeModules, onChangeDependencies }: Props) {
|
||||
const [selectedId, setSelectedId] = useState<number>(modules[0]?.b_id ?? 1);
|
||||
const selected = modules.find((m) => m.b_id === selectedId) ?? modules[0];
|
||||
|
||||
const update = (patch: Partial<Module>) => {
|
||||
onChangeModules(modules.map((m) => (m.b_id === selected.b_id ? { ...m, ...patch } : m)));
|
||||
};
|
||||
|
||||
const addModule = () => {
|
||||
const id = Math.max(0, ...modules.map((m) => m.b_id)) + 1;
|
||||
const m: Module = {
|
||||
b_id: id,
|
||||
b_code: `new-module-${id}`,
|
||||
b_name: "新模块",
|
||||
b_i18n: `module.new-module-${id}`,
|
||||
b_parent_id: selected.b_id,
|
||||
b_module_type: "business",
|
||||
b_canuse: 1,
|
||||
b_xh: (selected.b_xh + 1) * 10,
|
||||
b_icon: null,
|
||||
b_bz: null,
|
||||
};
|
||||
onChangeModules([...modules, m]);
|
||||
setSelectedId(id);
|
||||
};
|
||||
|
||||
const addDependency = () => {
|
||||
const id = Math.max(0, ...dependencies.map((d) => d.b_id)) + 1;
|
||||
onChangeDependencies([
|
||||
...dependencies,
|
||||
{ b_id: id, b_module_id: selected.b_id, b_depend_module_id: 1 },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeDependency = (id: number) => onChangeDependencies(dependencies.filter((d) => d.b_id !== id));
|
||||
|
||||
const inDeps = dependencies.filter((d) => d.b_module_id === selected.b_id);
|
||||
const dependOnMe = dependencies.filter((d) => d.b_depend_module_id === selected.b_id);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
|
||||
{/* 左:模块树 */}
|
||||
<Card
|
||||
title="模块树 s_module"
|
||||
extra={
|
||||
<button onClick={addModule} className="rounded bg-sky-500 px-2 py-1 text-xs text-white hover:bg-sky-600">
|
||||
+ 新增子模块
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{modules
|
||||
.filter((m) => m.b_parent_id === null)
|
||||
.map((root) => (
|
||||
<ModuleNode
|
||||
key={root.b_id}
|
||||
m={root}
|
||||
modules={modules}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs leading-5 text-slate-400">
|
||||
b_parent_id 仅表达<b>业务分组归属</b>,不承担导航;模块间依赖见右侧 DAG。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* 右:选中模块配置 */}
|
||||
{selected ? (
|
||||
<div className="space-y-4">
|
||||
<Card title={`模块基本信息 s_module #${selected.b_id}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<Field label="b_code 模块编码">
|
||||
<Input value={selected.b_code} onChange={(e) => update({ b_code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_name 模块名称">
|
||||
<Input value={selected.b_name} onChange={(e) => update({ b_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_i18n 多语言 key">
|
||||
<Input value={selected.b_i18n ?? ""} onChange={(e) => update({ b_i18n: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_parent_id 父模块(业务分组)">
|
||||
<Select
|
||||
value={selected.b_parent_id ?? ""}
|
||||
onChange={(e) => update({ b_parent_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">(无/顶级)</option>
|
||||
{modules
|
||||
.filter((m) => m.b_id !== selected.b_id)
|
||||
.map((m) => (
|
||||
<option key={m.b_id} value={m.b_id}>
|
||||
{m.b_name}({m.b_code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_module_type 模块类型">
|
||||
<Select
|
||||
value={selected.b_module_type}
|
||||
onChange={(e) => update({ b_module_type: e.target.value as Module["b_module_type"] })}
|
||||
>
|
||||
{MODULE_TYPES.map((t) => (
|
||||
<option key={t.v} value={t.v}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="b_xh 排序号">
|
||||
<Input
|
||||
type="number"
|
||||
value={selected.b_xh}
|
||||
onChange={(e) => update({ b_xh: Number(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="b_icon 图标 key">
|
||||
<Input value={selected.b_icon ?? ""} onChange={(e) => update({ b_icon: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_canuse 是否启用">
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Toggle on={selected.b_canuse === 1} onChange={(v) => update({ b_canuse: v ? 1 : 0 })} />
|
||||
<span className="text-sm text-slate-500">{selected.b_canuse === 1 ? "启用" : "停用"}</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Field label="b_bz 模块描述">
|
||||
<TextArea rows={2} value={selected.b_bz ?? ""} onChange={(e) => update({ b_bz: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={`模块依赖 s_module_dependency(依赖方: ${selected.b_name})`}
|
||||
extra={
|
||||
<button onClick={addDependency} className="rounded bg-sky-500 px-2 py-1 text-xs text-white hover:bg-sky-600">
|
||||
+ 新增依赖
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>依赖方 b_module_id</Th>
|
||||
<Th>被依赖 b_depend_module_id</Th>
|
||||
<Th>操作</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inDeps.length === 0 && <EmptyRow colSpan={3} text="暂无依赖,点击右上角新增" />}
|
||||
{inDeps.map((d) => (
|
||||
<tr key={d.b_id}>
|
||||
<Td>{selected.b_name}({selected.b_code})</Td>
|
||||
<Td>
|
||||
<Select
|
||||
value={d.b_depend_module_id}
|
||||
onChange={(e) =>
|
||||
onChangeDependencies(
|
||||
dependencies.map((x) =>
|
||||
x.b_id === d.b_id ? { ...x, b_depend_module_id: Number(e.target.value) } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{modules
|
||||
.filter((m) => m.b_id !== d.b_module_id)
|
||||
.map((m) => (
|
||||
<option key={m.b_id} value={m.b_id}>
|
||||
{m.b_name}({m.b_code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Td>
|
||||
<Td>
|
||||
<button onClick={() => removeDependency(d.b_id)} className="text-xs text-rose-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3 rounded bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
依赖必须形成<b>有向无环图</b>:新增依赖时服务层会校验是否产生循环引用,禁止依赖自己。
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="被依赖关系(谁依赖我)">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>依赖方</Th>
|
||||
<Th>关系</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dependOnMe.length === 0 && <EmptyRow colSpan={2} text="暂无其他模块依赖本模块" />}
|
||||
{dependOnMe.map((d) => {
|
||||
const m = modules.find((x) => x.b_id === d.b_module_id);
|
||||
return (
|
||||
<tr key={d.b_id}>
|
||||
<Td>
|
||||
{m ? `${m.b_name}(${m.b_code})` : `#${d.b_module_id}`}
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge tone="blue">depends on →</Badge>
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card title="提示">请选择左侧模块进行配置</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleNode({
|
||||
m,
|
||||
modules,
|
||||
selectedId,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
m: Module;
|
||||
modules: Module[];
|
||||
selectedId: number;
|
||||
onSelect: (id: number) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const children = modules.filter((x) => x.b_parent_id === m.b_id);
|
||||
const active = selectedId === m.b_id;
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => onSelect(m.b_id)}
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
active ? "bg-sky-50 font-medium text-sky-700" : "text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
<span className="text-slate-400">{m.b_icon ?? "▸"}</span>
|
||||
<span className="flex-1 truncate">{m.b_name}</span>
|
||||
<Badge tone={m.b_canuse === 1 ? "green" : "red"}>{m.b_canuse === 1 ? "启用" : "停用"}</Badge>
|
||||
</button>
|
||||
{children.length > 0 && (
|
||||
<ul className="mt-0.5 space-y-0.5">
|
||||
{children.map((c) => (
|
||||
<ModuleNode key={c.b_id} m={c} modules={modules} selectedId={selectedId} onSelect={onSelect} depth={depth + 1} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
// 节点编辑器:根据选中的树节点类型渲染对应表的配置表单
|
||||
// 字段/分组不在树中,作为实体内部明细,在实体面板内以表格维护
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
Autocode,
|
||||
Entity,
|
||||
Field,
|
||||
FieldGroup,
|
||||
Menu,
|
||||
Module,
|
||||
ModuleDependency,
|
||||
} from "../types";
|
||||
import type { TreeSelection } from "./MetaTree";
|
||||
import { Badge, Card, Field as F, Input, Select, TextArea, Toggle } from "./ui";
|
||||
|
||||
interface Props {
|
||||
selection: TreeSelection;
|
||||
modules: Module[];
|
||||
dependencies: ModuleDependency[];
|
||||
entities: Entity[];
|
||||
fields: Field[];
|
||||
groups: FieldGroup[];
|
||||
menus: Menu[];
|
||||
autocodes: Autocode[];
|
||||
onChangeModules: (m: Module[]) => void;
|
||||
onChangeDependencies: (d: ModuleDependency[]) => void;
|
||||
onChangeEntities: (e: Entity[]) => void;
|
||||
onChangeFields: (f: Field[]) => void;
|
||||
onChangeGroups: (g: FieldGroup[]) => void;
|
||||
onChangeMenus: (m: Menu[]) => void;
|
||||
onChangeAutocodes: (a: Autocode[]) => void;
|
||||
}
|
||||
|
||||
const MODULE_TYPES = [
|
||||
{ v: "platform", label: "platform 平台内置" },
|
||||
{ v: "tenant", label: "tenant 租户定制" },
|
||||
{ v: "business", label: "business 业务" },
|
||||
] as const;
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ v: "table", label: "table 简单实体" },
|
||||
{ v: "view", label: "view 复杂列表(视图)" },
|
||||
{ v: "query", label: "query 纯查询(只读)" },
|
||||
] as const;
|
||||
|
||||
const DELETE_POLICIES = [
|
||||
{ v: "restrict", label: "restrict 有引用拒绝删除" },
|
||||
{ v: "lifecycle", label: "lifecycle 草稿可删,正式作废" },
|
||||
{ v: "cascade", label: "cascade 随主表级联删除" },
|
||||
{ v: "hard", label: "hard 校验后物理删除" },
|
||||
] as const;
|
||||
|
||||
const MENU_TYPES = [
|
||||
{ v: "directory", label: "directory 目录" },
|
||||
{ v: "page", label: "page 固定页面" },
|
||||
{ v: "data", label: "data 数据模块" },
|
||||
{ v: "external", label: "external 外链" },
|
||||
] as const;
|
||||
|
||||
const RESET_TYPES = [
|
||||
{ v: "none", label: "none 不重置" },
|
||||
{ v: "day", label: "day 按天" },
|
||||
{ v: "month", label: "month 按月" },
|
||||
{ v: "year", label: "year 按年" },
|
||||
] as const;
|
||||
|
||||
const EDIT_TYPES = ["input", "select", "date", "datetime", "textarea", "number", "checkbox", "radio"] as const;
|
||||
const QUERY_OPS = ["eq", "like", "gt", "ge", "lt", "le", "in", "between"] as const;
|
||||
const FIELD_TYPES = ["varchar", "nvarchar(max)", "int", "bigint", "decimal", "datetime2", "bit", "text"] as const;
|
||||
|
||||
const BoolRow = ({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }) => (
|
||||
<div className="flex items-center gap-2 rounded border border-slate-200 bg-slate-50/50 px-3 py-2">
|
||||
<Toggle on={on} onChange={onChange} />
|
||||
<span className="text-sm text-slate-600">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function NodeEditor({
|
||||
selection,
|
||||
modules,
|
||||
dependencies,
|
||||
entities,
|
||||
fields,
|
||||
groups,
|
||||
menus,
|
||||
autocodes,
|
||||
onChangeModules,
|
||||
onChangeDependencies,
|
||||
onChangeEntities,
|
||||
onChangeFields,
|
||||
onChangeGroups,
|
||||
onChangeMenus,
|
||||
onChangeAutocodes,
|
||||
}: Props) {
|
||||
const moduleName = (id: number | null) => {
|
||||
const m = modules.find((x) => x.b_id === id);
|
||||
return m ? `${m.b_name}(${m.b_code})` : "-";
|
||||
};
|
||||
// ---------- 模块 ----------
|
||||
if (selection.type === "module") {
|
||||
const m = modules.find((x) => x.b_id === selection.id);
|
||||
if (!m) return <Empty />;
|
||||
const up = (patch: Partial<Module>) => onChangeModules(modules.map((x) => (x.b_id === m.b_id ? { ...x, ...patch } : x)));
|
||||
const inDeps = dependencies.filter((d) => d.b_module_id === m.b_id);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card title={`模块 s_module · ${m.b_name}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<F label="b_code 模块编码"><Input value={m.b_code} onChange={(e) => up({ b_code: e.target.value })} /></F>
|
||||
<F label="b_name 模块名称"><Input value={m.b_name} onChange={(e) => up({ b_name: e.target.value })} /></F>
|
||||
<F label="b_i18n 多语言 key"><Input value={m.b_i18n ?? ""} onChange={(e) => up({ b_i18n: e.target.value })} /></F>
|
||||
<F label="b_parent_id 父模块(业务分组)">
|
||||
<Select value={m.b_parent_id ?? ""} onChange={(e) => up({ b_parent_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">(无/顶级)</option>
|
||||
{modules.filter((x) => x.b_id !== m.b_id).map((x) => (
|
||||
<option key={x.b_id} value={x.b_id}>{x.b_name}({x.b_code})</option>
|
||||
))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_module_type 模块类型">
|
||||
<Select value={m.b_module_type} onChange={(e) => up({ b_module_type: e.target.value as Module["b_module_type"] })}>
|
||||
{MODULE_TYPES.map((t) => (<option key={t.v} value={t.v}>{t.label}</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_xh 排序号"><Input type="number" value={m.b_xh} onChange={(e) => up({ b_xh: Number(e.target.value) })} /></F>
|
||||
<F label="b_icon 图标 key"><Input value={m.b_icon ?? ""} onChange={(e) => up({ b_icon: e.target.value })} /></F>
|
||||
<F label="b_canuse 是否启用"><BoolRow on={m.b_canuse === 1} onChange={(v) => up({ b_canuse: v ? 1 : 0 })} label={m.b_canuse === 1 ? "启用" : "停用"} /></F>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<F label="b_bz 模块描述"><TextArea rows={2} value={m.b_bz ?? ""} onChange={(e) => up({ b_bz: e.target.value })} /></F>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={`模块依赖 s_module_dependency · 依赖 ${inDeps.length} 项`}>
|
||||
{inDeps.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">暂无依赖</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{inDeps.map((d) => (
|
||||
<li key={d.b_id} className="flex items-center justify-between rounded border border-slate-200 px-3 py-1.5 text-sm">
|
||||
<span>
|
||||
<Badge tone="blue">依赖</Badge>
|
||||
<span className="ml-2">{moduleName(d.b_depend_module_id)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onChangeDependencies(dependencies.filter((x) => x.b_id !== d.b_id))}
|
||||
className="text-xs text-rose-500 hover:underline"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 菜单 ----------
|
||||
if (selection.type === "menu") {
|
||||
const menu = menus.find((x) => x.b_id === selection.id);
|
||||
if (!menu) return <Empty />;
|
||||
const up = (patch: Partial<Menu>) => onChangeMenus(menus.map((x) => (x.b_id === menu.b_id ? { ...x, ...patch } : x)));
|
||||
return (
|
||||
<Card title={`菜单/页面 s_menu · ${menu.b_name}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<F label="b_code 菜单编码"><Input value={menu.b_code} onChange={(e) => up({ b_code: e.target.value })} /></F>
|
||||
<F label="b_name 菜单名称"><Input value={menu.b_name} onChange={(e) => up({ b_name: e.target.value })} /></F>
|
||||
<F label="b_menu_type 菜单类型">
|
||||
<Select value={menu.b_menu_type} onChange={(e) => up({ b_menu_type: e.target.value as Menu["b_menu_type"] })}>
|
||||
{MENU_TYPES.map((t) => (<option key={t.v} value={t.v}>{t.label}</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_module_id 所属模块">
|
||||
<Select value={menu.b_module_id ?? ""} onChange={(e) => up({ b_module_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">(公共/无模块)</option>
|
||||
{modules.map((x) => (<option key={x.b_id} value={x.b_id}>{x.b_name}({x.b_code})</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_parent_id 父菜单">
|
||||
<Select value={menu.b_parent_id ?? ""} onChange={(e) => up({ b_parent_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">(顶级)</option>
|
||||
{menus.filter((x) => x.b_id !== menu.b_id).map((x) => (
|
||||
<option key={x.b_id} value={x.b_id}>{x.b_name}({x.b_code})</option>
|
||||
))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_route 路由(固定页面/外链)">
|
||||
<Input value={menu.b_route ?? ""} placeholder="如 /ocean/booking/detail" onChange={(e) => up({ b_route: e.target.value || null })} />
|
||||
</F>
|
||||
<F label="b_entity_id 绑定实体(data 类型)">
|
||||
<Select value={menu.b_entity_id ?? ""} onChange={(e) => up({ b_entity_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">(无)</option>
|
||||
{entities.map((x) => (<option key={x.b_id} value={x.b_id}>{x.b_name}({x.b_code})</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_power_code 访问权限编码">
|
||||
<Input value={menu.b_power_code ?? ""} placeholder="如 ocean.booking.view" onChange={(e) => up({ b_power_code: e.target.value || null })} />
|
||||
</F>
|
||||
<F label="b_icon 图标"><Input value={menu.b_icon ?? ""} onChange={(e) => up({ b_icon: e.target.value || null })} /></F>
|
||||
<F label="b_xh 排序号"><Input type="number" value={menu.b_xh} onChange={(e) => up({ b_xh: Number(e.target.value) })} /></F>
|
||||
<F label="b_canmenu 是否显示在菜单"><BoolRow on={menu.b_canmenu === 1} onChange={(v) => up({ b_canmenu: v ? 1 : 0 })} label={menu.b_canmenu === 1 ? "显示" : "隐藏"} /></F>
|
||||
<F label="b_canuse 是否启用"><BoolRow on={menu.b_canuse === 1} onChange={(v) => up({ b_canuse: v ? 1 : 0 })} label={menu.b_canuse === 1 ? "启用" : "停用"} /></F>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 实体 ----------
|
||||
if (selection.type === "entity") {
|
||||
const e = entities.find((x) => x.b_id === selection.id);
|
||||
if (!e) return <Empty />;
|
||||
const up = (patch: Partial<Entity>) => onChangeEntities(entities.map((x) => (x.b_id === e.b_id ? { ...x, ...patch } : x)));
|
||||
const entityGroups = groups.filter((g) => g.b_entity_id === e.b_id);
|
||||
const entityFields = fields.filter((f) => f.b_entity_id === e.b_id);
|
||||
const entityAutocodes = autocodes.filter((a) => a.b_entity_id === e.b_id);
|
||||
const upGroup = (g: FieldGroup, patch: Partial<FieldGroup>) =>
|
||||
onChangeGroups(groups.map((x) => (x.b_id === g.b_id ? { ...x, ...patch } : x)));
|
||||
const addField = () => {
|
||||
const maxXh = Math.max(0, ...entityFields.map((x) => x.b_xh));
|
||||
const n = entityFields.length + 1;
|
||||
const f: Field = {
|
||||
b_id: Math.max(0, ...fields.map((x) => x.b_id)) + 1,
|
||||
b_entity_id: e.b_id,
|
||||
b_field: `new_field_${n}`,
|
||||
b_name: `新字段${n}`,
|
||||
b_i18n: null,
|
||||
b_type: "varchar",
|
||||
b_length: 50,
|
||||
b_precision: null,
|
||||
b_scale: null,
|
||||
b_nullable: 1,
|
||||
b_default_value: null,
|
||||
b_options: null,
|
||||
b_edittype: "input",
|
||||
b_required: 0,
|
||||
b_readonly: 0,
|
||||
b_disabled: 0,
|
||||
b_listvisible: 1,
|
||||
b_listwidth: null,
|
||||
b_queryable: 1,
|
||||
b_querytype: null,
|
||||
b_queryop: null,
|
||||
b_group_id: null,
|
||||
b_xh: maxXh + 1,
|
||||
b_canuse: 1,
|
||||
};
|
||||
onChangeFields([...fields, f]);
|
||||
};
|
||||
const addGroup = () => {
|
||||
const g: FieldGroup = {
|
||||
b_id: Math.max(0, ...groups.map((x) => x.b_id)) + 1,
|
||||
b_entity_id: e.b_id,
|
||||
b_parent_id: null,
|
||||
b_title: `新分组${entityGroups.length + 1}`,
|
||||
b_i18n: null,
|
||||
b_xh: Math.max(0, ...entityGroups.map((x) => x.b_xh)) + 1,
|
||||
b_canuse: 1,
|
||||
};
|
||||
onChangeGroups([...groups, g]);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card title={`实体 s_entity · ${e.b_name}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<F label="b_code 实体编码"><Input value={e.b_code} onChange={(ev) => up({ b_code: ev.target.value })} /></F>
|
||||
<F label="b_name 实体名称"><Input value={e.b_name} onChange={(ev) => up({ b_name: ev.target.value })} /></F>
|
||||
<F label="b_entity_type 实体类型">
|
||||
<Select value={e.b_entity_type} onChange={(ev) => up({ b_entity_type: ev.target.value as Entity["b_entity_type"] })}>
|
||||
{ENTITY_TYPES.map((t) => (<option key={t.v} value={t.v}>{t.label}</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_viewtable 查询数据源"><Input value={e.b_viewtable ?? ""} onChange={(ev) => up({ b_viewtable: ev.target.value })} /></F>
|
||||
<F label="b_savetable 保存目标(只读可空)">
|
||||
<Input value={e.b_savetable ?? ""} placeholder="纯查询实体留空" onChange={(ev) => up({ b_savetable: ev.target.value || null })} />
|
||||
</F>
|
||||
<F label="b_keyfield 保存主键字段"><Input value={e.b_keyfield ?? ""} onChange={(ev) => up({ b_keyfield: ev.target.value })} /></F>
|
||||
<F label="b_delete_policy 删除策略">
|
||||
<Select value={e.b_delete_policy} onChange={(ev) => up({ b_delete_policy: ev.target.value as Entity["b_delete_policy"] })}>
|
||||
{DELETE_POLICIES.map((p) => (<option key={p.v} value={p.v}>{p.label}</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_parent_id 父实体(主子表)">
|
||||
<Select value={e.b_parent_id ?? ""} onChange={(ev) => up({ b_parent_id: ev.target.value ? Number(ev.target.value) : null })}>
|
||||
<option value="">(无)</option>
|
||||
{entities.filter((x) => x.b_id !== e.b_id && x.b_module_id === e.b_module_id).map((x) => (
|
||||
<option key={x.b_id} value={x.b_id}>{x.b_name}({x.b_code})</option>
|
||||
))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_canuse 是否启用"><BoolRow on={e.b_canuse === 1} onChange={(v) => up({ b_canuse: v ? 1 : 0 })} label={e.b_canuse === 1 ? "启用" : "停用"} /></F>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<F label="b_bz 实体描述"><TextArea rows={2} value={e.b_bz ?? ""} onChange={(ev) => up({ b_bz: ev.target.value })} /></F>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
|
||||
<span>下属字段: <b>{entityFields.length}</b> 个</span>
|
||||
<span>分组: <b>{entityGroups.length}</b> 个</span>
|
||||
<span>编码规则: <b>{entityAutocodes.length}</b> 条</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 字段维护:实体内部明细,不进树,表格内联编辑 */}
|
||||
<Card
|
||||
title={`字段维护 s_field · ${entityFields.length} 个`}
|
||||
extra={<Badge tone="blue">实体明细</Badge>}
|
||||
>
|
||||
{entityFields.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">该实体暂无字段,点击下方按钮新增</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entityFields.map((f) => (
|
||||
<FieldRow key={f.b_id} f={f} fields={fields} groups={entityGroups} onChangeFields={onChangeFields} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addField}
|
||||
className="mt-3 rounded border border-sky-300 bg-sky-50 px-3 py-1.5 text-sm text-sky-700 transition-colors hover:bg-sky-100"
|
||||
>
|
||||
+ 新增字段
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
{/* 分组维护:实体内部明细,不进树 */}
|
||||
<Card
|
||||
title={`字段分组 s_field_group · ${entityGroups.length} 个`}
|
||||
extra={<Badge tone="blue">实体明细</Badge>}
|
||||
>
|
||||
{entityGroups.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">该实体暂无分组</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entityGroups.map((g) => (
|
||||
<div key={g.b_id} className="flex items-center gap-3 rounded border border-slate-200 px-3 py-2">
|
||||
<div className="flex-1">
|
||||
<Input value={g.b_title} onChange={(ev) => upGroup(g, { b_title: ev.target.value })} />
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<Input type="number" value={g.b_xh} onChange={(ev) => upGroup(g, { b_xh: Number(ev.target.value) })} />
|
||||
</div>
|
||||
<Toggle on={g.b_canuse === 1} onChange={(v) => upGroup(g, { b_canuse: v ? 1 : 0 })} />
|
||||
<span className="text-xs text-slate-500">启用</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChangeGroups(groups.filter((x) => x.b_id !== g.b_id))}
|
||||
className="text-xs text-rose-500 hover:underline"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addGroup}
|
||||
className="mt-3 rounded border border-sky-300 bg-sky-50 px-3 py-1.5 text-sm text-sky-700 transition-colors hover:bg-sky-100"
|
||||
>
|
||||
+ 新增分组
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 字段/分组(实体明细) ----------
|
||||
// 字段(s_field)与分组(s_field_group)不再是树节点,改在实体面板中以表格维护,
|
||||
// 详细表单见下方 FieldRow / FieldDetail 组件(实体分支复用)。
|
||||
|
||||
// ---------- 自动编码 ----------
|
||||
if (selection.type === "autocode") {
|
||||
const a = autocodes.find((x) => x.b_id === selection.id);
|
||||
if (!a) return <Empty />;
|
||||
const up = (patch: Partial<Autocode>) => onChangeAutocodes(autocodes.map((x) => (x.b_id === a.b_id ? { ...x, ...patch } : x)));
|
||||
return (
|
||||
<Card title={`自动编码 s_autocode · ${a.b_field}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<F label="b_entity_id 所属实体">
|
||||
<Select value={a.b_entity_id} onChange={(e) => up({ b_entity_id: Number(e.target.value) })}>
|
||||
{entities.map((x) => (<option key={x.b_id} value={x.b_id}>{x.b_name}({x.b_code})</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_field 目标字段"><Input value={a.b_field} onChange={(e) => up({ b_field: e.target.value })} /></F>
|
||||
<F label="b_prefix 前缀"><Input value={a.b_prefix} onChange={(e) => up({ b_prefix: e.target.value })} /></F>
|
||||
<F label="b_dateformat 日期格式"><Input value={a.b_dateformat} onChange={(e) => up({ b_dateformat: e.target.value })} /></F>
|
||||
<F label="b_separator 分隔符"><Input value={a.b_separator} onChange={(e) => up({ b_separator: e.target.value })} /></F>
|
||||
<F label="b_seqwidth 流水位宽"><Input type="number" value={a.b_seqwidth} onChange={(e) => up({ b_seqwidth: Number(e.target.value) })} /></F>
|
||||
<F label="b_resettype 重置周期">
|
||||
<Select value={a.b_resettype} onChange={(e) => up({ b_resettype: e.target.value as Autocode["b_resettype"] })}>
|
||||
{RESET_TYPES.map((t) => (<option key={t.v} value={t.v}>{t.label}</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_startvalue 起始值"><Input type="number" value={a.b_startvalue} onChange={(e) => up({ b_startvalue: Number(e.target.value) })} /></F>
|
||||
<F label="b_currentperiod 当前周期"><Input value={a.b_currentperiod ?? ""} onChange={(e) => up({ b_currentperiod: e.target.value || null })} /></F>
|
||||
<F label="b_currentvalue 当前流水值"><Input type="number" value={a.b_currentvalue} onChange={(e) => up({ b_currentvalue: Number(e.target.value) })} /></F>
|
||||
</div>
|
||||
<div className="mt-3 rounded bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
下一单号:<span className="font-mono">{a.b_prefix}{a.b_separator}{a.b_dateformat}{a.b_separator}{String(a.b_currentvalue + 1).padStart(a.b_seqwidth, "0")}</span>
|
||||
,取号使用原子自增 + 与业务保存同事务,避免并发重号。
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return <Card title="提示">请在左侧树中选择一个节点进行配置</Card>;
|
||||
}
|
||||
|
||||
// ---------- 字段维护行(实体明细表格) ----------
|
||||
function FieldRow({
|
||||
f,
|
||||
fields,
|
||||
groups,
|
||||
onChangeFields,
|
||||
}: {
|
||||
f: Field;
|
||||
fields: Field[];
|
||||
groups: FieldGroup[];
|
||||
onChangeFields: (fs: Field[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const up = (patch: Partial<Field>) => onChangeFields(fields.map((x) => (x.b_id === f.b_id ? { ...x, ...patch } : x)));
|
||||
return (
|
||||
<div className="rounded border border-slate-200">
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||
<button type="button" onClick={() => setOpen(!open)} className="w-5 text-center text-xs text-slate-400">
|
||||
{open ? "▾" : "▸"}
|
||||
</button>
|
||||
<div className="w-40">
|
||||
<Input value={f.b_name} onChange={(e) => up({ b_name: e.target.value })} />
|
||||
</div>
|
||||
<span className="w-36 truncate font-mono text-xs text-slate-400" title={f.b_field}>
|
||||
{f.b_field}
|
||||
</span>
|
||||
<div className="w-32">
|
||||
<Select value={f.b_type} onChange={(e) => up({ b_type: e.target.value })}>
|
||||
{FIELD_TYPES.map((t) => (<option key={t} value={t}>{t}</option>))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Toggle on={f.b_required === 1} onChange={(v) => up({ b_required: v ? 1 : 0 })} />
|
||||
<span className="text-xs text-slate-500">必填</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Toggle on={f.b_listvisible === 1} onChange={(v) => up({ b_listvisible: v ? 1 : 0 })} />
|
||||
<span className="text-xs text-slate-500">列表</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Toggle on={f.b_queryable === 1} onChange={(v) => up({ b_queryable: v ? 1 : 0 })} />
|
||||
<span className="text-xs text-slate-500">查询</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChangeFields(fields.filter((x) => x.b_id !== f.b_id))}
|
||||
className="ml-auto text-xs text-rose-500 hover:underline"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="border-t border-slate-100 px-3 py-3">
|
||||
<FieldDetail f={f} groups={groups} up={up} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 字段完整详情表单(行展开时显示) ----------
|
||||
function FieldDetail({
|
||||
f,
|
||||
groups,
|
||||
up,
|
||||
}: {
|
||||
f: Field;
|
||||
groups: FieldGroup[];
|
||||
up: (patch: Partial<Field>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<F label="b_field 物理列名"><Input value={f.b_field} onChange={(e) => up({ b_field: e.target.value })} /></F>
|
||||
<F label="b_name 字段名称"><Input value={f.b_name} onChange={(e) => up({ b_name: e.target.value })} /></F>
|
||||
<F label="b_i18n 多语言 key"><Input value={f.b_i18n ?? ""} onChange={(e) => up({ b_i18n: e.target.value })} /></F>
|
||||
<F label="b_type 存储类型">
|
||||
<Select value={f.b_type} onChange={(e) => up({ b_type: e.target.value })}>
|
||||
{FIELD_TYPES.map((t) => (<option key={t} value={t}>{t}</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_length 长度"><Input type="number" value={f.b_length ?? ""} onChange={(e) => up({ b_length: e.target.value ? Number(e.target.value) : null })} /></F>
|
||||
<F label="b_precision / b_scale">
|
||||
<div className="flex gap-1">
|
||||
<Input type="number" placeholder="precision" value={f.b_precision ?? ""} onChange={(e) => up({ b_precision: e.target.value ? Number(e.target.value) : null })} />
|
||||
<Input type="number" placeholder="scale" value={f.b_scale ?? ""} onChange={(e) => up({ b_scale: e.target.value ? Number(e.target.value) : null })} />
|
||||
</div>
|
||||
</F>
|
||||
<F label="b_edittype 编辑组件">
|
||||
<Select value={f.b_edittype ?? ""} onChange={(e) => up({ b_edittype: e.target.value || null })}>
|
||||
<option value="">(默认)</option>
|
||||
{EDIT_TYPES.map((t) => (<option key={t} value={t}>{t}</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_group_id 所属分组">
|
||||
<Select value={f.b_group_id ?? ""} onChange={(e) => up({ b_group_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">未分组</option>
|
||||
{groups.map((g) => (<option key={g.b_id} value={g.b_id}>{g.b_title}</option>))}
|
||||
</Select>
|
||||
</F>
|
||||
<F label="b_default_value 默认值"><Input value={f.b_default_value ?? ""} onChange={(e) => up({ b_default_value: e.target.value || null })} /></F>
|
||||
<F label="b_querytype / b_queryop">
|
||||
<div className="flex gap-1">
|
||||
<Select value={f.b_querytype ?? ""} onChange={(e) => up({ b_querytype: e.target.value || null })}>
|
||||
<option value="">-</option>
|
||||
<option value="input">input</option>
|
||||
<option value="select">select</option>
|
||||
<option value="date">date</option>
|
||||
</Select>
|
||||
<Select value={f.b_queryop ?? ""} onChange={(e) => up({ b_queryop: e.target.value || null })}>
|
||||
<option value="">-</option>
|
||||
{QUERY_OPS.map((op) => (<option key={op} value={op}>{op}</option>))}
|
||||
</Select>
|
||||
</div>
|
||||
</F>
|
||||
<F label="b_options 下拉选项(JSON)" className="col-span-2">
|
||||
<TextArea rows={2} value={f.b_options ?? ""} placeholder='[{"label":"宁波","value":"NGB"}]' onChange={(e) => up({ b_options: e.target.value || null })} />
|
||||
</F>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
<BoolRow label="b_required 必填" on={f.b_required === 1} onChange={(v) => up({ b_required: v ? 1 : 0 })} />
|
||||
<BoolRow label="b_readonly 只读" on={f.b_readonly === 1} onChange={(v) => up({ b_readonly: v ? 1 : 0 })} />
|
||||
<BoolRow label="b_disabled 禁用" on={f.b_disabled === 1} onChange={(v) => up({ b_disabled: v ? 1 : 0 })} />
|
||||
<BoolRow label="b_nullable 可空" on={f.b_nullable === 1} onChange={(v) => up({ b_nullable: v ? 1 : 0 })} />
|
||||
<BoolRow label="b_listvisible 列表显示" on={f.b_listvisible === 1} onChange={(v) => up({ b_listvisible: v ? 1 : 0 })} />
|
||||
<BoolRow label="b_queryable 可查询" on={f.b_queryable === 1} onChange={(v) => up({ b_queryable: v ? 1 : 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<F label="b_listwidth 列表列宽(px)"><Input type="number" value={f.b_listwidth ?? ""} onChange={(e) => up({ b_listwidth: e.target.value ? Number(e.target.value) : null })} /></F>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
// 权限配置:s_role + s_user_role + s_power + s_role_power(RBAC 三层)
|
||||
import { useState } from "react";
|
||||
import type { Menu, Power, Role, RolePower, UserRole } from "../types";
|
||||
import { Badge, Card, EmptyRow, Field, Input, Select, Td, Th, TextArea, Toggle } from "./ui";
|
||||
|
||||
interface Props {
|
||||
roles: Role[];
|
||||
userRoles: UserRole[];
|
||||
powers: Power[];
|
||||
rolePowers: RolePower[];
|
||||
menus: Menu[];
|
||||
onChangeRoles: (r: Role[]) => void;
|
||||
onChangeUserRoles: (r: UserRole[]) => void;
|
||||
onChangePowers: (p: Power[]) => void;
|
||||
onChangeRolePowers: (r: RolePower[]) => void;
|
||||
}
|
||||
|
||||
const POWER_TYPES = [
|
||||
{ v: "menu", label: "menu 页面" },
|
||||
{ v: "entity", label: "entity 实体" },
|
||||
{ v: "action", label: "action 动作" },
|
||||
{ v: "field", label: "field 字段" },
|
||||
] as const;
|
||||
|
||||
// 演示用用户列表(真实环境来自身份系统)
|
||||
const DEMO_USERS = [
|
||||
{ b_id: 1001, b_name: "张三(zhangsan)" },
|
||||
{ b_id: 1002, b_name: "李四(lisi)" },
|
||||
{ b_id: 1003, b_name: "王五(wangwu)" },
|
||||
];
|
||||
|
||||
export default function PermissionPanel({
|
||||
roles,
|
||||
userRoles,
|
||||
powers,
|
||||
rolePowers,
|
||||
menus,
|
||||
onChangeRoles,
|
||||
onChangeUserRoles,
|
||||
onChangePowers,
|
||||
onChangeRolePowers,
|
||||
}: Props) {
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<number>(roles[0]?.b_id ?? 701);
|
||||
const selectedRole = roles.find((r) => r.b_id === selectedRoleId) ?? roles[0];
|
||||
const [powerTypeFilter, setPowerTypeFilter] = useState<string>("all");
|
||||
|
||||
// ---- 角色 ----
|
||||
const addRole = () => {
|
||||
const id = Math.max(0, ...roles.map((r) => r.b_id)) + 1;
|
||||
onChangeRoles([
|
||||
...roles,
|
||||
{ b_id: id, b_code: `new_role_${id}`, b_name: "新角色", b_i18n: `role.new_role_${id}`, b_remark: null, b_canuse: 1 },
|
||||
]);
|
||||
setSelectedRoleId(id);
|
||||
};
|
||||
|
||||
const updateRole = (patch: Partial<Role>) => {
|
||||
if (!selectedRole) return;
|
||||
onChangeRoles(roles.map((r) => (r.b_id === selectedRole.b_id ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const removeRole = (id: number) => {
|
||||
onChangeRoles(roles.filter((r) => r.b_id !== id));
|
||||
onChangeRolePowers(rolePowers.filter((rp) => rp.b_role_id !== id));
|
||||
onChangeUserRoles(userRoles.filter((ur) => ur.b_role_id !== id));
|
||||
if (selectedRoleId === id) setSelectedRoleId(roles[0]?.b_id ?? 0);
|
||||
};
|
||||
|
||||
// ---- 权限点 ----
|
||||
const addPower = () => {
|
||||
const id = Math.max(0, ...powers.map((p) => p.b_id)) + 1;
|
||||
onChangePowers([
|
||||
...powers,
|
||||
{
|
||||
b_id: id,
|
||||
b_code: `new.power.${id}`,
|
||||
b_name: "新权限",
|
||||
b_i18n: `power.new.${id}`,
|
||||
b_power_type: "action",
|
||||
b_resource: null,
|
||||
b_canuse: 1,
|
||||
b_xh: powers.length + 1,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updatePower = (pid: number, patch: Partial<Power>) => {
|
||||
onChangePowers(powers.map((p) => (p.b_id === pid ? { ...p, ...patch } : p)));
|
||||
};
|
||||
|
||||
const removePower = (pid: number) => {
|
||||
onChangePowers(powers.filter((p) => p.b_id !== pid));
|
||||
onChangeRolePowers(rolePowers.filter((rp) => rp.b_power_id !== pid));
|
||||
};
|
||||
|
||||
// ---- 角色-权限 ----
|
||||
const rolePowersOfSelected = rolePowers.filter((rp) => rp.b_role_id === selectedRoleId);
|
||||
const grantedIds = new Set(rolePowersOfSelected.filter((rp) => rp.b_canuse === 1).map((rp) => rp.b_power_id));
|
||||
const deniedIds = new Set(rolePowersOfSelected.filter((rp) => rp.b_canuse === 0).map((rp) => rp.b_power_id));
|
||||
|
||||
const toggleRolePower = (powerId: number) => {
|
||||
const exist = rolePowersOfSelected.find((rp) => rp.b_power_id === powerId);
|
||||
if (exist) {
|
||||
onChangeRolePowers(
|
||||
rolePowers.map((rp) => (rp.b_id === exist.b_id ? { ...rp, b_canuse: rp.b_canuse === 1 ? 0 : 1 } : rp)),
|
||||
);
|
||||
} else {
|
||||
const id = Math.max(0, ...rolePowers.map((rp) => rp.b_id)) + 1;
|
||||
onChangeRolePowers([...rolePowers, { b_id: id, b_role_id: selectedRoleId, b_power_id: powerId, b_canuse: 1 }]);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 用户-角色 ----
|
||||
const addUserRole = (userId: number) => {
|
||||
const exist = userRoles.find((ur) => ur.b_user_id === userId && ur.b_role_id === selectedRoleId);
|
||||
if (exist) return;
|
||||
const id = Math.max(0, ...userRoles.map((ur) => ur.b_id)) + 1;
|
||||
onChangeUserRoles([...userRoles, { b_id: id, b_user_id: userId, b_role_id: selectedRoleId, b_canuse: 1 }]);
|
||||
};
|
||||
|
||||
const removeUserRole = (urid: number) => onChangeUserRoles(userRoles.filter((ur) => ur.b_id !== urid));
|
||||
|
||||
const usersOfRole = userRoles.filter((ur) => ur.b_role_id === selectedRoleId);
|
||||
const filteredPowers = powerTypeFilter === "all" ? powers : powers.filter((p) => p.b_power_type === powerTypeFilter);
|
||||
|
||||
const powerResourceLabel = (p: Power) => {
|
||||
if (p.b_power_type === "menu") {
|
||||
const menu = menus.find((m) => m.b_code === p.b_resource);
|
||||
return menu ? `${p.b_resource}(${menu.b_name})` : p.b_resource ?? "-";
|
||||
}
|
||||
return p.b_resource ?? "-";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[260px_1fr]">
|
||||
{/* 左:角色列表 */}
|
||||
<Card
|
||||
title="角色 s_role"
|
||||
extra={
|
||||
<button onClick={addRole} className="rounded bg-sky-500 px-2 py-1 text-xs text-white hover:bg-sky-600">
|
||||
+ 新增角色
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{roles.map((r) => {
|
||||
const active = r.b_id === selectedRoleId;
|
||||
return (
|
||||
<li key={r.b_id} className="group flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setSelectedRoleId(r.b_id)}
|
||||
className={`flex flex-1 items-center justify-between rounded px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
active ? "bg-sky-50 font-medium text-sky-700" : "text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<span>{r.b_name}</span>
|
||||
<Badge tone={r.b_canuse === 1 ? "green" : "red"}>{r.b_canuse === 1 ? "启用" : "停用"}</Badge>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeRole(r.b_id)}
|
||||
className="hidden text-xs text-rose-400 hover:text-rose-600 group-hover:block"
|
||||
>
|
||||
删
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
{/* 右:选中角色的权限与用户 */}
|
||||
{selectedRole ? (
|
||||
<div className="space-y-4">
|
||||
{/* 角色信息 */}
|
||||
<Card title={`角色信息 s_role #${selectedRole.b_id}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Field label="b_code 角色编码">
|
||||
<Input value={selectedRole.b_code} onChange={(e) => updateRole({ b_code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_name 角色名称">
|
||||
<Input value={selectedRole.b_name} onChange={(e) => updateRole({ b_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_i18n 多语言 key">
|
||||
<Input value={selectedRole.b_i18n ?? ""} onChange={(e) => updateRole({ b_i18n: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="b_canuse 是否启用">
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Toggle on={selectedRole.b_canuse === 1} onChange={(v) => updateRole({ b_canuse: v ? 1 : 0 })} />
|
||||
<span className="text-sm text-slate-500">{selectedRole.b_canuse === 1 ? "启用" : "停用"}</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Field label="b_remark 描述">
|
||||
<TextArea rows={2} value={selectedRole.b_remark ?? ""} onChange={(e) => updateRole({ b_remark: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 角色-权限 */}
|
||||
<Card title={`角色-权限 s_role_power(角色: ${selectedRole.b_name})`}>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{["all", "menu", "entity", "action", "field"].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setPowerTypeFilter(t)}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
powerTypeFilter === t ? "bg-sky-500 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-slate-400">
|
||||
权限点共 {powers.length} 个,已授权 {grantedIds.size} 个
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>b_code 权限编码</Th>
|
||||
<Th>b_name 名称</Th>
|
||||
<Th>类型</Th>
|
||||
<Th>b_resource 资源</Th>
|
||||
<Th>授权</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPowers.length === 0 && <EmptyRow colSpan={5} text="无匹配权限点" />}
|
||||
{filteredPowers.map((p) => (
|
||||
<tr key={p.b_id}>
|
||||
<Td className="font-mono text-xs">{p.b_code}</Td>
|
||||
<Td>{p.b_name}</Td>
|
||||
<Td>
|
||||
<Badge
|
||||
tone={
|
||||
p.b_power_type === "entity" ? "green" : p.b_power_type === "action" ? "blue" : p.b_power_type === "field" ? "amber" : "slate"
|
||||
}
|
||||
>
|
||||
{p.b_power_type}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="font-mono text-xs text-slate-500">{powerResourceLabel(p)}</Td>
|
||||
<Td>
|
||||
{grantedIds.has(p.b_id) ? (
|
||||
<button
|
||||
onClick={() => toggleRolePower(p.b_id)}
|
||||
className="rounded bg-emerald-500 px-2 py-1 text-xs text-white hover:bg-emerald-600"
|
||||
>
|
||||
✓ 已授权
|
||||
</button>
|
||||
) : deniedIds.has(p.b_id) ? (
|
||||
<button
|
||||
onClick={() => toggleRolePower(p.b_id)}
|
||||
className="rounded bg-rose-500 px-2 py-1 text-xs text-white hover:bg-rose-600"
|
||||
title="显式拒绝"
|
||||
>
|
||||
✕ 拒绝
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => toggleRolePower(p.b_id)}
|
||||
className="rounded border border-slate-300 px-2 py-1 text-xs text-slate-500 hover:border-sky-400 hover:text-sky-600"
|
||||
>
|
||||
未授权
|
||||
</button>
|
||||
)}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
授权 = 允许;拒绝 = 显式禁止(覆盖其他角色的允许);未授权 = 默认无权限。菜单显示继承页面访问权限,后端运行时必须再次校验同一权限编码。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* 角色-用户 */}
|
||||
<Card title={`角色-用户 s_user_role(角色: ${selectedRole.b_name})`}>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>用户</Th>
|
||||
<Th>b_role_id</Th>
|
||||
<Th>操作</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usersOfRole.length === 0 && <EmptyRow colSpan={3} text="该角色下暂无用户" />}
|
||||
{usersOfRole.map((ur) => {
|
||||
const user = DEMO_USERS.find((u) => u.b_id === ur.b_user_id);
|
||||
return (
|
||||
<tr key={ur.b_id}>
|
||||
<Td>{user ? user.b_name : `#${ur.b_user_id}`}</Td>
|
||||
<Td className="text-xs text-slate-500">{selectedRole.b_name}({selectedRole.b_code})</Td>
|
||||
<Td>
|
||||
<button onClick={() => removeUserRole(ur.b_id)} className="text-xs text-rose-500 hover:underline">
|
||||
移除
|
||||
</button>
|
||||
</Td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{DEMO_USERS.filter((u) => !usersOfRole.some((ur) => ur.b_user_id === u.b_id)).map((u) => (
|
||||
<button
|
||||
key={u.b_id}
|
||||
onClick={() => addUserRole(u.b_id)}
|
||||
className="rounded border border-dashed border-sky-300 px-2 py-1 text-xs text-sky-600 hover:bg-sky-50"
|
||||
>
|
||||
+ {u.b_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 权限点管理 */}
|
||||
<Card
|
||||
title="权限点 s_power(全局权限字典)"
|
||||
extra={
|
||||
<button onClick={addPower} className="rounded bg-sky-500 px-2 py-1 text-xs text-white hover:bg-sky-600">
|
||||
+ 新增权限点
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>编码</Th>
|
||||
<Th>名称</Th>
|
||||
<Th>类型</Th>
|
||||
<Th>资源</Th>
|
||||
<Th>操作</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{powers.map((p) => (
|
||||
<tr key={p.b_id}>
|
||||
<Td>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={p.b_code}
|
||||
onChange={(e) => updatePower(p.b_id, { b_code: e.target.value })}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<Input value={p.b_name} onChange={(e) => updatePower(p.b_id, { b_name: e.target.value })} />
|
||||
</Td>
|
||||
<Td>
|
||||
<Select
|
||||
value={p.b_power_type}
|
||||
onChange={(e) => updatePower(p.b_id, { b_power_type: e.target.value as Power["b_power_type"] })}
|
||||
>
|
||||
{POWER_TYPES.map((t) => (
|
||||
<option key={t.v} value={t.v}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Td>
|
||||
<Td>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={p.b_resource ?? ""}
|
||||
onChange={(e) => updatePower(p.b_id, { b_resource: e.target.value || null })}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<button onClick={() => removePower(p.b_id)} className="text-xs text-rose-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card title="提示">请选择左侧角色</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
// 共享 UI 小组件(Tailwind)
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const Card = ({
|
||||
title,
|
||||
extra,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<section className={`rounded-lg border border-slate-200 bg-white shadow-sm ${className}`}>
|
||||
<header className="flex items-center justify-between border-b border-slate-100 px-4 py-2.5">
|
||||
<h3 className="text-sm font-semibold text-slate-700">{title}</h3>
|
||||
{extra}
|
||||
</header>
|
||||
<div className="p-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export const Badge = ({ children, tone = "slate" }: { children: ReactNode; tone?: "slate" | "green" | "blue" | "amber" | "red" }) => {
|
||||
const tones: Record<string, string> = {
|
||||
slate: "bg-slate-100 text-slate-600",
|
||||
green: "bg-emerald-50 text-emerald-700",
|
||||
blue: "bg-sky-50 text-sky-700",
|
||||
amber: "bg-amber-50 text-amber-700",
|
||||
red: "bg-rose-50 text-rose-700",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium ${tones[tone]}`}>{children}</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const Field = ({
|
||||
label,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<label className={`block ${className}`}>
|
||||
<span className="mb-1 block text-xs font-medium text-slate-500">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
export const Input = (props: React.InputHTMLAttributes<HTMLInputElement>) => (
|
||||
<input
|
||||
{...props}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-200"
|
||||
/>
|
||||
);
|
||||
|
||||
export const Select = (props: React.SelectHTMLAttributes<HTMLSelectElement>) => (
|
||||
<select
|
||||
{...props}
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-sm outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-200"
|
||||
/>
|
||||
);
|
||||
|
||||
export const TextArea = (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => (
|
||||
<textarea
|
||||
{...props}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-200"
|
||||
/>
|
||||
);
|
||||
|
||||
export const Toggle = ({ on, onChange, label }: { on: boolean; onChange: (v: boolean) => void; label?: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!on)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
|
||||
on ? "bg-sky-500" : "bg-slate-300"
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
on ? "translate-x-4.5" : "translate-x-0.5"
|
||||
}`}
|
||||
style={{ transform: on ? "translateX(18px)" : "translateX(2px)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const Td = ({
|
||||
children,
|
||||
className = "",
|
||||
colSpan,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
colSpan?: number;
|
||||
}) => (
|
||||
<td colSpan={colSpan} className={`border-b border-slate-100 px-3 py-2 text-sm text-slate-700 ${className}`}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
|
||||
export const Th = ({ children }: { children: ReactNode }) => (
|
||||
<th className="border-b border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs font-semibold text-slate-500">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
|
||||
export const EmptyRow = ({ colSpan, text }: { colSpan: number; text: string }) => (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-3 py-6 text-center text-sm text-slate-400">
|
||||
{text}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -1,345 +0,0 @@
|
||||
// ============================================================
|
||||
// 演示数据:以「海运订舱 ocean」模块为例,覆盖全部 17 张表
|
||||
// ============================================================
|
||||
import type {
|
||||
Module,
|
||||
ModuleDependency,
|
||||
Entity,
|
||||
Field,
|
||||
FieldGroup,
|
||||
Menu,
|
||||
Autocode,
|
||||
I18n,
|
||||
Role,
|
||||
UserRole,
|
||||
Power,
|
||||
RolePower,
|
||||
} from "../types";
|
||||
|
||||
let seq = 1000;
|
||||
const nid = () => ++seq;
|
||||
|
||||
// ---------- s_module 模块(业务分组树) ----------
|
||||
export const initialModules: Module[] = [
|
||||
{
|
||||
b_id: 1,
|
||||
b_code: "master-data",
|
||||
b_name: "基础资料",
|
||||
b_i18n: "module.master-data",
|
||||
b_parent_id: null,
|
||||
b_module_type: "platform",
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
b_icon: "database",
|
||||
b_bz: "客户、港口、航线、币种等基础主数据",
|
||||
},
|
||||
{
|
||||
b_id: 2,
|
||||
b_code: "ocean",
|
||||
b_name: "海运业务",
|
||||
b_i18n: "module.ocean",
|
||||
b_parent_id: null,
|
||||
b_module_type: "business",
|
||||
b_canuse: 1,
|
||||
b_xh: 20,
|
||||
b_icon: "ship",
|
||||
b_bz: "海运订舱、集装箱、运输等业务能力",
|
||||
},
|
||||
{
|
||||
b_id: 3,
|
||||
b_code: "finance",
|
||||
b_name: "财务结算",
|
||||
b_i18n: "module.finance",
|
||||
b_parent_id: null,
|
||||
b_module_type: "business",
|
||||
b_canuse: 1,
|
||||
b_xh: 30,
|
||||
b_icon: "wallet",
|
||||
b_bz: "费用、账单、发票、收款付款",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------- s_module_dependency 模块依赖(DAG) ----------
|
||||
export const initialDependencies: ModuleDependency[] = [
|
||||
{ b_id: 1, b_module_id: 2, b_depend_module_id: 1 }, // ocean 依赖 master-data
|
||||
{ b_id: 2, b_module_id: 3, b_depend_module_id: 1 }, // finance 依赖 master-data
|
||||
{ b_id: 3, b_module_id: 3, b_depend_module_id: 2 }, // finance 依赖 ocean
|
||||
];
|
||||
|
||||
// ---------- s_entity 实体 ----------
|
||||
export const initialEntities: Entity[] = [
|
||||
{
|
||||
b_id: 101,
|
||||
b_module_id: 2,
|
||||
b_parent_id: null,
|
||||
b_code: "ocean_booking",
|
||||
b_name: "海运订舱",
|
||||
b_i18n: "entity.ocean_booking",
|
||||
b_viewtable: "ocean_booking",
|
||||
b_savetable: "ocean_booking",
|
||||
b_keyfield: "b_id",
|
||||
b_entity_type: "table",
|
||||
b_delete_policy: "lifecycle",
|
||||
b_canuse: 1,
|
||||
b_xh: 1,
|
||||
b_bz: "海运订舱主表(简单实体:查询源=保存源)",
|
||||
},
|
||||
{
|
||||
b_id: 102,
|
||||
b_module_id: 2,
|
||||
b_parent_id: 101,
|
||||
b_code: "ocean_booking_container",
|
||||
b_name: "订舱集装箱",
|
||||
b_i18n: "entity.ocean_booking_container",
|
||||
b_viewtable: "ocean_booking_container",
|
||||
b_savetable: "ocean_booking_container",
|
||||
b_keyfield: "b_id",
|
||||
b_entity_type: "table",
|
||||
b_delete_policy: "cascade",
|
||||
b_canuse: 1,
|
||||
b_xh: 2,
|
||||
b_bz: "订舱从表:随主表级联删除",
|
||||
},
|
||||
{
|
||||
b_id: 103,
|
||||
b_module_id: 2,
|
||||
b_parent_id: null,
|
||||
b_code: "ocean_booking_view",
|
||||
b_name: "订舱列表(带费用)",
|
||||
b_i18n: "entity.ocean_booking_view",
|
||||
b_viewtable: "v_booking_with_fee",
|
||||
b_savetable: null,
|
||||
b_keyfield: null,
|
||||
b_entity_type: "view",
|
||||
b_delete_policy: "restrict",
|
||||
b_canuse: 1,
|
||||
b_xh: 3,
|
||||
b_bz: "复杂列表:查询源为只读视图,无保存目标",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------- s_field_group 字段分组 ----------
|
||||
export const initialFieldGroups: FieldGroup[] = [
|
||||
{ b_id: 201, b_entity_id: 101, b_parent_id: null, b_title: "基本信息", b_i18n: "group.basic", b_xh: 0, b_canuse: 1 },
|
||||
{ b_id: 202, b_entity_id: 101, b_parent_id: null, b_title: "运输信息", b_i18n: "group.transport", b_xh: 1, b_canuse: 1 },
|
||||
{ b_id: 203, b_entity_id: 101, b_parent_id: null, b_title: "费用信息", b_i18n: "group.finance", b_xh: 2, b_canuse: 1 },
|
||||
];
|
||||
|
||||
// ---------- s_field 字段(定义 + 列表/表单/查询 合并单表) ----------
|
||||
const f = (
|
||||
b_id: number,
|
||||
b_entity_id: number,
|
||||
b_field: string,
|
||||
b_name: string,
|
||||
b_type: string,
|
||||
extra: Partial<Field> = {},
|
||||
): Field => ({
|
||||
b_id,
|
||||
b_entity_id,
|
||||
b_field,
|
||||
b_name,
|
||||
b_i18n: `field.${b_field}`,
|
||||
b_type,
|
||||
b_length: null,
|
||||
b_precision: null,
|
||||
b_scale: null,
|
||||
b_nullable: 1,
|
||||
b_default_value: null,
|
||||
b_options: null,
|
||||
b_edittype: null,
|
||||
b_required: 0,
|
||||
b_readonly: 0,
|
||||
b_disabled: 0,
|
||||
b_listvisible: 1,
|
||||
b_listwidth: null,
|
||||
b_queryable: 0,
|
||||
b_querytype: null,
|
||||
b_queryop: null,
|
||||
b_group_id: null,
|
||||
b_xh: 0,
|
||||
b_canuse: 1,
|
||||
...extra,
|
||||
});
|
||||
|
||||
export const initialFields: Field[] = [
|
||||
// 订舱主表 ocean_booking 字段
|
||||
f(301, 101, "b_ywno", "业务单号", "varchar", {
|
||||
b_length: 50, b_required: 1, b_readonly: 0, b_listvisible: 1, b_listwidth: 140,
|
||||
b_queryable: 1, b_querytype: "input", b_queryop: "like", b_group_id: 201, b_xh: 1,
|
||||
}),
|
||||
f(302, 101, "b_customer_id", "客户", "bigint", {
|
||||
b_edittype: "select", b_options: JSON.stringify([
|
||||
{ label: "宁波远洋货代", value: "1" },
|
||||
{ label: "上海国际物流", value: "2" },
|
||||
]),
|
||||
b_required: 1, b_listvisible: 1, b_listwidth: 160, b_queryable: 1,
|
||||
b_querytype: "select", b_queryop: "eq", b_group_id: 201, b_xh: 2,
|
||||
}),
|
||||
f(303, 101, "b_ywtype", "业务类型", "varchar", {
|
||||
b_length: 50, b_edittype: "select", b_options: JSON.stringify([
|
||||
{ label: "整箱出口", value: "FCL" },
|
||||
{ label: "拼箱出口", value: "LCL" },
|
||||
]),
|
||||
b_listvisible: 1, b_listwidth: 100, b_queryable: 1, b_querytype: "select",
|
||||
b_queryop: "eq", b_group_id: 201, b_xh: 3,
|
||||
}),
|
||||
f(304, 101, "b_loadport", "起运港", "varchar", {
|
||||
b_length: 50, b_edittype: "select", b_options: JSON.stringify([
|
||||
{ label: "宁波", value: "NGB" },
|
||||
{ label: "上海", value: "SHA" },
|
||||
{ label: "深圳", value: "SZX" },
|
||||
]),
|
||||
b_required: 1, b_listvisible: 1, b_listwidth: 100, b_queryable: 1,
|
||||
b_querytype: "select", b_queryop: "eq", b_group_id: 202, b_xh: 4,
|
||||
}),
|
||||
f(305, 101, "b_disport", "目的港", "varchar", {
|
||||
b_length: 50, b_edittype: "select", b_options: JSON.stringify([
|
||||
{ label: "汉堡", value: "HAM" },
|
||||
{ label: "鹿特丹", value: "RTM" },
|
||||
]),
|
||||
b_required: 1, b_listvisible: 1, b_listwidth: 100, b_group_id: 202, b_xh: 5,
|
||||
}),
|
||||
f(306, 101, "b_etd", "预计开船日", "datetime2", {
|
||||
b_edittype: "date", b_listvisible: 1, b_listwidth: 120, b_queryable: 1,
|
||||
b_querytype: "date", b_queryop: "between", b_group_id: 202, b_xh: 6,
|
||||
}),
|
||||
f(307, 101, "b_eta", "预计到港日", "datetime2", {
|
||||
b_edittype: "date", b_listvisible: 1, b_listwidth: 120, b_group_id: 202, b_xh: 7,
|
||||
}),
|
||||
f(308, 101, "b_ctns", "件数", "int", {
|
||||
b_edittype: "number", b_listvisible: 1, b_listwidth: 80, b_group_id: 202, b_xh: 8,
|
||||
}),
|
||||
f(309, 101, "b_gw", "毛重(KG)", "decimal", {
|
||||
b_precision: 18, b_scale: 3, b_edittype: "number", b_listvisible: 1,
|
||||
b_listwidth: 100, b_group_id: 202, b_xh: 9,
|
||||
}),
|
||||
f(310, 101, "b_vol", "体积(CBM)", "decimal", {
|
||||
b_precision: 18, b_scale: 3, b_edittype: "number", b_listvisible: 1,
|
||||
b_listwidth: 100, b_group_id: 202, b_xh: 10,
|
||||
}),
|
||||
f(311, 101, "b_freight", "运费总额", "decimal", {
|
||||
b_precision: 18, b_scale: 2, b_edittype: "number", b_readonly: 1,
|
||||
b_listvisible: 1, b_listwidth: 110, b_group_id: 203, b_xh: 11,
|
||||
}),
|
||||
f(312, 101, "b_state", "状态", "varchar", {
|
||||
b_length: 20, b_edittype: "select", b_options: JSON.stringify([
|
||||
{ label: "草稿", value: "draft" },
|
||||
{ label: "已确认", value: "confirmed" },
|
||||
{ label: "已截关", value: "closed" },
|
||||
]),
|
||||
b_readonly: 1, b_listvisible: 1, b_listwidth: 90, b_queryable: 1,
|
||||
b_querytype: "select", b_queryop: "eq", b_group_id: 203, b_xh: 12,
|
||||
}),
|
||||
f(313, 101, "b_remark", "备注", "nvarchar(max)", {
|
||||
b_edittype: "textarea", b_listvisible: 0, b_group_id: 201, b_xh: 13,
|
||||
}),
|
||||
// 订舱集装箱从表字段
|
||||
f(321, 102, "b_con_no", "箱号", "varchar", { b_length: 50, b_required: 1, b_listvisible: 1, b_listwidth: 140, b_group_id: null, b_xh: 1 }),
|
||||
f(322, 102, "b_con_type", "箱型", "varchar", {
|
||||
b_length: 20, b_edittype: "select", b_options: JSON.stringify([
|
||||
{ label: "20GP", value: "20GP" },
|
||||
{ label: "40GP", value: "40GP" },
|
||||
{ label: "40HQ", value: "40HQ" },
|
||||
]),
|
||||
b_required: 1, b_listvisible: 1, b_listwidth: 90, b_group_id: null, b_xh: 2,
|
||||
}),
|
||||
f(323, 102, "b_sealno", "封号", "varchar", { b_length: 50, b_listvisible: 1, b_listwidth: 120, b_group_id: null, b_xh: 3 }),
|
||||
f(324, 102, "b_con_gw", "箱毛重", "decimal", { b_precision: 18, b_scale: 6, b_edittype: "number", b_listvisible: 1, b_listwidth: 100, b_group_id: null, b_xh: 4 }),
|
||||
];
|
||||
|
||||
// ---------- s_menu 菜单/页面(纯导航树,挂模块) ----------
|
||||
export const initialMenus: Menu[] = [
|
||||
{ b_id: 401, b_parent_id: null, b_module_id: 2, b_code: "ocean_menu", b_name: "海运业务", b_i18n: "menu.ocean_menu", b_menu_type: "directory", b_route: null, b_entity_id: null, b_power_code: null, b_icon: "ship", b_canmenu: 1, b_xh: 1, b_canuse: 1 },
|
||||
{ b_id: 402, b_parent_id: 401, b_module_id: 2, b_code: "ocean_booking_list", b_name: "订舱列表", b_i18n: "menu.ocean_booking_list", b_menu_type: "data", b_route: null, b_entity_id: 103, b_power_code: "ocean.booking.view", b_icon: null, b_canmenu: 1, b_xh: 1, b_canuse: 1 },
|
||||
{ b_id: 403, b_parent_id: 401, b_module_id: 2, b_code: "ocean_booking_page", b_name: "订舱详情页", b_i18n: "menu.ocean_booking_page", b_menu_type: "page", b_route: "/ocean/booking/detail", b_entity_id: 101, b_power_code: "ocean.booking.view", b_icon: null, b_canmenu: 1, b_xh: 2, b_canuse: 1 },
|
||||
];
|
||||
|
||||
// ---------- s_autocode 自动编码 ----------
|
||||
export const initialAutocodes: Autocode[] = [
|
||||
{
|
||||
b_id: 501,
|
||||
b_entity_id: 101,
|
||||
b_field: "b_ywno",
|
||||
b_prefix: "BK",
|
||||
b_dateformat: "yyyyMM",
|
||||
b_separator: "-",
|
||||
b_seqwidth: 5,
|
||||
b_resettype: "month",
|
||||
b_startvalue: 1,
|
||||
b_currentperiod: "202608",
|
||||
b_currentvalue: 128,
|
||||
b_canuse: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------- s_i18n 多语言 ----------
|
||||
export const initialI18ns: I18n[] = [
|
||||
{ b_id: 601, b_key: "module.ocean", b_locale: "zh-CN", b_value: "海运业务", b_i18ntype: "module", b_canuse: 1 },
|
||||
{ b_id: 602, b_key: "module.ocean", b_locale: "en-US", b_value: "Ocean", b_i18ntype: "module", b_canuse: 1 },
|
||||
{ b_id: 603, b_key: "entity.ocean_booking", b_locale: "zh-CN", b_value: "海运订舱", b_i18ntype: "entity", b_canuse: 1 },
|
||||
{ b_id: 604, b_key: "entity.ocean_booking", b_locale: "en-US", b_value: "Ocean Booking", b_i18ntype: "entity", b_canuse: 1 },
|
||||
{ b_id: 605, b_key: "field.b_ywno", b_locale: "zh-CN", b_value: "业务单号", b_i18ntype: "field", b_canuse: 1 },
|
||||
{ b_id: 606, b_key: "field.b_ywno", b_locale: "en-US", b_value: "Business No.", b_i18ntype: "field", b_canuse: 1 },
|
||||
{ b_id: 607, b_key: "menu.ocean_booking_list", b_locale: "zh-CN", b_value: "订舱列表", b_i18ntype: "menu", b_canuse: 1 },
|
||||
{ b_id: 608, b_key: "menu.ocean_booking_list", b_locale: "en-US", b_value: "Booking List", b_i18ntype: "menu", b_canuse: 1 },
|
||||
];
|
||||
|
||||
// ---------- s_role 角色 ----------
|
||||
export const initialRoles: Role[] = [
|
||||
{ b_id: 701, b_code: "ops", b_name: "操作员", b_i18n: "role.ops", b_remark: "日常业务操作", b_canuse: 1 },
|
||||
{ b_id: 702, b_code: "sales", b_name: "销售", b_i18n: "role.sales", b_remark: "客户与报价", b_canuse: 1 },
|
||||
{ b_id: 703, b_code: "finance", b_name: "财务", b_i18n: "role.finance", b_remark: "费用与结算", b_canuse: 1 },
|
||||
{ b_id: 704, b_code: "admin", b_name: "系统管理员", b_i18n: "role.admin", b_remark: "元数据与权限管理", b_canuse: 1 },
|
||||
];
|
||||
|
||||
// ---------- s_user_role 用户-角色 ----------
|
||||
export const initialUserRoles: UserRole[] = [
|
||||
{ b_id: 1, b_user_id: 1001, b_role_id: 701, b_canuse: 1 }, // 张三 -> 操作员
|
||||
{ b_id: 2, b_user_id: 1001, b_role_id: 702, b_canuse: 1 }, // 张三 -> 销售
|
||||
{ b_id: 3, b_user_id: 1002, b_role_id: 703, b_canuse: 1 }, // 李四 -> 财务
|
||||
{ b_id: 4, b_user_id: 1003, b_role_id: 704, b_canuse: 1 }, // 王五 -> 管理员
|
||||
];
|
||||
|
||||
// ---------- s_power 权限点 ----------
|
||||
export const initialPowers: Power[] = [
|
||||
{ b_id: 801, b_code: "ocean.booking.view", b_name: "查看订舱", b_i18n: "power.ocean.booking.view", b_power_type: "entity", b_resource: "ocean_booking", b_canuse: 1, b_xh: 1 },
|
||||
{ b_id: 802, b_code: "ocean.booking.create", b_name: "新建订舱", b_i18n: "power.ocean.booking.create", b_power_type: "action", b_resource: "ocean_booking", b_canuse: 1, b_xh: 2 },
|
||||
{ b_id: 803, b_code: "ocean.booking.update", b_name: "修改订舱", b_i18n: "power.ocean.booking.update", b_power_type: "action", b_resource: "ocean_booking", b_canuse: 1, b_xh: 3 },
|
||||
{ b_id: 804, b_code: "ocean.booking.confirm", b_name: "确认订舱", b_i18n: "power.ocean.booking.confirm", b_power_type: "action", b_resource: "ocean_booking", b_canuse: 1, b_xh: 4 },
|
||||
{ b_id: 805, b_code: "ocean.booking.field.freight", b_name: "查看运费字段", b_i18n: "power.ocean.booking.field.freight", b_power_type: "field", b_resource: "ocean_booking.b_freight", b_canuse: 1, b_xh: 5 },
|
||||
{ b_id: 806, b_code: "finance.fee.view", b_name: "查看费用", b_i18n: "power.finance.fee.view", b_power_type: "entity", b_resource: "finance_fee", b_canuse: 1, b_xh: 1 },
|
||||
{ b_id: 807, b_code: "meta.module.config", b_name: "模块元数据配置", b_i18n: "power.meta.module.config", b_power_type: "menu", b_resource: "meta_config", b_canuse: 1, b_xh: 1 },
|
||||
];
|
||||
|
||||
// ---------- s_role_power 角色-权限 ----------
|
||||
export const initialRolePowers: RolePower[] = [
|
||||
{ b_id: 1, b_role_id: 701, b_power_id: 801, b_canuse: 1 }, // 操作员: 查看订舱
|
||||
{ b_id: 2, b_role_id: 701, b_power_id: 802, b_canuse: 1 }, // 操作员: 新建订舱
|
||||
{ b_id: 3, b_role_id: 701, b_power_id: 803, b_canuse: 1 }, // 操作员: 修改订舱
|
||||
{ b_id: 4, b_role_id: 701, b_power_id: 804, b_canuse: 1 }, // 操作员: 确认订舱
|
||||
{ b_id: 5, b_role_id: 701, b_power_id: 805, b_canuse: 0 }, // 操作员: 运费字段不可见
|
||||
{ b_id: 6, b_role_id: 702, b_power_id: 801, b_canuse: 1 }, // 销售: 查看订舱
|
||||
{ b_id: 7, b_role_id: 702, b_power_id: 802, b_canuse: 1 }, // 销售: 新建订舱
|
||||
{ b_id: 8, b_role_id: 703, b_power_id: 805, b_canuse: 1 }, // 财务: 查看运费字段
|
||||
{ b_id: 9, b_role_id: 703, b_power_id: 806, b_canuse: 1 }, // 财务: 查看费用
|
||||
{ b_id: 10, b_role_id: 704, b_power_id: 807, b_canuse: 1 }, // 管理员: 元数据配置
|
||||
{ b_id: 11, b_role_id: 704, b_power_id: 801, b_canuse: 1 },
|
||||
{ b_id: 12, b_role_id: 704, b_power_id: 802, b_canuse: 1 },
|
||||
{ b_id: 13, b_role_id: 704, b_power_id: 803, b_canuse: 1 },
|
||||
{ b_id: 14, b_role_id: 704, b_power_id: 804, b_canuse: 1 },
|
||||
];
|
||||
|
||||
// ---------- 辅助工厂:新增记录时生成 ID ----------
|
||||
export const nextId = (): number => nid();
|
||||
|
||||
export const moduleName = (modules: Module[], id: number | null): string => {
|
||||
if (id == null) return "-";
|
||||
const m = modules.find((x) => x.b_id === id);
|
||||
return m ? `${m.b_name}(${m.b_code})` : `#${id}`;
|
||||
};
|
||||
|
||||
export const entityName = (entities: Entity[], id: number | null): string => {
|
||||
if (id == null) return "-";
|
||||
const e = entities.find((x) => x.b_id === id);
|
||||
return e ? e.b_name : `#${id}`;
|
||||
};
|
||||
@@ -1,7 +1,145 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #26344a;
|
||||
background: #f3f5f8;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 1120px; background: #f3f5f8; }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.app { min-height: 100vh; }
|
||||
.topbar { height: 64px; display: flex; align-items: center; gap: 32px; padding: 0 24px; background: #fff; border-bottom: 1px solid #e1e6ed; }
|
||||
.brand { width: 250px; display: flex; align-items: center; gap: 11px; }
|
||||
.brand .logo { width: 32px; height: 32px; display: grid; place-items: center; color: #fff; background: #2468d8; border-radius: 6px; }
|
||||
.brand b { display: block; color: #24344e; font-size: 14px; }
|
||||
.brand small { display: block; margin-top: 3px; color: #98a4b4; font-size: 10px; }
|
||||
.top-module { display: flex; align-items: center; gap: 10px; padding-left: 24px; border-left: 1px solid #edf0f4; font-size: 11px; }
|
||||
.top-module span { color: #98a4b4; }
|
||||
.top-module b { color: #586a83; }
|
||||
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 16px; color: #7b899d; font-size: 10px; }
|
||||
.save-state i { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: #32a879; }
|
||||
.save-state.dirty i { background: #d99736; }
|
||||
.save { padding: 7px 12px; border: 1px solid #d9e0e9; border-radius: 5px; background: #fff; color: #576a84; font-size: 10px; font-weight: 600; }
|
||||
.save:disabled, .primary:disabled { cursor: default; opacity: .45; }
|
||||
.avatar { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; background: #e7effd; color: #2463c9; font-size: 11px; font-weight: 700; }
|
||||
|
||||
.layout { display: grid; grid-template-columns: 286px minmax(760px, 1fr); min-height: calc(100vh - 64px); }
|
||||
.tree-panel { position: relative; padding: 20px 13px; background: #fff; border-right: 1px solid #e1e6ed; }
|
||||
.tree-title { display: flex; justify-content: space-between; align-items: center; padding: 0 9px 15px; }
|
||||
.tree-title b { display: block; color: #40526d; font-size: 13px; }
|
||||
.tree-title small { display: block; margin-top: 4px; color: #9aa6b5; font-size: 9px; }
|
||||
.icon-btn { width: 30px; height: 30px; display: grid; place-items: center; padding: 0; border: 1px solid #dce3ec; border-radius: 5px; background: #fff; color: #60728b; }
|
||||
.icon-btn:hover { border-color: #9bb7e6; color: #2769d4; background: #f7faff; }
|
||||
.add-wrap { position: relative; }
|
||||
.add-menu { position: absolute; z-index: 5; top: 35px; right: 0; width: 138px; padding: 5px; border: 1px solid #dde4ed; border-radius: 6px; background: #fff; box-shadow: 0 10px 28px rgba(37, 52, 74, .14); }
|
||||
.add-menu button { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px; border: 0; border-radius: 4px; background: transparent; color: #586a83; text-align: left; font-size: 10px; }
|
||||
.add-menu button:hover { color: #2468d8; background: #f1f6fe; }
|
||||
.tree-search { height: 34px; display: flex; align-items: center; gap: 7px; margin-bottom: 11px; padding: 0 9px; border: 1px solid #dfe5ed; border-radius: 5px; color: #9aa6b5; }
|
||||
.tree-search:focus-within { border-color: #83a6df; box-shadow: 0 0 0 2px rgba(36, 104, 216, .07); }
|
||||
.tree-search input { min-width: 0; flex: 1; border: 0; outline: 0; color: #50627c; font-size: 10px; }
|
||||
.tree-search button { display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: #99a5b5; }
|
||||
.module-tree { min-height: 400px; }
|
||||
.tree-row { width: 100%; min-height: 43px; display: flex; align-items: center; gap: 8px; padding-top: 6px; padding-right: 8px; padding-bottom: 6px; border: 0; border-radius: 5px; background: transparent; color: #5f7089; text-align: left; }
|
||||
.tree-row:hover { background: #f5f8fc; }
|
||||
.tree-row.selected { color: #2468d8; background: #edf4ff; }
|
||||
.tree-icon { width: 17px; display: grid; place-items: center; flex: 0 0 auto; }
|
||||
.tree-icon.directory { color: #c78628; }
|
||||
.tree-icon.page { color: #6c64b6; }
|
||||
.tree-icon.data { color: #39847b; }
|
||||
.tree-name { min-width: 0; flex: 1; overflow: hidden; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.tree-name small { display: block; margin-top: 2px; overflow: hidden; color: #a0abb9; font-size: 8px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.tree-type { padding: 2px 4px; border-radius: 3px; background: #eef1f5; color: #9aa5b3; font-size: 8px; }
|
||||
.row-more { display: none; color: #7e8ca0; }
|
||||
.tree-row:hover .row-more, .tree-row.selected .row-more { display: block; }
|
||||
.tree-row:hover .tree-type, .tree-row.selected .tree-type { display: none; }
|
||||
.tree-legend { display: flex; gap: 13px; margin: 17px 9px 0; padding-top: 13px; border-top: 1px solid #edf0f4; color: #8c98a9; font-size: 9px; }
|
||||
.tree-legend span { display: flex; align-items: center; gap: 5px; }
|
||||
.dot { width: 6px; height: 6px; display: inline-block; border-radius: 50%; }
|
||||
.dot.directory { background: #cf9137; }.dot.page { background: #7770c5; }.dot.data { background: #459489; }
|
||||
.tree-help { display: flex; align-items: flex-start; gap: 7px; margin: 14px 5px 0; padding: 10px; border-radius: 5px; background: #f7f9fc; color: #7e8da2; font-size: 9px; line-height: 1.6; }
|
||||
.tree-help svg { flex: 0 0 auto; margin-top: 1px; color: #557fbe; }
|
||||
|
||||
.detail { width: 100%; max-width: 1280px; margin: 0 auto; padding: 27px 34px 40px; }
|
||||
.detail-head { min-height: 54px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
|
||||
.crumb { margin-bottom: 9px; color: #9ba6b5; font-size: 10px; white-space: pre-wrap; }
|
||||
.title-line { display: flex; align-items: center; gap: 9px; }
|
||||
.title-line h1 { margin: 0; color: #293a54; font-size: 21px; letter-spacing: 0; }
|
||||
.title-line code { color: #98a4b4; font-size: 9px; }
|
||||
.node-badge { padding: 3px 7px; border-radius: 4px; font-size: 9px; }
|
||||
.node-badge.directory { background: #fff1d9; color: #a66e1e; }
|
||||
.node-badge.page { background: #efedff; color: #6860b6; }
|
||||
.node-badge.data { background: #e7f5f1; color: #287a70; }
|
||||
.head-actions { display: flex; align-items: center; gap: 13px; }
|
||||
.head-actions label { display: flex; align-items: center; gap: 6px; color: #738198; font-size: 10px; }
|
||||
.more-button { width: 32px; height: 32px; }
|
||||
.primary, .outline { min-height: 31px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 7px 12px; border-radius: 5px; font-size: 10px; font-weight: 600; }
|
||||
.primary { border: 1px solid #2468d8; background: #2468d8; color: #fff; }
|
||||
.primary:not(:disabled):hover { background: #1e5fc8; }
|
||||
.outline { border: 1px solid #d8e0e9; background: #fff; color: #596b84; }
|
||||
.outline:hover { border-color: #a6bde0; color: #2468d8; }
|
||||
|
||||
.detail-body { display: grid; grid-template-columns: 148px minmax(0, 1fr); gap: 18px; }
|
||||
.section-nav { padding-top: 2px; }
|
||||
.section-nav button { width: 100%; height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; border: 0; border-left: 2px solid transparent; border-radius: 0 4px 4px 0; background: transparent; color: #7f8da1; text-align: left; font-size: 10px; }
|
||||
.section-nav button:hover { color: #516b92; background: rgba(255,255,255,.55); }
|
||||
.section-nav button.active { border-left-color: #2468d8; background: #fff; color: #2468d8; font-weight: 600; box-shadow: 0 1px 7px rgba(45, 68, 101, .05); }
|
||||
.content { min-width: 0; }
|
||||
.panel { overflow: hidden; border: 1px solid #e0e6ee; border-radius: 7px; background: #fff; box-shadow: 0 2px 10px rgba(40, 59, 86, .04); }
|
||||
.panel-header { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 15px 19px; border-bottom: 1px solid #edf0f4; }
|
||||
.panel-header h2 { margin: 0 0 4px; color: #34455f; font-size: 14px; }
|
||||
.panel-header p { margin: 0; color: #96a2b2; font-size: 10px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 20px; padding: 21px; }
|
||||
.form-grid label { position: relative; display: block; color: #66768d; font-size: 10px; }
|
||||
.form-grid input, .form-grid select, .table-row input, .table-row select, .rule-step input, .rule-step select { width: 100%; height: 32px; display: block; margin-top: 6px; padding: 0 9px; border: 1px solid #dbe2eb; border-radius: 4px; outline: 0; background: #fff; color: #51627b; font-size: 10px; }
|
||||
.form-grid input:focus, .form-grid select:focus, .table-row input:focus, .table-row select:focus { border-color: #789bd5; box-shadow: 0 0 0 2px rgba(36, 104, 216, .06); }
|
||||
.form-grid input[readonly], .form-grid select:disabled { background: #f7f8fa; color: #8d99a8; }
|
||||
.related-field select { padding-right: 104px; }
|
||||
.link-button { position: absolute; right: 7px; bottom: 7px; padding: 0; border: 0; background: #fff; color: #2468d8; font-size: 9px; }
|
||||
.info-strip { display: flex; align-items: center; gap: 8px; margin: 0 20px 20px; padding: 10px 11px; border-left: 2px solid #83a7df; background: #f6f9fd; color: #77879d; font-size: 9px; line-height: 1.55; }
|
||||
.info-strip svg { flex: 0 0 auto; color: #527fbe; }
|
||||
|
||||
.table-head, .table-row { display: grid; align-items: center; column-gap: 12px; }
|
||||
.table-head { min-height: 37px; padding: 0 18px; background: #fafbfd; color: #8d99aa; font-size: 9px; font-weight: 600; }
|
||||
.table-row { min-height: 54px; padding: 0 18px; border-top: 1px solid #edf1f5; color: #5b6c84; font-size: 10px; }
|
||||
.table-row:hover { background: #fcfdff; }
|
||||
.table-row input, .table-row select { margin-top: 0; }
|
||||
.table-row code { color: #75869d; font-size: 9px; }
|
||||
.table-row b { display: block; margin-bottom: 2px; color: #4f6078; font-size: 10px; }
|
||||
.table-head.fields, .table-row.fields { grid-template-columns: 1.15fr 1fr 1fr 1.2fr 46px; }
|
||||
.table-head.config, .table-row.config { grid-template-columns: 1.15fr 1fr 70px 110px 90px; }
|
||||
.table-head.i18n, .table-row.i18n { grid-template-columns: 1.35fr 1fr 1fr 1fr; }
|
||||
.i18n-table .table-row { min-height: 55px; }
|
||||
|
||||
.toggle { width: 28px; height: 17px; display: inline-block; padding: 2px; border: 0; border-radius: 20px; background: #d8e0e9; vertical-align: middle; }
|
||||
.toggle i { width: 13px; height: 13px; display: block; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(38, 52, 74, .16); transition: transform .15s; }
|
||||
.toggle.on { background: #36a57b; }
|
||||
.toggle.on i { transform: translateX(11px); }
|
||||
.power-list > div { min-height: 62px; display: flex; align-items: center; gap: 12px; padding: 0 19px; border-top: 1px solid #edf1f5; color: #4c79b8; }
|
||||
.power-list > div:first-child { border-top: 0; }
|
||||
.power-list > div > span { flex: 1; }
|
||||
.power-list b { display: block; color: #50617a; font-size: 11px; }
|
||||
.power-list small { display: block; margin-top: 3px; color: #95a1b1; font-size: 9px; }
|
||||
.more { display: grid; place-items: center; padding: 4px; border: 0; background: transparent; color: #8f9bac; }
|
||||
|
||||
.rule-summary { display: grid; grid-template-columns: 1fr 1fr auto; align-items: center; gap: 20px; padding: 18px 20px; border-bottom: 1px solid #edf1f5; background: #fafbfd; }
|
||||
.rule-summary span { display: block; margin-bottom: 4px; color: #98a3b2; font-size: 9px; }
|
||||
.rule-summary b { color: #53647c; font-size: 11px; }
|
||||
.rule-summary code { color: #24705f; font-size: 12px; font-weight: 700; }
|
||||
.rule-builder { padding: 20px; }
|
||||
.rule-step { min-height: 50px; display: grid; grid-template-columns: 24px 140px 1fr 28px; align-items: center; gap: 10px; padding: 7px 10px; border-bottom: 1px solid #edf1f5; }
|
||||
.rule-step > i { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; background: #edf3fc; color: #3c71bd; font-size: 9px; font-style: normal; }
|
||||
.rule-step input, .rule-step select { margin: 0; }
|
||||
.rule-step > button { display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: #8f9bab; }
|
||||
.inline-inputs { display: flex; align-items: center; gap: 8px; color: #7a899d; font-size: 10px; }
|
||||
.inline-inputs input { width: 62px; }
|
||||
.add-rule { display: flex; align-items: center; gap: 6px; margin-top: 14px; padding: 7px 9px; border: 0; background: transparent; color: #2468d8; font-size: 10px; }
|
||||
|
||||
@media (max-width: 1220px) {
|
||||
.detail { padding-left: 24px; padding-right: 24px; }
|
||||
.top-module { display: none; }
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
// ============================================================
|
||||
// FMS 新系统元数据模型(对应 FMS新系统核心表结构设计.md)
|
||||
// 命名与表结构保持一致:s_ 表 / b_ 字段
|
||||
// ============================================================
|
||||
|
||||
/** s_module 模块 */
|
||||
export interface Module {
|
||||
b_id: number;
|
||||
b_code: string; // 模块编码(如 ocean/finance/master-data)
|
||||
b_name: string; // 模块名称
|
||||
b_i18n: string | null; // 显示名多语言 key
|
||||
b_parent_id: number | null; // 父模块(业务分组树)
|
||||
b_module_type: 'platform' | 'tenant' | 'business'; // 类型
|
||||
b_canuse: 0 | 1; // 是否启用
|
||||
b_xh: number; // 排序号
|
||||
b_icon: string | null; // 图标
|
||||
b_bz: string | null; // 描述
|
||||
}
|
||||
|
||||
/** s_module_dependency 模块依赖 */
|
||||
export interface ModuleDependency {
|
||||
b_id: number;
|
||||
b_module_id: number; // 依赖方
|
||||
b_depend_module_id: number; // 被依赖方
|
||||
}
|
||||
|
||||
/** s_entity 实体 */
|
||||
export interface Entity {
|
||||
b_id: number;
|
||||
b_module_id: number; // 所属模块
|
||||
b_parent_id: number | null; // 父实体(主子表)
|
||||
b_code: string; // 实体编码(如 ocean_booking)
|
||||
b_name: string; // 实体名称
|
||||
b_i18n: string | null;
|
||||
b_viewtable: string | null; // 查询数据源(表或只读视图)
|
||||
b_savetable: string | null; // 保存目标(真实表)
|
||||
b_keyfield: string | null; // 保存对象主键字段(如 b_id)
|
||||
b_entity_type: 'table' | 'view' | 'query'; // 简单/复杂列表/纯查询
|
||||
b_delete_policy: 'restrict' | 'lifecycle' | 'cascade' | 'hard';
|
||||
b_canuse: 0 | 1;
|
||||
b_xh: number;
|
||||
b_bz: string | null;
|
||||
}
|
||||
|
||||
/** s_field 字段 */
|
||||
export interface Field {
|
||||
b_id: number;
|
||||
b_entity_id: number; // 所属实体
|
||||
b_field: string; // 物理列名
|
||||
b_name: string; // 字段名称
|
||||
b_i18n: string | null;
|
||||
b_type: string; // 存储类型:varchar/decimal/datetime/int/bit/text
|
||||
b_length: number | null;
|
||||
b_precision: number | null;
|
||||
b_scale: number | null;
|
||||
b_nullable: 0 | 1;
|
||||
b_default_value: string | null;
|
||||
b_options: string | null; // 下拉/单选选项(JSON 文本)
|
||||
b_edittype: string | null; // input/select/date/datetime/textarea/number/checkbox/radio
|
||||
b_required: 0 | 1;
|
||||
b_readonly: 0 | 1;
|
||||
b_disabled: 0 | 1;
|
||||
b_listvisible: 0 | 1;
|
||||
b_listwidth: number | null;
|
||||
b_queryable: 0 | 1;
|
||||
b_querytype: string | null;
|
||||
b_queryop: string | null;
|
||||
b_group_id: number | null; // 所属分组
|
||||
b_xh: number;
|
||||
b_canuse: 0 | 1;
|
||||
}
|
||||
|
||||
/** s_field_group 字段分组 */
|
||||
export interface FieldGroup {
|
||||
b_id: number;
|
||||
b_entity_id: number;
|
||||
b_parent_id: number | null;
|
||||
b_title: string;
|
||||
b_i18n: string | null;
|
||||
b_xh: number;
|
||||
b_canuse: 0 | 1;
|
||||
}
|
||||
|
||||
/** s_menu 菜单/页面 */
|
||||
export interface Menu {
|
||||
b_id: number;
|
||||
b_parent_id: number | null;
|
||||
b_module_id: number | null; // 所属模块(菜单的组织维度,可空)
|
||||
b_code: string;
|
||||
b_name: string;
|
||||
b_i18n: string | null;
|
||||
b_menu_type: 'directory' | 'page' | 'data' | 'external';
|
||||
b_route: string | null;
|
||||
b_entity_id: number | null;
|
||||
b_power_code: string | null;
|
||||
b_icon: string | null;
|
||||
b_canmenu: 0 | 1;
|
||||
b_xh: number;
|
||||
b_canuse: 0 | 1;
|
||||
}
|
||||
|
||||
/** s_autocode 自动编码 */
|
||||
export interface Autocode {
|
||||
b_id: number;
|
||||
b_entity_id: number;
|
||||
b_field: string;
|
||||
b_prefix: string;
|
||||
b_dateformat: string;
|
||||
b_separator: string;
|
||||
b_seqwidth: number;
|
||||
b_resettype: 'none' | 'day' | 'month' | 'year';
|
||||
b_startvalue: number;
|
||||
b_currentperiod: string | null;
|
||||
b_currentvalue: number;
|
||||
b_canuse: 0 | 1;
|
||||
}
|
||||
|
||||
/** s_i18n 多语言 */
|
||||
export interface I18n {
|
||||
b_id: number;
|
||||
b_key: string;
|
||||
b_locale: string;
|
||||
b_value: string;
|
||||
b_i18ntype: string | null;
|
||||
b_canuse: 0 | 1;
|
||||
}
|
||||
|
||||
/** s_role 角色 */
|
||||
export interface Role {
|
||||
b_id: number;
|
||||
b_code: string;
|
||||
b_name: string;
|
||||
b_i18n: string | null;
|
||||
b_remark: string | null;
|
||||
b_canuse: 0 | 1;
|
||||
}
|
||||
|
||||
/** s_user_role 用户-角色 */
|
||||
export interface UserRole {
|
||||
b_id: number;
|
||||
b_user_id: number; // 逻辑外键:身份系统用户
|
||||
b_role_id: number;
|
||||
b_canuse: 0 | 1;
|
||||
}
|
||||
|
||||
/** s_power 权限点 */
|
||||
export interface Power {
|
||||
b_id: number;
|
||||
b_code: string;
|
||||
b_name: string;
|
||||
b_i18n: string | null;
|
||||
b_power_type: 'menu' | 'entity' | 'action' | 'field';
|
||||
b_resource: string | null;
|
||||
b_canuse: 0 | 1;
|
||||
b_xh: number;
|
||||
}
|
||||
|
||||
/** s_role_power 角色-权限 */
|
||||
export interface RolePower {
|
||||
b_id: number;
|
||||
b_role_id: number;
|
||||
b_power_id: number;
|
||||
b_canuse: 0 | 1;
|
||||
}
|
||||
|
||||
/** 下拉选项解析结果 */
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const parseOptions = (json: string | null): SelectOption[] => {
|
||||
if (!json) return [];
|
||||
try {
|
||||
const arr = JSON.parse(json);
|
||||
if (Array.isArray(arr)) return arr as SelectOption[];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
/** 从多语言文本解析出可展示的资源 key(demo 简化:直接显示 b_i18n) */
|
||||
export const fmtI18n = (key: string | null): string => (key ? `🌐 ${key}` : "");
|
||||
Reference in new issue
Block a user