Files
workspace/code/fms/fms-demo/index.html
T
2026-08-18 22:08:49 +08:00

656 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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', 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', view: 'ocean.booking.default-list', form: 'ocean.booking.default-form', 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 },
{ key: 'customer_id', label: '客户', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1 },
{ key: 'carrier_id', label: '船公司', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1 },
{ key: 'etd', label: '预计离港日', type: 'date', write: 'direct', req: 0, qry: 1, list: 1, edit: 1 },
{ key: 'load_port', label: '起运港', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1 },
{ key: 'discharge_port', label: '目的港', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1 },
{ key: 'freight_payment',label: '运费支付', type: 'varchar(32)', write: 'direct', req: 0, 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: 'volume', label: '体积', type: 'decimal(18,3)', write: 'direct', req: 0, qry: 0, list: 1, edit: 1 },
{ key: 'container_count',label: '箱量', type: 'int', write: 'computed', req: 0, qry: 0, list: 1, edit: 0 },
{ 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 },
{ key: 'direction', label: '方向', type: 'varchar(16)', write: 'createOnly', req: 1, qry: 1, list: 1, edit: 0 },
{ key: 'customer_id', label: '往来单位', type: 'bigint', write: 'direct', req: 1, qry: 1, list: 1, edit: 1 },
{ key: 'amount', label: '含税金额', type: 'decimal(18,2)', write: 'computed', req: 0, qry: 0, list: 1, edit: 0 },
{ key: 'currency', label: '币种', type: 'varchar(8)', write: 'direct', req: 1, qry: 1, list: 1, edit: 1 },
{ key: 'status', label: '状态', type: 'varchar(32)', write: 'actionOnly', req: 0, qry: 1, list: 1, edit: 0 },
],
};
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 ENTITY_VIEWS = {
'ocean.booking': [
{ key: 'ocean.booking.default-list', queryable: 1, exportable: 1, pageSize: 50 },
{ key: 'ocean.booking.query-simple', queryable: 1, exportable: 1, pageSize: 20 },
],
};
const ENTITY_FORMS = {
'ocean.booking': [
{ key: 'ocean.booking.default-form', type: 'default', relations: ['ocean.booking.containers'] },
{ key: 'ocean.booking.readonly-form', type: 'readonly', relations: [] },
],
};
/* ============ 状态与工具 ============ */
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 entityTable(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>物理表</th><th>删除策略</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.table}</td>
<td class="dim">${e.policy}</td>
<td>${e.desc}</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, table: entityTable(menu.entity),
fields: ENTITY_FIELDS[menu.entity] ? ENTITY_FIELDS[menu.entity].length : 0,
views: ENTITY_VIEWS[menu.entity] ? ENTITY_VIEWS[menu.entity].length : 0,
forms: ENTITY_FORMS[menu.entity] ? ENTITY_FORMS[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">data 类型</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">物理表</div><div class="v">${entity.table}</div></div>
<div class="kv"><div class="k">字段 / 视图 / 表单</div><div class="v">${entity.fields} / ${entity.views} / ${entity.forms}</div></div>
<div class="kv"><div class="k">视图 key</div><div class="v">${menu.view || '—'}</div></div>
<div class="kv"><div class="k">表单 key</div><div class="v">${menu.form || '—'}</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 views = ENTITY_VIEWS[ek] || [];
const forms = ENTITY_FORMS[ek] || [];
return `
<div class="page-head">
<div class="ph-title">${ent.desc} <span class="tag">${ent.policy}</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} · 物理表: ${ent.table} · module: ${mod.key}</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></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.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>
<div class="section">
<div class="section-title">视图与表单 <span class="hint">一个实体可有多视图/多表单</span></div>
<div class="kv-grid">
<div class="kv"><div class="k">视图 s_module_view</div><div class="v">${views.map(v => v.key).join('、') || '—'}</div></div>
<div class="kv"><div class="k">表单 s_module_form</div><div class="v">${forms.map(v => v.key).join('、') || '—'}</div></div>
</div>
${forms.some(f => f.relations.length) ? `<div style="margin-top:10px;font-size:12.5px;color:var(--text-3)">主子表区域(s_module_form_relation):${forms.find(f => f.relations.length).relations.join('、')} 以 table 模式内嵌于表单</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>