675 lines
35 KiB
HTML
675 lines
35 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>FMS 新系统 · 模块与菜单设计 Demo</title>
|
||
<style>
|
||
:root {
|
||
--text: #1f2328;
|
||
--text-2: #57606a;
|
||
--text-3: #8b949e;
|
||
--border: #d0d7de;
|
||
--bg: #ffffff;
|
||
--bg-2: #f6f8fa;
|
||
--accent: #0969da;
|
||
--accent-bg: #ddf4ff;
|
||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
html, body { height: 100%; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
color: var(--text); background: var(--bg); font-size: 14px; line-height: 1.6;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* 顶栏 */
|
||
.topbar {
|
||
height: 52px; background: var(--bg); border-bottom: 1px solid var(--border);
|
||
display: flex; align-items: center; padding: 0 24px; gap: 12px;
|
||
position: fixed; top: 0; left: 0; right: 0; z-index: 20;
|
||
}
|
||
.logo-title { font-weight: 600; font-size: 14px; }
|
||
.topbar .divider { width: 1px; height: 18px; background: var(--border); }
|
||
.breadcrumb { color: var(--text-3); font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
||
.breadcrumb .cur { color: var(--text); }
|
||
.topbar-right { margin-left: auto; color: var(--text-2); font-size: 13px; }
|
||
|
||
/* 布局 */
|
||
.layout { display: flex; padding-top: 52px; height: 100vh; }
|
||
.sidebar {
|
||
width: 240px; flex: 0 0 240px; border-right: 1px solid var(--border);
|
||
overflow-y: auto; padding: 14px 10px 30px;
|
||
}
|
||
.main { flex: 1; overflow-y: auto; padding: 28px 40px 80px; max-width: 1080px; }
|
||
|
||
/* 侧边栏 */
|
||
.side-module { margin-bottom: 2px; }
|
||
.side-module-head {
|
||
display: flex; align-items: center; gap: 8px; padding: 7px 10px;
|
||
border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 13.5px;
|
||
color: var(--text-2); user-select: none;
|
||
}
|
||
.side-module-head:hover { background: var(--bg-2); }
|
||
.side-module-head.active { background: var(--accent-bg); color: var(--accent); }
|
||
.side-module-head .deps { font-size: 10.5px; color: var(--text-3); font-weight: 400; margin-left: auto; }
|
||
.side-menu { list-style: none; display: none; margin: 1px 0 6px; }
|
||
.side-menu.open { display: block; }
|
||
.side-menu-item {
|
||
display: flex; align-items: center; gap: 7px; padding: 5px 10px 5px 24px;
|
||
border-radius: 6px; cursor: pointer; color: var(--text-2); font-size: 13px;
|
||
}
|
||
.side-menu-item:hover { background: var(--bg-2); color: var(--text); }
|
||
.side-menu-item.active { background: var(--accent-bg); color: var(--accent); }
|
||
.side-menu-item .mi { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.side-menu-item .tk { font-size: 10px; color: var(--text-3); }
|
||
.side-menu-item .mark { width: 5px; height: 5px; border-radius: 50%; flex: 0 0 5px; }
|
||
.side-menu-item .mark.m-data { background: var(--accent); }
|
||
.side-menu-item .mark.m-dir, .side-menu-item .mark.m-dcat { background: transparent; border: 1px solid var(--text-3); }
|
||
.side-menu-item .mark.m-page { background: var(--text-2); }
|
||
.side-menu-item .mark.m-ext { background: transparent; border: 1px solid var(--text-3); border-style: dashed; }
|
||
|
||
/* 页面头部 */
|
||
.page-head { margin-bottom: 24px; }
|
||
.ph-title { font-size: 20px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
|
||
.ph-title .tag { font-size: 11px; color: var(--text-3); border: 1px solid var(--border); border-radius: 4px; padding: 1px 7px; font-weight: 400; }
|
||
.ph-desc { color: var(--text-2); font-size: 13px; margin-top: 6px; max-width: 720px; }
|
||
.ph-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; font-family: var(--mono); font-size: 12px; color: var(--text-3); }
|
||
|
||
/* 区块 */
|
||
.section { margin-bottom: 32px; }
|
||
.section-title {
|
||
font-size: 13px; font-weight: 600; color: var(--text-2);
|
||
padding-bottom: 6px; border-bottom: 1px solid var(--border); margin-bottom: 12px;
|
||
}
|
||
.section-title .hint { float: right; font-weight: 400; color: var(--text-3); font-family: var(--mono); font-size: 11.5px; }
|
||
|
||
/* 表格 */
|
||
table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
table.grid th {
|
||
text-align: left; color: var(--text-2); font-weight: 500;
|
||
padding: 7px 10px; font-size: 12px; border-bottom: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
}
|
||
table.grid td { padding: 7px 10px; border-bottom: 1px solid var(--bg-2); color: var(--text-2); vertical-align: middle; }
|
||
table.grid tr:last-child td { border-bottom: none; }
|
||
table.grid tr.row-click { cursor: pointer; }
|
||
table.grid tr.row-click:hover td { background: var(--bg-2); }
|
||
table.grid .mono { font-family: var(--mono); color: var(--text); font-size: 12.5px; }
|
||
table.grid .dim { color: var(--text-3); }
|
||
|
||
/* 键值 */
|
||
.kv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px 24px; }
|
||
.kv .k { font-size: 11px; color: var(--text-3); }
|
||
.kv .v { font-size: 13px; font-family: var(--mono); }
|
||
|
||
/* 依赖 */
|
||
.dep-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-family: var(--mono); font-size: 12.5px; color: var(--text-2); }
|
||
.dep-line .arr { color: var(--text-3); }
|
||
|
||
/* 树预览 */
|
||
.tree { font-family: var(--mono); font-size: 12.5px; }
|
||
.tree .tr { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
|
||
.tree .tr .name { color: var(--text); }
|
||
.tree .tr .type { font-size: 10.5px; color: var(--text-3); border: 1px solid var(--border); border-radius: 3px; padding: 0 5px; }
|
||
.tree .tr .route { color: var(--text-3); font-size: 11px; }
|
||
|
||
/* 类型说明 */
|
||
.type-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 0 32px; }
|
||
.type-item { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--bg-2); }
|
||
.type-item .tn { width: 90px; flex: 0 0 90px; font-family: var(--mono); font-size: 12.5px; color: var(--text); }
|
||
.type-item .tn .cn { display: block; font-family: inherit; color: var(--text-3); font-size: 11px; }
|
||
.type-item .td { font-size: 12.5px; color: var(--text-2); }
|
||
.type-item .td .req { color: var(--text-3); font-size: 11.5px; }
|
||
|
||
.empty { color: var(--text-3); font-size: 13px; padding: 18px 0; }
|
||
.link { color: var(--accent); cursor: pointer; text-decoration: none; }
|
||
.link:hover { text-decoration: underline; }
|
||
.wm-tag { font-family: var(--mono); font-size: 11px; color: var(--text-2); background: var(--bg-2); border: 1px solid var(--border); padding: 1px 6px; border-radius: 3px; white-space: nowrap; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<span class="logo-title">FMS 新系统 · 模块与菜单设计</span>
|
||
<span class="divider"></span>
|
||
<div class="breadcrumb" id="breadcrumb"></div>
|
||
<div class="topbar-right">orgId = SH01 · 售后技术 · 张工</div>
|
||
</div>
|
||
|
||
<div class="layout">
|
||
<aside class="sidebar" id="sidebar"></aside>
|
||
<main class="main" id="main"></main>
|
||
</div>
|
||
|
||
<script>
|
||
/* ============ 数据:与《FMS新系统核心表结构设计》对应 ============ */
|
||
|
||
const MENU_TYPES = {
|
||
directory: { cn: '目录', desc: '纯导航节点,不绑页面', req: '不可填实体/视图/表单/外链路由' },
|
||
data_category: { cn: '数据目录', desc: '数据模块的分组目录', req: '不直接绑定实体,仅组织子节点' },
|
||
data: { cn: '数据', desc: '通用列表+表单维护的数据模块', req: '必填 entity_key + view_key' },
|
||
page: { cn: '页面', desc: '固定 Vue 组件页面', req: '必填 route,绑定 view/form' },
|
||
external: { cn: '外链', desc: '跳转外部 URL', req: '必填 route + permission_key' },
|
||
};
|
||
|
||
const MODULES = [
|
||
{
|
||
key: 'platform', name: '平台', origin: 'platform', sortNo: 0,
|
||
desc: '平台基础能力:用户与组织身份、文件、审计、工作流、报表。其他模块都直接或间接依赖它。',
|
||
deps: [],
|
||
entities: [
|
||
{ key: 'platform.user', table: 'core_user', policy: 'restrictWhenReferenced', desc: '系统用户与账号' },
|
||
{ key: 'platform.org', table: 'core_org', policy: 'restrictWhenReferenced', desc: '组织与部门' },
|
||
{ key: 'platform.file', table: 'core_file', policy: 'hardDelete', desc: '文件元数据' },
|
||
],
|
||
menus: [
|
||
{ key: 'platform.system', name: '系统管理', type: 'directory', children: [
|
||
{ key: 'platform.user.list', name: '用户管理', type: 'data', entity: 'platform.user', route: '/platform/users', permission: 'platform.user.view' },
|
||
{ key: 'platform.role.page', name: '角色权限', type: 'page', route: '/platform/roles', permission: 'platform.role.view' },
|
||
{ key: 'platform.audit.data', name: '审计日志', type: 'data', entity: 'platform.audit', route: '/platform/audit', permission: 'platform.audit.view' },
|
||
]},
|
||
],
|
||
},
|
||
{
|
||
key: 'master-data', name: '基础数据', origin: 'platform', sortNo: 10,
|
||
desc: '客户、联系人、港口、币种、船名航次等共享主数据。被海运、空运、财务、合同等业务模块引用。',
|
||
deps: ['platform'],
|
||
entities: [
|
||
{ key: 'master.company', table: 'master_company', policy: 'restrictWhenReferenced', desc: '客户/供应商/代理统一主数据' },
|
||
{ key: 'master.contact', table: 'master_contact', policy: 'restrictWhenReferenced', desc: '联系人' },
|
||
{ key: 'master.port', table: 'master_port', policy: 'restrictWhenReferenced', desc: '港口' },
|
||
{ key: 'master.currency',table: 'master_currency',policy: 'restrictWhenReferenced', desc: '币种' },
|
||
{ key: 'master.vessel', table: 'master_vessel', policy: 'restrictWhenReferenced', desc: '船名航次' },
|
||
],
|
||
menus: [
|
||
{ key: 'master.company.list', name: '公司管理', type: 'data', entity: 'master.company', route: '/master/company', permission: 'master.company.view' },
|
||
{ key: 'master.contact.list', name: '联系人', type: 'data', entity: 'master.contact', route: '/master/contact', permission: 'master.contact.view' },
|
||
{ key: 'master.port.list', name: '港口', type: 'data', entity: 'master.port', route: '/master/port', permission: 'master.port.view' },
|
||
{ key: 'master.currency.list',name: '币种', type: 'data', entity: 'master.currency', route: '/master/currency', permission: 'master.currency.view' },
|
||
{ key: 'master.vessel.list', name: '船名航次', type: 'data', entity: 'master.vessel', route: '/master/vessel', permission: 'master.vessel.view' },
|
||
],
|
||
},
|
||
{
|
||
key: 'ocean', name: '海运', origin: 'platform', sortNo: 20,
|
||
desc: '海运出口全流程:订舱、海运单、集装箱、提单、船期。典型的"一模块多实体多菜单"形态。',
|
||
deps: ['platform', 'master-data'],
|
||
entities: [
|
||
{ key: 'ocean.booking', table: 'ocean_booking', view: 'v_booking_with_fee', policy: 'lifecycle', desc: '订舱主单' },
|
||
{ key: 'ocean.container', table: 'ocean_container', policy: 'cascadeOwned', desc: '箱信息(订舱的子表)' },
|
||
{ key: 'ocean.shipment', table: 'ocean_shipment', policy: 'lifecycle', desc: '海运单' },
|
||
{ key: 'ocean.bill_of_lading', table: 'ocean_bill_of_lading', policy: 'lifecycle', desc: '提单' },
|
||
{ key: 'ocean.sailing', table: 'ocean_sailing', policy: 'hardDelete', desc: '船期' },
|
||
],
|
||
menus: [
|
||
{ key: 'ocean.booking.cat', name: '订舱管理', type: 'data_category', children: [
|
||
{ key: 'ocean.booking.list', name: '订舱列表', type: 'data', entity: 'ocean.booking', route: '/ocean/bookings', permission: 'ocean.booking.view' },
|
||
]},
|
||
{ key: 'ocean.shipment.cat', name: '海运单', type: 'data_category', children: [
|
||
{ key: 'ocean.shipment.list', name: '海运单列表', type: 'data', entity: 'ocean.shipment', route: '/ocean/shipments', permission: 'ocean.shipment.view' },
|
||
]},
|
||
{ key: 'ocean.sailing.cat', name: '船期', type: 'data_category', children: [
|
||
{ key: 'ocean.sailing.list', name: '船期列表', type: 'data', entity: 'ocean.sailing', route: '/ocean/sailings', permission: 'ocean.sailing.view' },
|
||
]},
|
||
{ key: 'ocean.workbench', name: '提单工作台', type: 'page', route: '/ocean/bl-workbench', permission: 'ocean.bl.workbench', special: true },
|
||
{ key: 'ocean.carrier.edi', name: '船公司订舱 EDI', type: 'external', route: 'https://edi.carrier.com', permission: 'ocean.edi.view' },
|
||
],
|
||
},
|
||
{
|
||
key: 'air', name: '空运', origin: 'platform', sortNo: 30,
|
||
desc: '空运出口:订舱、空运单、主单/分单、航班。',
|
||
deps: ['platform', 'master-data'],
|
||
entities: [
|
||
{ key: 'air.booking', table: 'air_booking', policy: 'lifecycle', desc: '空运订舱' },
|
||
{ key: 'air.shipment',table: 'air_shipment',policy: 'lifecycle', desc: '空运单' },
|
||
{ key: 'air.mawb', table: 'air_mawb', policy: 'lifecycle', desc: '主单' },
|
||
{ key: 'air.hawb', table: 'air_hawb', policy: 'lifecycle', desc: '分单' },
|
||
],
|
||
menus: [
|
||
{ key: 'air.booking.cat', name: '空运订舱', type: 'data_category', children: [
|
||
{ key: 'air.booking.list', name: '订舱列表', type: 'data', entity: 'air.booking', route: '/air/bookings', permission: 'air.booking.view' },
|
||
]},
|
||
{ key: 'air.doc.cat', name: '单证管理', type: 'data_category', children: [
|
||
{ key: 'air.shipment.list', name: '空运单', type: 'data', entity: 'air.shipment', route: '/air/shipments', permission: 'air.shipment.view' },
|
||
{ key: 'air.mawb.list', name: '主单', type: 'data', entity: 'air.mawb', route: '/air/mawbs', permission: 'air.mawb.view' },
|
||
{ key: 'air.hawb.list', name: '分单', type: 'data', entity: 'air.hawb', route: '/air/hawbs', permission: 'air.hawb.view' },
|
||
]},
|
||
],
|
||
},
|
||
{
|
||
key: 'finance', name: '财务', origin: 'platform', sortNo: 40,
|
||
desc: '应收应付、发票、结算。依赖业务模块产生费用,属于典型的跨模块依赖。',
|
||
deps: ['platform', 'master-data', 'ocean', 'air'],
|
||
entities: [
|
||
{ key: 'finance.invoice', table: 'finance_invoice', policy: 'lifecycle', desc: '发票' },
|
||
{ key: 'finance.bill', table: 'finance_bill', policy: 'lifecycle', desc: '收付款单' },
|
||
{ key: 'finance.settlement', table: 'finance_settlement', policy: 'lifecycle', desc: '结算单' },
|
||
],
|
||
menus: [
|
||
{ key: 'finance.receivable', name: '应收管理', type: 'data_category', children: [
|
||
{ key: 'finance.invoice.list', name: '发票', type: 'data', entity: 'finance.invoice', route: '/finance/invoices', permission: 'finance.invoice.view' },
|
||
{ key: 'finance.bill.list', name: '收款单', type: 'data', entity: 'finance.bill', route: '/finance/bills', permission: 'finance.bill.view' },
|
||
]},
|
||
{ key: 'finance.settle', name: '结算管理', type: 'data_category', children: [
|
||
{ key: 'finance.settlement.list', name: '结算单', type: 'data', entity: 'finance.settlement', route: '/finance/settlements', permission: 'finance.settlement.view' },
|
||
]},
|
||
],
|
||
},
|
||
{
|
||
key: 'contract', name: '合同', origin: 'platform', sortNo: 50,
|
||
desc: '报价单与客户合同。报价单通常由客户合同拆分而来。',
|
||
deps: ['platform', 'master-data'],
|
||
entities: [
|
||
{ key: 'contract.pricelist', table: 'contract_pricelist', policy: 'lifecycle', desc: '报价单' },
|
||
{ key: 'contract.pricelist_item', table: 'contract_pricelist_item', policy: 'cascadeOwned', desc: '报价明细(子表)' },
|
||
{ key: 'contract.customer_contract', table: 'contract_customer_contract', policy: 'lifecycle', desc: '客户合同' },
|
||
],
|
||
menus: [
|
||
{ key: 'contract.pricelist.list', name: '报价单', type: 'data', entity: 'contract.pricelist', route: '/contract/pricelists', permission: 'contract.pricelist.view' },
|
||
{ key: 'contract.ct.list', name: '客户合同', type: 'data', entity: 'contract.customer_contract', route: '/contract/contracts', permission: 'contract.contract.view' },
|
||
],
|
||
},
|
||
];
|
||
|
||
const ENTITY_FIELDS = {
|
||
'ocean.booking': [
|
||
{ key: 'booking_no', label: '订舱号', type: 'varchar(50)', write: 'direct', req: 1, qry: 1, list: 1, edit: 1, group: 'basic' },
|
||
{ key: 'customer_id', label: '客户', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1, group: 'basic' },
|
||
{ key: 'carrier_id', label: '船公司', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1, group: 'basic' },
|
||
{ key: 'etd', label: '预计离港日', type: 'date', write: 'direct', req: 0, qry: 1, list: 1, edit: 1, group: 'transport' },
|
||
{ key: 'load_port', label: '起运港', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1, group: 'transport' },
|
||
{ key: 'discharge_port', label: '目的港', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1, group: 'transport' },
|
||
{ key: 'freight_payment',label: '运费支付', type: 'varchar(32)', write: 'direct', req: 0, qry: 1, list: 1, edit: 1, group: 'transport' },
|
||
{ key: 'gross_weight', label: '毛重', type: 'decimal(18,3)', write: 'direct', req: 0, qry: 0, list: 1, edit: 1, group: 'cargo' },
|
||
{ key: 'volume', label: '体积', type: 'decimal(18,3)', write: 'direct', req: 0, qry: 0, list: 1, edit: 1, group: 'cargo' },
|
||
{ key: 'container_count',label: '箱量', type: 'int', write: 'computed', req: 0, qry: 0, list: 1, edit: 0, group: 'cargo' },
|
||
{ key: 'status', label: '状态', type: 'varchar(32)', write: 'actionOnly', req: 0, qry: 1, list: 1, edit: 0 },
|
||
{ key: 'remark', label: '备注', type: 'nvarchar(1000)', write: 'direct', req: 0, qry: 0, list: 0, edit: 1 },
|
||
{ key: 'created_by', label: '创建人', type: 'nvarchar(128)', write: 'system', req: 0, qry: 0, list: 0, edit: 0 },
|
||
{ key: 'version', label: '版本', type: 'int', write: 'system', req: 0, qry: 0, list: 0, edit: 0 },
|
||
],
|
||
'ocean.container': [
|
||
{ key: 'con_no', label: '箱号', type: 'varchar(50)', write: 'direct', req: 1, qry: 1, list: 1, edit: 1 },
|
||
{ key: 'seal_no', label: '封号', type: 'varchar(50)', write: 'direct', req: 0, qry: 0, list: 1, edit: 1 },
|
||
{ key: 'con_size', label: '箱型', type: 'varchar(32)', write: 'direct', req: 1, qry: 1, list: 1, edit: 1 },
|
||
{ key: 'gross_weight', label: '毛重', type: 'decimal(18,3)', write: 'direct', req: 0, qry: 0, list: 1, edit: 1 },
|
||
{ key: 'vgm', label: 'VGM', type: 'decimal(18,3)', write: 'direct', req: 0, qry: 0, list: 1, edit: 1 },
|
||
],
|
||
'finance.invoice': [
|
||
{ key: 'invoice_no', label: '发票号', type: 'varchar(50)', write: 'direct', req: 1, qry: 1, list: 1, edit: 0, group: 'basic' },
|
||
{ key: 'direction', label: '方向', type: 'varchar(16)', write: 'createOnly', req: 1, qry: 1, list: 1, edit: 0, group: 'basic' },
|
||
{ key: 'customer_id', label: '往来单位', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1, group: 'basic' },
|
||
{ key: 'amount', label: '含税金额', type: 'decimal(18,2)', write: 'computed', req: 0, qry: 0, list: 1, edit: 0, group: 'amount' },
|
||
{ key: 'currency', label: '币种', type: 'varchar(8)', write: 'direct', req: 1, qry: 1, list: 1, edit: 1, group: 'amount' },
|
||
{ key: 'status', label: '状态', type: 'varchar(32)', write: 'actionOnly', req: 0, qry: 1, list: 1, edit: 0 },
|
||
],
|
||
};
|
||
|
||
const ENTITY_GROUPS = {
|
||
'ocean.booking': [
|
||
{ key: 'basic', name: '基本信息', sort: 0 },
|
||
{ key: 'transport', name: '运输信息', sort: 1 },
|
||
{ key: 'cargo', name: '货物信息', sort: 2 },
|
||
],
|
||
'finance.invoice': [
|
||
{ key: 'basic', name: '基本信息', sort: 0 },
|
||
{ key: 'amount', name: '金额信息', sort: 1 },
|
||
],
|
||
};
|
||
|
||
const ENTITY_RELATIONS = {
|
||
'ocean.booking': [
|
||
{ key: 'ocean.booking.containers', type: 'one-to-many', target: 'ocean.container', ownership: 'composition', delete: 'cascade', save: 'with-parent' },
|
||
{ key: 'ocean.booking.customer', type: 'many-to-one', target: 'master.company', ownership: 'reference', delete: 'restrict', save: 'separate' },
|
||
{ key: 'ocean.booking.carrier', type: 'many-to-one', target: 'master.company', ownership: 'reference', delete: 'restrict', save: 'separate' },
|
||
],
|
||
};
|
||
|
||
/* ============ 状态与工具 ============ */
|
||
const state = { selectedModule: null, selectedMenu: null, selectedEntity: null, openModules: new Set(['ocean']) };
|
||
|
||
function findMenuByKey(k) {
|
||
for (const m of MODULES) for (const menu of m.menus) {
|
||
if (menu.key === k) return menu;
|
||
if (menu.children) { const c = menu.children.find(x => x.key === k); if (c) return c; }
|
||
}
|
||
return null;
|
||
}
|
||
function menuOwner(k) {
|
||
return MODULES.find(m => m.menus.some(mm => mm.key === k || (mm.children && mm.children.some(c => c.key === k))));
|
||
}
|
||
function entityViewTable(ek) {
|
||
for (const m of MODULES) { const e = m.entities.find(x => x.key === ek); if (e) return e.view || e.table; }
|
||
return '—';
|
||
}
|
||
function entitySaveTable(ek) {
|
||
for (const m of MODULES) { const e = m.entities.find(x => x.key === ek); if (e) return e.table; }
|
||
return '—';
|
||
}
|
||
function entityOwner(ek) {
|
||
for (const m of MODULES) { const e = m.entities.find(x => x.key === ek); if (e) return { mod: m, ent: e }; }
|
||
return null;
|
||
}
|
||
function countMenus(m) {
|
||
let n = 0;
|
||
for (const menu of m.menus) n += menu.children ? menu.children.length : 1;
|
||
return n;
|
||
}
|
||
|
||
/* ============ 渲染 ============ */
|
||
function render() {
|
||
const bc = ['配置中心'];
|
||
if (state.selectedModule) {
|
||
const m = MODULES.find(x => x.key === state.selectedModule);
|
||
if (m) bc.push(m.name + ' 模块');
|
||
}
|
||
if (state.selectedMenu) { const menu = findMenuByKey(state.selectedMenu); if (menu) bc.push(menu.name); }
|
||
if (state.selectedEntity) bc.push(state.selectedEntity);
|
||
document.getElementById('breadcrumb').innerHTML = bc.map((s, i) => i === bc.length - 1
|
||
? `<span class="cur">${s}</span>` : `<span>${s}</span><span> / </span>`).join('');
|
||
|
||
renderSidebar();
|
||
const main = document.getElementById('main');
|
||
if (state.selectedEntity) main.innerHTML = renderEntity();
|
||
else if (state.selectedMenu) main.innerHTML = renderMenu();
|
||
else if (state.selectedModule) main.innerHTML = renderModule();
|
||
else main.innerHTML = renderOverview();
|
||
window.scrollTo(0, 0);
|
||
}
|
||
|
||
function renderSidebar() {
|
||
const el = document.getElementById('sidebar');
|
||
let html = '';
|
||
for (const m of MODULES) {
|
||
const open = state.openModules.has(m.key);
|
||
const active = state.selectedModule === m.key;
|
||
html += `<div class="side-module">
|
||
<div class="side-module-head ${open ? '' : ''} ${active ? 'active' : ''}" data-mod="${m.key}">
|
||
<span>${m.name}</span>
|
||
${m.deps.length ? `<span class="deps">依赖 ${m.deps.length}</span>` : ''}
|
||
</div>
|
||
<ul class="side-menu ${open ? 'open' : ''}">`;
|
||
for (const menu of m.menus) html += renderMenuRow(menu, 0);
|
||
html += `</ul></div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function renderMenuRow(menu, depth) {
|
||
const t = MENU_TYPES[menu.type];
|
||
const active = state.selectedMenu === menu.key;
|
||
const pad = depth * 14;
|
||
if (menu.children) {
|
||
return `<li class="side-menu-item ${active ? 'active' : ''}" data-menu="${menu.key}" style="padding-left:${10 + pad}px">
|
||
<span class="mark m-${menu.type}"></span><span class="mi">${menu.name}</span><span class="tk">${t.cn}</span>
|
||
</li>` + menu.children.map(c => renderMenuRow(c, depth + 1)).join('');
|
||
}
|
||
return `<li class="side-menu-item ${active ? 'active' : ''}" data-menu="${menu.key}" style="padding-left:${10 + pad}px">
|
||
<span class="mark m-${menu.type}"></span><span class="mi">${menu.name}</span><span class="tk">${t.cn}</span>
|
||
</li>`;
|
||
}
|
||
|
||
/* --- 总览 --- */
|
||
function renderOverview() {
|
||
return `
|
||
<div class="page-head">
|
||
<div class="ph-title">模块与菜单设计总览</div>
|
||
<div class="ph-desc">依据《FMS新系统核心表结构设计》实现。左侧为 s_module_menu 导航树(五种菜单类型),点击查看模块、菜单与实体的元数据设计。</div>
|
||
<div class="ph-meta">s_module · s_module_dependency · s_module_entity · s_module_field · s_module_menu</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">菜单类型(s_module_menu.b_menu_type)</div>
|
||
<div class="type-list">
|
||
${Object.entries(MENU_TYPES).map(([k, v]) => `
|
||
<div class="type-item">
|
||
<div class="tn">${k}<span class="cn">${v.cn}</span></div>
|
||
<div class="td">${v.desc}<div class="req">${v.req}</div></div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">模块清单(s_module)<span class="hint">点击模块查看详情</span></div>
|
||
<table class="grid">
|
||
<thead><tr><th>模块</th><th>模块 key</th><th>依赖</th><th>实体</th><th>菜单</th><th>来源</th></tr></thead>
|
||
<tbody>
|
||
${MODULES.map(m => `
|
||
<tr class="row-click" data-mod="${m.key}">
|
||
<td>${m.name}</td>
|
||
<td class="mono">${m.key}</td>
|
||
<td class="mono dim">${m.deps.length ? m.deps.join(', ') : '—'}</td>
|
||
<td>${m.entities.length}</td>
|
||
<td>${countMenus(m)}</td>
|
||
<td class="dim">${m.origin}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* --- 模块详情 --- */
|
||
function renderModule() {
|
||
const m = MODULES.find(x => x.key === state.selectedModule);
|
||
if (!m) return renderOverview();
|
||
const reverseDeps = MODULES.filter(x => x.deps.includes(m.key));
|
||
|
||
return `
|
||
<div class="page-head">
|
||
<div class="ph-title">${m.name} 模块 <span class="tag">${m.origin}</span></div>
|
||
<div class="ph-desc">${m.desc}</div>
|
||
<div class="ph-meta">key: ${m.key} · name_key: module.${m.key} · sort_no: ${m.sortNo}</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">模块依赖(s_module_dependency)<span class="hint">有向无环图</span></div>
|
||
<div class="dep-line">
|
||
${m.deps.length
|
||
? m.deps.map(d => `<span>${d}</span>`).join('<span class="arr">←</span>')
|
||
: '<span class="dim">无依赖(平台根基)</span>'}
|
||
</div>
|
||
${reverseDeps.length ? `<div style="margin-top:8px;font-size:12.5px;color:var(--text-3)">被依赖:${reverseDeps.map(x => x.name).join('、')}</div>` : ''}
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">包含实体(s_module_entity)<span class="hint">点击实体查看字段元数据</span></div>
|
||
<table class="grid">
|
||
<thead><tr><th>实体 key</th><th>查询源 view_table</th><th>保存目标 save_table</th><th>删除策略</th><th>字段数</th></tr></thead>
|
||
<tbody>
|
||
${m.entities.map(e => `
|
||
<tr class="row-click" data-entity="${e.key}">
|
||
<td class="mono">${e.key}</td>
|
||
<td class="mono dim">${e.view || e.table}${e.view ? '(视图)' : ''}</td>
|
||
<td class="mono dim">${e.table}</td>
|
||
<td class="dim">${e.policy}</td>
|
||
<td>${ENTITY_FIELDS[e.key] ? ENTITY_FIELDS[e.key].length : 0}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">菜单结构(s_module_menu)<span class="hint">一条记录 = 导航节点 + 页面入口</span></div>
|
||
${renderTree(m.menus)}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderTree(items, depth = 0) {
|
||
let html = '<div class="tree">';
|
||
const walk = (list, d) => {
|
||
for (const item of list) {
|
||
const t = MENU_TYPES[item.type];
|
||
html += `<div class="tr" data-menu="${item.key}" style="cursor:pointer;padding-left:${d * 22}px">
|
||
<span class="type">${t.cn}</span><span class="name">${item.name}</span>
|
||
${item.route ? `<span class="route">${item.route}</span>` : ''}
|
||
</div>`;
|
||
if (item.children) walk(item.children, d + 1);
|
||
}
|
||
};
|
||
walk(items, depth);
|
||
return html + '</div>';
|
||
}
|
||
|
||
/* --- 菜单详情 --- */
|
||
function renderMenu() {
|
||
const menu = findMenuByKey(state.selectedMenu);
|
||
if (!menu) { state.selectedMenu = null; return renderMainFallback(); }
|
||
const t = MENU_TYPES[menu.type];
|
||
const owner = menuOwner(menu.key);
|
||
const entity = menu.entity ? {
|
||
key: menu.entity,
|
||
viewTable: entityViewTable(menu.entity),
|
||
saveTable: entitySaveTable(menu.entity),
|
||
fields: ENTITY_FIELDS[menu.entity] ? ENTITY_FIELDS[menu.entity].length : 0,
|
||
groups: ENTITY_GROUPS[menu.entity] ? ENTITY_GROUPS[menu.entity].length : 0,
|
||
rels: ENTITY_RELATIONS[menu.entity] ? ENTITY_RELATIONS[menu.entity].length : 0,
|
||
} : null;
|
||
|
||
return `
|
||
<div class="page-head">
|
||
<div class="ph-title">${menu.name} <span class="tag">${t.cn} · ${menu.type}</span></div>
|
||
<div class="ph-desc">${t.desc}</div>
|
||
<div class="ph-meta">menu_key: ${menu.key} · module: ${owner ? owner.key : '—'}${menu.permission ? ' · permission: ' + menu.permission : ''}</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">节点约束(元数据校验器)<span class="hint">b_menu_type</span></div>
|
||
<div class="kv-grid">
|
||
<div class="kv"><div class="k">类型</div><div class="v">${t.cn} (${menu.type})</div></div>
|
||
<div class="kv"><div class="k">路由 route</div><div class="v">${menu.route || '—'}</div></div>
|
||
<div class="kv"><div class="k">权限 permission_key</div><div class="v">${menu.permission || '—'}</div></div>
|
||
<div class="kv"><div class="k">校验要求</div><div class="v">${t.req}</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
${entity ? `
|
||
<div class="section">
|
||
<div class="section-title">绑定的实体 <span class="hint">查询走 view_table,保存走 save_table</span></div>
|
||
<div class="kv-grid">
|
||
<div class="kv"><div class="k">实体 entity_key</div><div class="v"><a class="link" data-entity="${entity.key}">${entity.key} ›</a></div></div>
|
||
<div class="kv"><div class="k">查询源 view_table</div><div class="v">${entity.viewTable}${entity.viewTable !== entity.saveTable ? '(视图)' : ''}</div></div>
|
||
<div class="kv"><div class="k">保存目标 save_table</div><div class="v">${entity.saveTable}</div></div>
|
||
<div class="kv"><div class="k">字段 / 分组 / 关系</div><div class="v">${entity.fields} / ${entity.groups} / ${entity.rels}</div></div>
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
${menu.children ? `
|
||
<div class="section">
|
||
<div class="section-title">子节点</div>
|
||
${renderTree([menu])}
|
||
</div>` : ''}
|
||
`;
|
||
}
|
||
|
||
function renderMainFallback() { state.selectedModule = null; state.selectedMenu = null; state.selectedEntity = null; return renderOverview(); }
|
||
|
||
/* --- 实体详情 --- */
|
||
function renderEntity() {
|
||
const { mod, ent } = entityOwner(state.selectedEntity) || {};
|
||
if (!ent) { state.selectedEntity = null; return render(); }
|
||
const ek = ent.key;
|
||
const fields = ENTITY_FIELDS[ek] || [];
|
||
const rels = ENTITY_RELATIONS[ek] || [];
|
||
const groups = ENTITY_GROUPS[ek] || [];
|
||
const viewTable = ent.view || ent.table;
|
||
const isView = !!ent.view;
|
||
const groupName = gk => (groups.find(g => g.key === gk) || {}).name || '未分组';
|
||
|
||
return `
|
||
<div class="page-head">
|
||
<div class="ph-title">${ent.desc} <span class="tag">${ent.policy}</span>${isView ? ' <span class="tag">视图查询</span>' : ''}</div>
|
||
<div class="ph-desc">实体是数据和字段的所有者,字段挂实体(s_module_field.b_entity_key)。返回<a class="link" data-back="mod">${mod.name} 模块</a>。</div>
|
||
<div class="ph-meta">entity_key: ${ek} · 查询源: ${viewTable} · 保存目标: ${ent.table} · module: ${mod.key}</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">查询 / 保存数据源 <span class="hint">s_module_entity</span></div>
|
||
<div class="kv-grid">
|
||
<div class="kv"><div class="k">查询源 view_table</div><div class="v">${viewTable}${isView ? '(SQL 视图,带关联/聚合)' : ''}</div></div>
|
||
<div class="kv"><div class="k">保存目标 save_table</div><div class="v">${ent.table}</div></div>
|
||
<div class="kv"><div class="k">主键字段 key_field</div><div class="v">b_id</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">字段分组(s_module_field_group)<span class="hint">分组独立定义,字段引用 group_key</span></div>
|
||
${groups.length ? `<table class="grid">
|
||
<thead><tr><th>分组 key</th><th>标题 name_key</th><th>排序</th></tr></thead>
|
||
<tbody>${groups.map(g => `<tr>
|
||
<td class="mono">${g.key}</td>
|
||
<td>${g.name}</td>
|
||
<td>${g.sort}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table>` : '<div class="empty">无分组(字段直接平铺)</div>'}
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">字段元数据(s_module_field)<span class="hint">写入模式 / 分组 / 必填 / 查询 / 列表 / 编辑</span></div>
|
||
<table class="grid">
|
||
<thead><tr><th>字段 key</th><th>标签</th><th>数据类型</th><th>写入模式</th><th>分组</th><th>必填</th><th>查询</th><th>列表</th><th>编辑</th></tr></thead>
|
||
<tbody>
|
||
${fields.map(f => `<tr>
|
||
<td class="mono">${f.key}</td>
|
||
<td>${f.label}</td>
|
||
<td class="mono dim">${f.type}</td>
|
||
<td><span class="wm-tag">${f.write}</span></td>
|
||
<td>${f.group ? groupName(f.group) : '<span class="dim">—</span>'}</td>
|
||
<td>${f.req ? '必填' : '<span class="dim">—</span>'}</td>
|
||
<td>${f.qry ? '✓' : ''}</td>
|
||
<td>${f.list ? '✓' : ''}</td>
|
||
<td>${f.edit ? '✓' : ''}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
<div style="margin-top:8px;font-size:12px;color:var(--text-3)">
|
||
direct 普通保存 · createOnly 仅创建 · readOnly 只读 · computed 计算 · actionOnly 仅动作 · system 系统维护
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-title">关系(s_module_relation)<span class="hint">主子表 / 引用</span></div>
|
||
${rels.length ? `<table class="grid">
|
||
<thead><tr><th>关系 key</th><th>类型</th><th>目标实体</th><th>所有权</th><th>删除</th><th>保存</th></tr></thead>
|
||
<tbody>${rels.map(r => `<tr>
|
||
<td class="mono">${r.key}</td>
|
||
<td class="mono">${r.type}</td>
|
||
<td class="mono">${r.target}</td>
|
||
<td>${r.ownership}</td>
|
||
<td>${r.delete}</td>
|
||
<td>${r.save}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table>` : '<div class="empty">无</div>'}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* ============ 事件 ============ */
|
||
document.addEventListener('click', (e) => {
|
||
const modEl = e.target.closest('[data-mod]');
|
||
if (modEl) {
|
||
const k = modEl.getAttribute('data-mod');
|
||
state.selectedModule = k; state.selectedMenu = null; state.selectedEntity = null;
|
||
state.openModules.add(k); render(); return;
|
||
}
|
||
const menuEl = e.target.closest('[data-menu]');
|
||
if (menuEl) {
|
||
const k = menuEl.getAttribute('data-menu');
|
||
state.selectedMenu = k; state.selectedEntity = null;
|
||
const owner = menuOwner(k);
|
||
if (owner) { state.selectedModule = owner.key; state.openModules.add(owner.key); }
|
||
render(); return;
|
||
}
|
||
const entEl = e.target.closest('[data-entity]');
|
||
if (entEl) { state.selectedEntity = entEl.getAttribute('data-entity'); state.selectedMenu = null; render(); return; }
|
||
const backEl = e.target.closest('[data-back]');
|
||
if (backEl) { state.selectedEntity = null; render(); return; }
|
||
});
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|