From 68ac5db038a899504cd2b2ed3defb91736e7c554 Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Thu, 20 Aug 2026 17:30:56 +0800 Subject: [PATCH] 20260820173056 --- code/fms/.fps-probe/measure-wheel-js.mjs | 120 + code/fms/.fps-probe/measure-wheel.mjs | 125 + code/fms/.fps-probe/trace.mjs | 180 ++ code/fms/FMS新系统架构设计.md | 2587 ----------------- code/fms/FMS新系统核心表结构设计.md | 128 +- .../src/components/fms-table/FmsTable.vue | 32 +- .../fms-table/utils/createCellRenderer.js | 25 +- code/test-demo/index.html | 2 +- code/test-demo/package.json | 1 + code/test-demo/pnpm-lock.yaml | 12 + code/test-demo/public/favicon.svg | 1 - code/test-demo/public/icons.svg | 24 - code/test-demo/src/App.tsx | 441 +-- .../src/components/AutoCodePanel.tsx | 267 -- code/test-demo/src/components/EntityPanel.tsx | 489 ---- code/test-demo/src/components/MenuPanel.tsx | 263 -- code/test-demo/src/components/MetaTree.tsx | 310 -- code/test-demo/src/components/ModulePanel.tsx | 279 -- code/test-demo/src/components/NodeEditor.tsx | 546 ---- .../src/components/PermissionPanel.tsx | 386 --- code/test-demo/src/components/ui.tsx | 117 - code/test-demo/src/data/mock.ts | 345 --- code/test-demo/src/index.css | 146 +- code/test-demo/src/types/index.ts | 184 -- 24 files changed, 975 insertions(+), 6035 deletions(-) create mode 100644 code/fms/.fps-probe/measure-wheel-js.mjs create mode 100644 code/fms/.fps-probe/measure-wheel.mjs create mode 100644 code/fms/.fps-probe/trace.mjs delete mode 100644 code/fms/FMS新系统架构设计.md delete mode 100644 code/test-demo/public/favicon.svg delete mode 100644 code/test-demo/public/icons.svg delete mode 100644 code/test-demo/src/components/AutoCodePanel.tsx delete mode 100644 code/test-demo/src/components/EntityPanel.tsx delete mode 100644 code/test-demo/src/components/MenuPanel.tsx delete mode 100644 code/test-demo/src/components/MetaTree.tsx delete mode 100644 code/test-demo/src/components/ModulePanel.tsx delete mode 100644 code/test-demo/src/components/NodeEditor.tsx delete mode 100644 code/test-demo/src/components/PermissionPanel.tsx delete mode 100644 code/test-demo/src/components/ui.tsx delete mode 100644 code/test-demo/src/data/mock.ts delete mode 100644 code/test-demo/src/types/index.ts diff --git a/code/fms/.fps-probe/measure-wheel-js.mjs b/code/fms/.fps-probe/measure-wheel-js.mjs new file mode 100644 index 00000000..62eedb34 --- /dev/null +++ b/code/fms/.fps-probe/measure-wheel-js.mjs @@ -0,0 +1,120 @@ +import { chromium } from 'playwright-core' + +// JS 派发 wheel 事件驱动滚动(与真实滚轮走同一 onTableWheel → scrollTop += deltaY 路径, +// 不受 CDP 输入合并影响;transform 滚动模式下 DOM scrollTop 恒为 0 也可用) +const BASE = process.env.FMS_BASE || 'http://localhost:5082' +const DURATION_MS = Number(process.env.DURATION_MS || 3000) +const WHEEL_DELTA = Number(process.env.WHEEL_DELTA || 100) +const WHEEL_INTERVAL_MS = Number(process.env.WHEEL_INTERVAL_MS || 8) +const chromePath = + process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe' + +const seedStorage = ` + sessionStorage.setItem('fms-login', JSON.stringify({ + loginInfo: { token: 'probe-token', user: { id: 1, account: 'admin', name: '探针用户' } }, + })); + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'PROBE', account: 'admin' } })); + localStorage.setItem('app', JSON.stringify({ darkMode: false })); +` + +const browser = await chromium.launch({ + executablePath: chromePath, + headless: process.env.HEADED !== '1', + args: [ + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu-sandbox', + ...(process.env.HEADED === '1' ? ['--disable-background-timer-throttling'] : []), + ], +}) +const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }) +await context.addInitScript(seedStorage) +const page = await context.newPage() +page.on('pageerror', (err) => console.log('[pageerror]', String(err).slice(0, 300))) + +console.log(`打开 ${BASE}/dashboard ...`) +await page.goto(`${BASE}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 30000 }) +await page.waitForSelector('.fms-table', { timeout: 20000 }) +await page.waitForFunction(() => document.querySelectorAll('.stk-tbody-main tr').length > 0) +await page.waitForTimeout(1200) + +async function measure(durationMs, drive) { + return page.evaluate( + async ({ durationMs, drive, delta, intervalMs }) => { + const root = document.querySelector('.fms-table') + // wheel 监听器绑定在 .stk-table 滚动容器上;transform 模式下无纵向溢出,直接用容器派发 + const sc = + [...root.querySelectorAll('*')].filter( + (el) => el.scrollHeight > el.clientHeight + 2 && getComputedStyle(el).overflowY !== 'hidden', + )[0] || root.querySelector('.stk-table') + const maxScroll = sc ? sc.scrollHeight - sc.clientHeight : 0 + if (sc) sc.scrollTop = 0 + + let wheelEvents = 0 + let scrollEvents = 0 + let frames = 0 + let minGap = Infinity + let maxGap = 0 + let prev = performance.now() + const start = performance.now() + + const scrollListener = () => scrollEvents++ + if (sc) sc.addEventListener('scroll', scrollListener) + + await new Promise((resolve) => { + const tick = (now) => { + // 每帧同步派发滚轮事件(不受 setInterval 节流影响),等价于高频真实滚轮 + if (drive === 'wheel' && sc) { + sc.dispatchEvent( + new WheelEvent('wheel', { deltaY: delta, deltaMode: 0, cancelable: true, bubbles: true }), + ) + wheelEvents++ + } + const gap = now - prev + prev = now + frames++ + if (gap < minGap) minGap = gap + if (gap > maxGap) maxGap = gap + if (now - start < durationMs) requestAnimationFrame(tick) + else resolve() + } + requestAnimationFrame(tick) + }) + if (sc) sc.removeEventListener('scroll', scrollListener) + const elapsed = (performance.now() - start) / 1000 + + const tbodyTransform = root.querySelector('.stk-tbody-main')?.style.transform || '' + return { + scene: drive, + fps: Math.round((frames / elapsed) * 10) / 10, + avgFrameMs: Math.round(((elapsed * 1000) / frames) * 10) / 10, + minGapMs: Math.round(minGap), + maxGapMs: Math.round(maxGap), + wheelEvents, + scrollEvents, + scrollTop: sc?.scrollTop ?? 0, + renderedRows: root.querySelectorAll('.stk-tbody-main tr').length, + customThumb: root.querySelectorAll('.stk-sb-thumb.vertical').length, + tbodyTransform, + } + }, + { durationMs, drive, delta: WHEEL_DELTA, intervalMs: WHEEL_INTERVAL_MS }, + ) +} + +for (let round = 1; round <= 2; round++) { + console.log(`\n===== 第 ${round}/2 轮 =====`) + const idle = await measure(1200, 'idle') + console.log( + `[静止] FPS=${idle.fps} avgFrame=${idle.avgFrameMs}ms rows=${idle.renderedRows} thumb=${idle.customThumb}`, + ) + const scroll = await measure(DURATION_MS, 'wheel') + console.log( + `[滚轮] FPS=${scroll.fps} avgFrame=${scroll.avgFrameMs}ms minGap=${scroll.minGapMs} maxGap=${scroll.maxGapMs} ` + + `wheelEvt=${scroll.wheelEvents} scrollEvt=${scroll.scrollEvents} scrollTop=${scroll.scrollTop} ` + + `rows=${scroll.renderedRows} thumb=${scroll.customThumb} transform=${scroll.tbodyTransform.slice(0, 40)}`, + ) + await page.waitForTimeout(400) +} + +await browser.close() diff --git a/code/fms/.fps-probe/measure-wheel.mjs b/code/fms/.fps-probe/measure-wheel.mjs new file mode 100644 index 00000000..25cea690 --- /dev/null +++ b/code/fms/.fps-probe/measure-wheel.mjs @@ -0,0 +1,125 @@ +import { chromium } from 'playwright-core' + +const BASE = process.env.FMS_BASE || 'http://localhost:5082' +const SCROLL_MS = Number(process.env.SCROLL_MS || 3000) +const ROUNDS = Number(process.env.ROUNDS || 2) +const WHEEL_INTERVAL = Number(process.env.WHEEL_INTERVAL || 10) // ms 每次滚轮间隔(模拟快速滚轮) +const chromePath = + process.env.CHROME_PATH || + 'C:/Program Files/Google/Chrome/Application/chrome.exe' + +const seedStorage = ` + sessionStorage.setItem('fms-login', JSON.stringify({ + loginInfo: { token: 'probe-token', user: { id: 1, account: 'admin', name: '探针用户' } }, + })); + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'PROBE', account: 'admin' } })); + localStorage.setItem('app', JSON.stringify({ darkMode: false })); +` + +const browser = await chromium.launch({ + executablePath: chromePath, + headless: true, + args: [ + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu-sandbox', + '--disable-background-timer-throttling', + ], +}) +const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }) +await context.addInitScript(seedStorage) +const page = await context.newPage() +page.on('pageerror', (err) => console.log('[pageerror]', String(err).slice(0, 300))) + +console.log(`打开 ${BASE}/dashboard ...`) +await page.goto(`${BASE}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 30000 }) +await page.waitForSelector('.fms-table', { timeout: 20000 }) +await page.waitForFunction(() => document.querySelectorAll('.stk-tbody-main tr').length > 0) +await page.waitForTimeout(1200) + +async function measure(durationMs) { + // 把鼠标移到表格上,确保滚轮事件落在表格滚动容器上 + const rect = await page.evaluate(() => { + const root = document.querySelector('.fms-table') + const r = root?.getBoundingClientRect() + return r ? { x: r.left + r.width / 2, y: r.top + Math.min(200, r.height / 2) } : null + }) + if (rect) await page.mouse.move(rect.x, rect.y) + + // 用真实滚轮事件驱动(transform 模式下 DOM scrollTop 恒为 0,程序化 scrollTop 无效) + const wheelDriver = (async () => { + const end = Date.now() + durationMs + while (Date.now() < end) { + await page.mouse.wheel(0, 100) + await page.waitForTimeout(WHEEL_INTERVAL) + } + })() + + const result = await page.evaluate( + async ({ durationMs }) => { + let frames = 0 + let minGap = Infinity + let maxGap = 0 + let prev = performance.now() + const start = performance.now() + const root0 = document.querySelector('.fms-table') + const sc0 = root0 + ? [...root0.querySelectorAll('*')].filter((el) => el.scrollHeight > el.clientHeight + 2)[0] + : null + const scrollTop0 = sc0?.scrollTop ?? 0 + const vs0 = window.__vs0 + await new Promise((resolve) => { + const tick = (now) => { + const gap = now - prev + prev = now + frames++ + if (gap < minGap) minGap = gap + if (gap > maxGap) maxGap = gap + if (now - start < durationMs) requestAnimationFrame(tick) + else resolve() + } + requestAnimationFrame(tick) + }) + const elapsed = (performance.now() - start) / 1000 + const root = document.querySelector('.fms-table') + const scroller = root + ? [...root.querySelectorAll('*')].filter( + (el) => el.scrollHeight > el.clientHeight + 2, + )[0] + : null + return { + fps: Math.round((frames / elapsed) * 10) / 10, + avgFrameMs: Math.round(((elapsed * 1000) / frames) * 10) / 10, + minGapMs: Math.round(minGap), + maxGapMs: Math.round(maxGap), + renderedRows: root?.querySelectorAll('.stk-tbody-main tr').length ?? 0, + customThumb: root?.querySelectorAll('.stk-sb-thumb.vertical').length ?? 0, + scrollerOverflow: scroller ? getComputedStyle(scroller).overflowY : null, + scrolledBy: (scroller?.scrollTop ?? 0) - scrollTop0, + } + }, + { durationMs }, + ) + await wheelDriver + return result +} + +for (let round = 1; round <= ROUNDS; round++) { + console.log(`\n===== 第 ${round}/${ROUNDS} 轮 =====`) + const idle = await measure(1200) + console.log(`[静止] FPS=${idle.fps} avgFrame=${idle.avgFrameMs}ms rows=${idle.renderedRows} thumb=${idle.customThumb}`) + const scroll = await measure(SCROLL_MS) + console.log( + `[滚轮滚动] FPS=${scroll.fps} avgFrame=${scroll.avgFrameMs}ms minGap=${scroll.minGapMs} maxGap=${scroll.maxGapMs} ` + + `rows=${scroll.renderedRows} thumb=${scroll.customThumb} overflow=${scroll.scrollerOverflow}`, + ) + await page.evaluate(() => { + const root = document.querySelector('.fms-table') + const els = root ? [...root.querySelectorAll('*')] : [] + const sc = els.filter((el) => el.scrollHeight > el.clientHeight + 2)[0] + if (sc) sc.scrollTop = 0 + }) + await page.waitForTimeout(400) +} + +await browser.close() diff --git a/code/fms/.fps-probe/trace.mjs b/code/fms/.fps-probe/trace.mjs new file mode 100644 index 00000000..9d515634 --- /dev/null +++ b/code/fms/.fps-probe/trace.mjs @@ -0,0 +1,180 @@ +import { chromium } from 'playwright-core' + +const BASE = process.env.FMS_BASE || 'http://localhost:5082' +const DURATION_MS = Number(process.env.DURATION_MS || 2500) +const STEP = Number(process.env.STEP || 120) +const chromePath = + process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe' + +const seedStorage = ` + sessionStorage.setItem('fms-login', JSON.stringify({ + loginInfo: { token: 'probe-token', user: { id: 1, account: 'admin', name: '探针用户' } }, + })); + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'PROBE', account: 'admin' } })); + localStorage.setItem('app', JSON.stringify({ darkMode: false })); +` + +const browser = await chromium.launch({ + executablePath: chromePath, + headless: process.env.HEADED !== '1', + args: [ + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu-sandbox', + '--disable-background-timer-throttling', + ], +}) +const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }) +await context.addInitScript(seedStorage) +const page = await context.newPage() +page.on('pageerror', (err) => console.log('[pageerror]', String(err).slice(0, 300))) + +await page.goto(`${BASE}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 30000 }) +await page.waitForSelector('.fms-table', { timeout: 20000 }) +await page.waitForFunction(() => document.querySelectorAll('.stk-tbody-main tr').length > 0) +await page.waitForTimeout(1200) + +const cdp = await context.newCDPSession(page) + +// 预热:driver='scrollTop' 程序化滚动(padding 模式)/ 'wheel' 每帧派发滚轮(transform 模式) +const DRIVE = process.env.DRIVE || 'scrollTop' + +async function warmup(drive) { + await page.evaluate(async ({ drive }) => { + const root = document.querySelector('.fms-table') + const els = [...root.querySelectorAll('*')] + const scroller = els + .filter((el) => el.scrollHeight > el.clientHeight + 2 && getComputedStyle(el).overflowY !== 'hidden') + .sort((a, b) => b.scrollHeight - b.clientHeight - (a.scrollHeight - a.clientHeight))[0] || + root.querySelector('.stk-table') + window.__scroller = scroller + window.__maxScroll = scroller ? scroller.scrollHeight - scroller.clientHeight : 0 + if (scroller) scroller.scrollTop = 0 + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) + const start = performance.now() + await new Promise((resolve) => { + const tick = (now) => { + if (drive === 'wheel') { + scroller.dispatchEvent( + new WheelEvent('wheel', { deltaY: 100, deltaMode: 0, cancelable: true, bubbles: true }), + ) + } else if (scroller.scrollTop + 120 < window.__maxScroll) { + scroller.scrollTop += 120 + } else { + scroller.scrollTop = 0 + } + if (now - start < 800) requestAnimationFrame(tick) + else resolve() + } + requestAnimationFrame(tick) + }) + }, { drive }) +} + +await warmup(DRIVE) + +const collected = [] +let complete = false +cdp.on('Tracing.dataCollected', (event) => { + if (event.value && Array.isArray(event.value)) collected.push(...event.value) +}) +cdp.on('Tracing.tracingComplete', () => { + complete = true +}) +await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { included_categories: ['devtools.timeline', 'v8.execute', 'blink.user_timing'] }, +}) +console.log('tracing started, driving scroll ...') + +const drivePromise = page.evaluate(async ({ step, durationMs, drive }) => { + const scroller = window.__scroller || document.querySelector('.stk-table') + const start = performance.now() + await new Promise((resolve) => { + const tick = (now) => { + if (drive === 'wheel') { + scroller.dispatchEvent( + new WheelEvent('wheel', { deltaY: 100, deltaMode: 0, cancelable: true, bubbles: true }), + ) + } else if (scroller.scrollTop + step < window.__maxScroll) { + scroller.scrollTop += step + } else { + scroller.scrollTop = 0 + } + if (now - start < durationMs) requestAnimationFrame(tick) + else resolve() + } + requestAnimationFrame(tick) + }) +}, { step: STEP, durationMs: DURATION_MS, drive: DRIVE }) + +await drivePromise +await cdp.send('Tracing.end') +// 等待 dataCollected 批次送达 +for (let i = 0; i < 20 && !complete; i++) await page.waitForTimeout(250) +await page.waitForTimeout(500) +console.log(`tracing ended, collected ${collected.length} events (complete=${complete})`) + +const events = collected + +// 按事件名聚合:count / totalDur / selfTime(总时长 - 子事件时长) +const agg = new Map() +const byPhase = new Map() +for (const ev of events) { + if (!ev.name || !ev.dur) continue + const key = ev.name + const a = agg.get(key) || { count: 0, total: 0, self: 0 } + a.count++ + a.total += ev.dur + agg.set(key, a) +} +// selfTime:父事件 dur 减去子事件 dur(简化:同一线程内嵌套) +const childrenByParent = new Map() +for (const ev of events) { + if (!ev.dur) continue + const key = `${ev.pid}:${ev.tid}` + const list = childrenByParent.get(key) || [] + list.push(ev) + childrenByParent.set(key, list) +} +for (const [key, list] of childrenByParent) { + list.sort((a, b) => a.ts - b.ts) + // 每个事件查找其直接父(最近的前驱且覆盖其区间) + for (let i = 0; i < list.length; i++) { + const ev = list[i] + if (!ev.dur) continue + const end = ev.ts + ev.dur + let self = ev.dur + for (let j = i + 1; j < list.length; j++) { + const c = list[j] + if (c.ts >= end) break + if (c.ts >= ev.ts && c.ts + (c.dur || 0) <= end) self -= c.dur || 0 + } + ev.__self = Math.max(0, self) + } +} + +const selfAgg = new Map() +for (const ev of events) { + if (!ev.dur || ev.__self == null) continue + const a = selfAgg.get(ev.name) || { count: 0, self: 0 } + a.count++ + a.self += ev.__self + selfAgg.set(ev.name, a) +} + +const top = [...selfAgg.entries()].sort((a, b) => b[1].self - a[1].self).slice(0, 25) +console.log('\n===== 按事件名 selfTime 汇总 (μs) =====') +for (const [name, a] of top) { + console.log(`${name.padEnd(48)} count=${String(a.count).padStart(6)} self=${(a.self / 1000).toFixed(1)}ms`) +} + +// 帧/合成相关事件计数 +const frameEvents = ['BeginFrame', 'DrawFrame', 'UpdateLayoutTree', 'Layout', 'Paint', 'RasterTask', 'CompositeLayers', 'Commit'] +console.log('\n===== 渲染相关事件 =====') +for (const name of frameEvents) { + const a = agg.get(name) + if (a) console.log(`${name.padEnd(24)} count=${a.count} total=${(a.total / 1000).toFixed(1)}ms`) +} + +await browser.close() diff --git a/code/fms/FMS新系统架构设计.md b/code/fms/FMS新系统架构设计.md deleted file mode 100644 index 343c4134..00000000 --- a/code/fms/FMS新系统架构设计.md +++ /dev/null @@ -1,2587 +0,0 @@ -# FMS 新系统架构设计 - -## 1. 文档目的 - -本文档整理 FMS 新系统重构的核心架构方向。新系统不是传统的固定功能 ERP,也不是完全依赖代码开发的业务系统,而是面向内部 ERP 场景的模块化低代码平台。 - -系统需要同时满足三类使用者: - -- 开发人员:开发复杂业务规则、专用页面、流程、算法和外部接口。 -- 售后技术人员:不修改代码即可新增实体、字段、引用关系、列表、表单、查询和基础页面。 -- 业务人员:通过统一的 ERP 页面和 AI 助手完成日常业务操作。 - -本次重构不以兼容旧架构或最小迁移成本为目标,可以重新设计模块模型、元数据模型、数据库变更机制和运行时。 - -## 2. 当前问题 - -旧系统以 `s_module` 为中心,将多种职责集中在同一套模块元数据中: - -- 菜单和路由 -- 数据表和视图 -- 字段定义 -- 列表、编辑和查询配置 -- 操作权限 -- 多语言 -- 自动编码 - -这种设计导致数据库结构和配置元数据相互依赖。创建或迁移一个功能时,通常需要执行以下步骤: - -```text -创建业务表 --> 创建查询视图 --> 注册模块 --> 拉取数据库字段 --> 刷新字段元数据 --> 配置列表 --> 配置表单 --> 配置查询 -``` - -主要问题包括: - -1. 数据库迁移和元数据迁移必须同时维护,迁移流程复杂。 -2. 新增字段后需要手工刷新和同步,数据库不是唯一事实来源,元数据也不是唯一事实来源。 -3. 模块、实体、页面、菜单和数据源概念混杂。 -4. 配置步骤多,售后技术人员难以独立完成小功能调整。 -5. 前端依赖表名、视图名和 SQL 条件,配置容易与数据库结构失配。 -6. 配置缺少完整的版本、发布、校验和跨环境迁移机制。 - -## 3. 业务需求、产品边界与设计目标 - -架构回答“系统怎么建设”,业务需求回答“系统需要建设什么”。在继续设计数据库和元数据之前,需要选择真实业务,从开始到结束梳理完整流程。 - -例如一票海运出口可能经历: - -```text -客户询价 --> 报价 --> 接单 --> 向船公司订舱 --> 收到订舱确认 --> 安排拖车和报关 --> 装船 --> 制作提单 --> 录入应收应付 --> 开票和收付款 --> 业务归档 -``` - -对每一个流程步骤都需要确认: - -1. 谁执行该步骤。 -2. 输入什么数据和文件。 -3. 产生或修改哪些实体。 -4. 状态如何变化。 -5. 哪些条件允许或禁止执行。 -6. 会触发哪些通知、任务、费用或后续流程。 -7. 失败、退回、取消和重新打开时如何处理。 - -除正常流程外,需要重点收集异常场景,例如改单、退单、拆单、并单、船期延误、费用冲销、跨币种结算和已完成业务重新打开。ERP 的复杂度通常来自状态变化和异常流程,而不是普通字段录入。 - -### 3.1 状态与动作 - -业务状态不能由用户直接随意修改,应通过动作完成。例如: - -```text -草稿 -> 已提交 -> 已确认 -> 已装船 -> 已完成 - | | - v v - 已拒绝 已取消 -``` - -每个状态动作需要定义执行人、输入参数、前置条件、是否可撤销、审计内容,以及对费用、任务和其他实体的影响。 - -### 3.2 ERP 横向能力 - -以下能力通常会被多个业务模块共同使用,应作为平台能力统一考虑: - -- 组织、部门、岗位和人员 -- 客户、供应商和联系人 -- 国家、港口、机场、币种和计量单位 -- 业务编号和编号规则 -- 附件、文件版本、打印和模板 -- 数据修改历史和操作审计 -- 备注、评论、消息和待办 -- 导入、导出和批量处理 -- 审批和工作流 -- 定时任务和通知 -- 全局搜索、报表和统计 -- 多语言、时区、币种、汇率和税率 -- 数据归档、备份和恢复 - -### 3.3 低代码边界 - -售后可以配置不代表所有功能都配置化。建议将能力划分为四级: - -| 级别 | 能力 | 实现方式 | -| --- | --- | --- | -| L1 | 修改名称、顺序、显示、必填 | 售后直接配置 | -| L2 | 新增字段、实体、关系和普通页面 | 元数据和 Schema Compiler | -| L3 | 简单校验、公式、动作和流程 | 受限制的规则或流程设计器 | -| L4 | 复杂业务、复杂页面和外部接口 | 开发人员编码 | - -复杂费用分摊、多票合并拆分、财务冲销、复杂单证生成和外部 EDI 等功能不应被强行塞入通用字段配置。低代码平台的目标是让简单需求不需要开发,而不是用配置重新发明一门难以维护的编程语言。 - -### 3.4 推荐验证方式 - -不要先完成整个平台再开发业务。应选择一个真实模块做纵向验证,例如海运订舱: - -```text -梳理完整流程和异常流程 --> 设计订舱、客户、船公司等实体 --> 实现元数据和 Schema Compiler --> 生成通用列表和表单 --> 实现确认、取消等业务动作 --> 接入权限和审计 --> 由真实用户试用 --> 根据实际问题抽象平台能力 -``` - -需求阶段至少应形成角色清单、业务流程、状态流转、实体关系、业务规则、权限矩阵、文件模板、报表、外部系统、异常场景和数据量预估。 - -### 3.5 设计目标 - -新系统的目标是: - -- 继续以海运、空运、财务等业务模块为中心组织系统。 -- 售后技术人员可以通过界面新增表、字段、引用关系和基础页面。 -- 修改显示名称、显示顺序和启用状态不需要数据库迁移。 -- 新增字段和实体时,由系统自动生成数据库结构变更。 -- 普通业务页面可以由通用运行时生成,复杂业务允许代码扩展。 -- IT 开发人员可以方便地增加特殊 Vue 页面、业务 Handler 和特殊 POST 接口。 -- 每次模块配置保存都生成不可变配置版本,默认可以立即生效,也可以先预览验证再激活,并支持回滚、导出、导入和跨环境迁移。 -- 菜单、动作、数据范围、字段级访问和配置管理权限由统一权限中心处理;首期支持按用户、角色或用户组配置字段查看和导出权限,复杂条件脱敏作为后续扩展。 -- 菜单、页面、字段、动作、字典和主数据支持统一多语言解析与回退。 -- 技术可观测日志和客户可见的业务操作日志分层存储并可关联追踪。 -- 系统元数据具备完整业务语义,可直接供 AI 理解和调用。 -- AI 能查询数据、执行业务动作、生成配置变更和处理业务文档。 - -## 4. 核心架构决策 - -新系统采用以下总体方向: - -```text -模块化低代码平台 -= 模块中心 -+ 实体语义模型 -+ 元数据中心 -+ 数据库结构编译器 -+ 通用数据运行时 -+ 页面配置运行时 -+ 业务动作与扩展机制 -+ 开发扩展 SDK -+ 统一权限中心 -+ 双层日志与可观测性 -+ 事件中心 -+ AI 工具网关 -``` - -核心原则如下: - -1. 模块是业务能力边界,不是数据库表的别名。 -2. 实体是数据和字段的所有者,页面只是实体的一种展示方式。 -3. 核心业务字段使用真实数据库列,不采用全量 EAV 模型。 -4. 元数据描述目标结构,Schema Compiler 负责将目标结构落实到数据库。 -5. 普通 CRUD 由通用运行时完成,复杂业务通过动作和代码扩展实现。 -6. 配置保存时生成不可变版本;默认保存并生效,也允许候选版本验证后再激活,运行时只读取当前版本。 -7. 页面、数据、动作、报表、配置和 AI 工具使用同一个权限决策结果。 -8. 技术日志和业务日志职责分离,通过统一请求标识关联。 -9. AI 负责理解意图和提出方案,平台负责校验、编译和确定性执行。 -10. 一个租户使用一个独立业务数据库,所有客户定制继续使用同一套平台架构和扩展机制。 - -## 5. 总体架构 - -```text -+----------------------------------------------------------+ -| 使用入口 | -| ERP 页面 | 配置中心 | AI 助手 | 移动端 | 外部集成 | -+-----------------------------+----------------------------+ - | -+-----------------------------v----------------------------+ -| 能力与工具网关 | -| 通用数据接口 | 特殊接口 | 业务动作 | 配置 | 报表 | 文档 | -+-----------------------------+----------------------------+ - | -+-----------------------------v----------------------------+ -| 通用运行时 | -| 数据运行时 | 动作 | 权限 | 事件 | 业务审计 | AI 网关 | -+-----------------------------+----------------------------+ - | -+-----------------------------v----------------------------+ -| 元数据中心与 Schema Compiler | -| 模块 | 实体 | 字段 | 关系 | 页面 | 动作 | 版本 | 变更集 | -+-----------------------------+----------------------------+ - | -+-----------------------------v----------------------------+ -| 基础设施 | -| 业务数据库 | 文件存储 | 搜索索引 | 消息队列 | 模型服务 | -+----------------------------------------------------------+ -``` - -系统可以先采用模块化单体,暂不为拆分微服务而增加额外复杂度。模块边界应在代码、数据库命名、事件和依赖关系中保持清晰,以便未来按实际需要拆分。 - -## 6. 模块模型 - -### 6.1 模块职责 - -模块用于表达完整的业务能力和边界。例如: - -```text -platform -|- identity 用户、组织、角色 -|- master-data 客户、联系人、港口、机场、币种 -|- file 文件和附件 -|- audit 审计记录 -|- workflow 工作流 -|- reporting 报表 - -modules -|- ocean 海运 -|- air 空运 -|- finance 财务 -|- contract 合同 -|- crm 客户和销售 -``` - -海运模块可以包含: - -```text -ocean -|- booking 订舱 -|- shipment 海运单 -|- container 集装箱 -|- bill-of-lading 提单 -|- sailing 船期 -``` - -空运模块可以包含: - -```text -air -|- booking 订舱 -|- shipment 空运单 -|- mawb 主单 -|- hawb 分单 -|- flight 航班 -``` - -海运和空运可以共享客户、地点、货物、附件、币种和费用等稳定概念,但不应为了复用而强行合并成一个包含大量类型判断的超级模块。 - -### 6.2 模块定义 - -每个模块拥有稳定的模块编码和依赖关系: - -```yaml -key: ocean -name: 海运 -dependencies: - - platform.master-data - - platform.file -entities: - - ocean.booking - - ocean.shipment - - ocean.container -pages: - - ocean.booking.default-list - - ocean.booking.default-form -menus: - - ocean.booking -``` - -模块依赖必须形成有向无环图,不能循环依赖。每个实体、动作、事件和特殊页面只能有一个所属模块;其他模块通过声明依赖后使用公开实体、查询、动作或事件,不能绕过通用运行时直接修改其他模块的数据表。 - -跨模块访问不要求为每个领域开发 Controller。普通读写仍使用 `EntityService`,业务状态变化使用 `ActionService`,异步协作使用事件。这样既保留内部 ERP 的开发灵活性,又不会让模块边界只停留在目录名称上。 - -模块定义可以从配置中心导出为当前快照 bundle,并进入 Git、测试和发布流程。 - -## 7. 实体模型 - -### 7.1 模块与实体分离 - -模块负责组织业务能力,实体负责数据结构。例如: - -```text -模块:ocean -实体:ocean.booking -物理表:ocean_booking -默认列表:ocean.booking.default-list -默认表单:ocean.booking.default-form -``` - -一个实体可以有多个列表、表单和详情页面。页面配置不能反过来决定实体的数据结构。页面是稳定的访问入口,负责绑定路由、视图/表单或特殊组件以及页面访问权限;菜单只负责组织页面入口,不重复维护内部页面路由。 - -页面和菜单的配置关系如下: - -```text -实体 -|- 视图 / 表单 -|- 页面(page_key + route + permission) -`- 菜单(menu_key + parent + page_key + sort + visible) -``` - -菜单类型至少包括 `directory`、`page` 和 `external`。目录菜单不直接代表业务页面;只有子页面存在且当前用户有页面访问权限时,目录才显示。内部 `page` 菜单的路由始终从页面资源解析,外链菜单才允许单独配置 URL。 - -### 7.2 字段的稳定标识 - -字段必须区分技术标识和显示名称: - -```text -field_key: carrier_id -label: 船公司 -``` - -规则如下: - -- `field_key` 是程序、配置、迁移和 AI 使用的稳定标识,创建后尽量不修改。 -- `label` 是显示名称,可以随时修改,不触发数据库迁移。 -- 禁用字段只修改元数据状态并从页面隐藏,默认保留数据库列和历史数据。 -- 真正修改字段技术名或类型时,必须生成显式结构迁移。 -- 删除字段默认采用先停用、后归档的方式,不直接删除数据。 - -### 7.3 字段语义 - -字段元数据不仅用于生成页面,还要供 AI 和自动化理解: - -```yaml -key: carrier_id -label: 船公司 -description: 承担本次海运运输的承运人 -semanticType: organization.carrier -aliases: - - 承运人 - - 船东 - - Carrier -dataType: reference -referenceEntity: base.company -required: true -searchable: true -writeMode: direct -``` - -建议字段支持以下语义属性: - -| 属性 | 说明 | -| --- | --- | -| `key` | 稳定技术标识 | -| `label` | 当前显示名称 | -| `description` | 业务含义 | -| `aliases` | 中文、英文和行业别名 | -| `semanticType` | 客户、地点、金额、重量等语义类型 | -| `dataType` | 数据类型 | -| `example` | 示例值 | -| `unit` | 金额、重量、体积等单位 | -| `required` | 是否必填 | -| `enabled` | 是否启用 | -| `searchable` | 是否支持查询 | -| `writeMode` | 后端允许的写入方式 | -| `aiHints` | AI 使用提示 | - -### 7.4 字段写入策略 - -字段是否显示和后端是否允许写入是两个不同概念。实体字段使用 `writeMode` 控制后端写入;页面是否显示输入框、是否只读由 `meta_form_field` 控制,不能使用一个 `editable` 同时表达两层含义: - -| 模式 | 说明 | 示例 | -| --- | --- | --- | -| `direct` | 可以通过 `saveobjt` 普通保存 | 备注、ETD、件数 | -| `createOnly` | 仅创建时允许写入 | 来源系统、初始业务类型 | -| `readOnly` | 前端和普通接口只读 | 汇总金额、关联展示值 | -| `computed` | 由公式或计算服务生成 | 毛利、计费重量 | -| `actionOnly` | 只能通过指定业务动作修改 | 审核状态、确认时间 | -| `system` | 仅平台内部维护 | 创建人、版本、流水状态 | - -`saveobjt` 必须根据 `writeMode` 拒绝不允许的字段,而不是只根据前端是否显示输入框判断。状态字段、审核人、审核时间、结算状态和作废标记通常应配置为 `actionOnly` 或 `system`。 - -实体还需要支持保存前业务校验器。通用保存负责类型、必填和权限校验,实体校验器负责跨字段规则;涉及状态变化、跨实体事务和外部副作用的行为必须通过 `action.execute` 或特殊 Handler 完成。 - -### 7.5 关系与主从生命周期 - -`meta_relation` 不能只描述引用目标,还需要描述: - -```text -关系类型 one-to-one、one-to-many、many-to-one、many-to-many -所有权 引用、组合、主从 -是否必填 -删除策略 restrict、set-null、cascade、archive -保存策略 独立保存、随主实体事务保存 -排序字段 -唯一性约束 -``` - -客户、港口等共享主数据通常是普通引用,删除业务单不能删除客户;订舱明细、箱信息等可能属于组合关系,需要明确随主单归档或删除。`saveobjt` 的多实体事务必须按照关系元数据检查外键、所有权和删除顺序。 - -组合关系可以作为主实体编辑表单中的子表区域使用。子表区域引用一个 `meta_relation`,并绑定子实体的列表列配置和编辑配置;通用运行时负责维护主外键、行排序、增删改和主子表事务。复杂的跨行计算、跨实体联动和状态变化仍通过动作或特殊 Handler 实现。 - -### 7.6 删除策略 - -不采用所有实体统一软删除。`meta_entity` 必须声明 `deletePolicy`,通用保存和动作引擎根据实体类型、引用关系和业务状态决定是否允许删除: - -| 策略 | 适用对象 | 行为 | -| --- | --- | --- | -| `restrictWhenReferenced` | 公司、客户、港口、费用类型等主数据 | 无引用时物理删除;有引用时拒绝删除,只能停用或合并 | -| `lifecycle` | 订舱、提单、发票、结算单等业务单据 | 草稿且没有下游业务时可以删除;正式后只能取消或作废 | -| `cascadeOwned` | 订舱明细、箱信息等主表拥有的从数据 | 删除允许删除的主表时在同一事务级联删除 | -| `hardDelete` | 临时记录、缓存和确定不需要保留的数据 | 校验权限后直接物理删除 | - -`enabled = false` 表示数据仍然存在但不再允许新业务选择,不等于删除;`status = cancelled/void` 表示正式业务已经取消或作废,也不等于删除。系统不默认给所有业务表增加 `deleted` 字段,只有未来确实需要回收站和恢复能力的实体才单独采用软删除。 - -例如删除公司时: - -```text -读取 meta_relation 和数据库外键 --> 检查海运、空运、财务、合同等引用 --> 没有引用:物理删除 --> 存在引用:拒绝删除并显示引用实体和数量 --> 提供“停用”或“合并到另一家公司”动作 -``` - -通用运行时负责在删除前生成友好提示,数据库外键使用 `restrict` 作为并发情况下的最后保护。合并主数据属于特殊动作:把允许迁移的引用改到目标记录,保存完整审计,确认原记录不再被引用后再删除或停用。 - -## 8. 元数据中心 - -建议将元数据拆分为明确的资源,而不是继续扩展单一模块表: - -| 元数据资源 | 职责 | -| --- | --- | -| `meta_module` | 模块、依赖、启用状态 | -| `meta_entity` | 实体和物理表映射 | -| `meta_field` | 字段结构和业务语义 | -| `meta_relation` | 实体关系和引用规则 | -| `meta_dictionary` | 固定字典和选项 | -| `meta_query_source` | 复杂查询数据源 | -| `meta_page` | 页面入口、路由、页面类型、组件和访问权限 | -| `meta_menu` | 菜单层级、页面/外链绑定、权限、图标、顺序和可见性 | -| `meta_view` | 列表、详情等视图定义 | -| `meta_view_field` | 列表字段、顺序、宽度和格式 | -| `meta_form` | 表单定义 | -| `meta_form_field` | 表单分组、控件、校验和布局 | -| `meta_form_relation` | 表单中的主子关系区域、子表视图和编辑行为 | -| `meta_action` | 通用动作和业务动作 | -| `meta_i18n` | 多语言资源 | -| `meta_change_set` | 配置和结构变更集 | -| `meta_release` | 不可变配置版本及 checksum | -| `meta_release_resource` | 配置版本包含的资源修订快照 | -| `meta_runtime_state` | 当前租户正在使用的 `current_release_id` | -| `meta_release_activation` | 发布和回滚的激活历史 | - -关系应使用稳定的 module key、entity key 和 field key 表达。数据库内部可以使用数值或 UUID 主键,但导入、导出和 AI 工具不能依赖环境相关的内部 ID。 - -### 8.1 多语言资源模型 - -多语言是元数据运行时的一部分,不能只在前端写固定翻译文件。需要区分四类内容: - -| 类型 | 示例 | 处理方式 | -| --- | --- | --- | -| 系统元数据 | 模块、菜单、实体、字段、动作名称 | `meta_i18n` | -| 字典选项 | 草稿、已确认、运输条款 | `meta_i18n` 或字典翻译表 | -| 主数据名称 | 国家、港口、机场名称 | 实体多语言子表或翻译字段 | -| 用户业务内容 | 备注、邮件正文、业务说明 | 默认保留原文,按需提供 AI 翻译 | - -多语言 key 必须由稳定技术 key 生成,不能依赖显示名称、物理字段名或环境相关 ID: - -```text -module.ocean -entity.ocean.booking -field.ocean.booking.carrier_id -action.ocean.booking.confirm -dictionary.shipment_status.confirmed -``` - -建议的多语言资源结构: - -```text -resource_key -locale -value -source 手工、导入或 AI -status 草稿、已确认 -version -updated_at -updated_by -``` - -语言回退采用确定性顺序: - -```text -用户语言 zh-CN --> 通用语言 zh --> 租户默认语言 --> 系统默认语言 --> 元数据基础 label --> 稳定 key -``` - -页面运行时、菜单、字段、动作、字典和 Select 回显都通过统一 i18n 服务解析。运行时请求在上下文中传递 `locale`,但业务数据存储不因显示语言而改变。 - -模块 bundle 必须携带多语言资源。售后可以补充和修改翻译,配置发布时一同生成变更集。AI 可以生成批量翻译草稿,但需要保留来源、模型版本并经过人工确认后发布。 - -### 8.2 页面与菜单配置 - -页面和菜单都使用稳定业务 key,并随模块配置版本一起发布。推荐的页面配置如下: - -```yaml -page: - key: ocean.booking.default-list - module: ocean - type: runtime - route: /ocean/bookings - view: ocean.booking.default-list - permission: ocean.booking.view - visible: true -``` - -菜单配置只描述导航树,不重复描述页面布局: - -```yaml -menu: - key: ocean.booking - module: ocean - parent: ocean - type: page - page: ocean.booking.default-list - nameKey: menu.ocean.booking - icon: ship - sort: 10 - visible: true -``` - -配置校验必须保证: - -1. 页面引用的视图、表单、组件和权限资源存在且属于当前模块或其依赖模块。 -2. `page` 菜单必须绑定页面,`directory` 菜单不能绑定页面或 URL,`external` 菜单必须提供外部 URL 和菜单权限。 -3. 同一个激活 release 内,页面 route 唯一,菜单 key、页面 key 和父子关系无循环。 -4. 菜单显示继承页面访问权限;目录菜单下没有可访问页面时不显示。 -5. 页面、菜单名称和图标等显示信息通过统一 i18n 和资源配置解析。 - -新增或修改页面、菜单、顺序、可见性和路由时,只生成元数据变更集;新增特殊 Vue 组件仍需代码部署。 - -## 9. Select 与引用关系 - -Select 选项应当是一等模型,不再要求为每一个下拉框创建数据库视图。 - -### 9.1 固定字典 - -用于状态、运输条款、包装类型等固定选项: - -```yaml -type: dictionary -dictionary: shipment.status -``` - -### 9.2 实体引用 - -用于客户、供应商、港口、船公司等来自其他实体的数据: - -```yaml -type: reference -targetEntity: base.company -valueField: id -labelTemplate: "{code} - {name}" -filter: - company_type: carrier - enabled: true -orderBy: - - name asc -multiple: false -``` - -通用运行时根据实体和关系元数据自动完成搜索、分页、回显和关联查询。 - -### 9.3 复杂查询数据源 - -复杂选项允许引用独立的查询资源: - -```yaml -key: available-carriers -sourceType: sql -parameters: - - route_id -``` - -复杂 SQL、脚本或代码查询必须作为独立且版本化的查询资源存在,不能散落在每个表单字段配置中。 - -## 10. 数据库存储策略 - -### 10.1 真实列优先 - -核心字段和售后新增的常用字段默认创建为真实数据库列,以保证: - -- 查询和索引性能 -- 报表和数据分析便利性 -- 数据迁移可控 -- SQL 排查方便 -- 外部系统集成清晰 - -不建议把所有数据存成 EAV: - -```text -entity_id + field_id + value -``` - -EAV 或 JSON 扩展字段仅用于极少查询、变化频繁、租户高度个性化的补充字段。 - -### 10.2 表归属 - -数据库表需要体现模块归属。可以使用 SQL schema: - -```text -core.user -master.company -ocean.booking -ocean.container -air.booking -finance.invoice -``` - -如果现有数据库规范不适合使用 schema,也可以使用稳定前缀: - -```text -core_user -master_company -ocean_booking -air_booking -finance_invoice -``` - -所有新实体自动包含统一技术字段,例如: - -```text -id -created_at -created_by -updated_at -updated_by -version -enabled -``` - -当前的 `orgId` 已经对应一个独立租户数据库,因此普通业务表不需要再保存 `tenant_id`。总分公司暂不纳入第一阶段,当前业务表也不统一增加 `business_org_id`。未来确实需要时,在同一个租户数据库中增加业务组织树和 `business_org_id`,它只负责总分公司数据归属,不承担租户隔离,也不改变现有数据源注入模型。 - -业务编号与内部主键分离,由统一编号服务管理。 - -### 10.3 一租户一数据库 - -FMS 采用一个租户一个业务数据库的物理隔离模式。当前 `fms-api` 中的 `orgId` 就是租户路由标识,应沿用现有的数据源注入机制: - -```text -登录时提交 orgId --> AuthService 使用该 orgId 对应的数据源验证用户 --> JWT 保存 orgId --> JwtAuthFilter 将 orgId 注入 OrgContext --> OrgRoutingDataSource 根据 OrgContext 取得连接 --> Service 只需注入统一 DataSource --> 本次请求的所有数据操作进入当前租户数据库 -``` - -`OrgContext.requireOrgId()` 在上下文缺失时直接失败,`OrgDataSourceManager` 按 orgId 创建和缓存连接池,现有实现已经具备清晰的物理隔离边界。新系统不需要为了元数据和配置另外建设一个参与运行时的租户控制数据库。 - -每个租户数据库本身就是一个完整、独立的运行单元: - -```text -租户数据库 -|- 业务数据 -|- 元数据和页面配置 -|- Schema 历史和 change_set -|- 菜单、权限和多语言 -|- 业务审计和 Outbox -|- 后台任务和集成记录 -``` - -售后或 AI 发起配置修改时,请求已经绑定当前 `OrgContext`,Schema Compiler、元数据发布和缓存失效都只作用于当前数据库。无论修改平台通用配置还是客户定制配置,都不会改变其他租户的数据库、配置或运行状态,也不要求其他租户执行刷新、迁移或重启。 - -元数据缓存、实体注册表和权限快照如果放在共享后端进程中,缓存 key 必须包含 `orgId`,发布时只清理当前 orgId 的缓存。后台任务、消息消费和异步线程没有 Web Filter 自动注入上下文,任务入口需要携带并设置 `orgId`,执行结束后清理 `OrgContext`;之后继续使用同一个统一 `DataSource` 即可。 - -当前数据库连接配置可以继续由 `config/dbconfigs/{ORG}.properties` 管理。将来如果租户数量增加,可以把连接配置改为配置服务或密钥服务,但这只是数据源配置来源的变化,不改变一租户一库和请求级注入模型。 - -这里的“通用配置”是指所有租户使用同一种元数据结构、配置界面和运行时能力,不是指所有租户共同读取一条全局配置。通用字段、页面、Select 或权限模板一旦安装到某个租户,就成为该租户数据库中的配置;对它的修改仍然只影响当前租户。 - -页面运行时、查询运行时、保存运行时、权限引擎和 AI 工具都是读取“当前数据源中的当前配置”的通用组件。修改某个租户配置时,不需要修改或重新注册这些组件;下一次处理该租户请求时,它们使用该租户的新 `current_release_id` 即可。只有新增 Java Handler、Vue 专用组件或新的底层平台能力时才涉及代码部署。 - -### 10.4 客户定制不分叉架构 - -客户定制必须继续使用同一套模块、实体、字段、页面、动作、权限、Schema Compiler 和扩展 SDK,不允许为单个客户复制并长期维护另一套平台架构。 - -推荐配置层次: - -```text -module bundle 作为安装或升级输入 --> 当前租户数据库中的完整活动配置 --> 用户个人偏好 -``` - -运行时只读取当前租户数据库,不需要跨租户合并配置,也不需要依赖其他租户的版本。为了后续升级能够识别平台资源和客户定制,租户新增或修改的实体、字段、页面和动作仍应使用稳定 key,并标记来源: - -```text -origin platform 或 tenant -``` - -导入 module bundle 时,只把 bundle 当前快照与当前租户配置做双向差异比较,不维护旧平台定义和新平台定义。存在以下冲突时,停止当前导入并让技术人员选择保留本地、采用导入内容、重命名或跳过: - -- 相同稳定 key 的字段类型或资源类型不同。 -- 导入内容要求删除当前仍被依赖的字段或页面。 -- 特殊页面或 Handler 依赖的实体、字段或动作不存在。 -- 导入内容与当前租户定制使用了相同 key,但定义不同。 - -客户定制可以包含配置、特殊页面和特殊 Handler,但交付物仍然是标准 module bundle 或 extension bundle,并通过统一发布机制安装到目标租户。 - -## 11. Schema Compiler - -Schema Compiler 是新架构的关键组件,用于把实体元数据转换成实际数据库结构。 - -```text -当前数据库结构 -+ 目标实体元数据 - | - v -结构差异分析 - | - v -迁移计划和影响预览 - | - v -执行 DDL - | - v -生成配置 release 并更新运行时缓存 -``` - -Schema Compiler 应支持: - -- 创建实体和物理表 -- 新增字段 -- 修改字段类型和长度 -- 新增索引和唯一约束 -- 创建引用关系和外键 -- 生成默认列表、表单和查询配置 -- 识别数据库结构与元数据之间的漂移 -- 生成可导出、可审查的迁移脚本 -- 记录执行状态、错误和校验结果 - -外部工具直接修改数据库后,可以提供结构对账和导入功能,但正常操作应始终从元数据配置中心发起,不需要人工点击“拉取字段”或“刷新字段”。 - -### 11.1 不可变配置版本 - -每次保存配置都创建一个新的 `meta_release`,版本包含的配置内容不可变,旧版本不覆盖、不修改。租户数据库通过 `meta_runtime_state.current_release_id` 指向当前版本,普通页面、通用数据运行时、权限和 AI 工具始终读取它。 - -```text -meta_release -release_id -release_no 当前租户内递增编号 -change_set_id -checksum -created_at -created_by - -meta_runtime_state -current_release_id - -meta_release_activation -activation_id -release_id -previous_release_id -reason PUBLISH 或 ROLLBACK -activated_at -activated_by -``` - -界面显示的 `CANDIDATE`、`CURRENT`、`HISTORY` 和 `FAILED` 状态由校验结果、当前指针和激活记录得出,不通过修改历史版本内容实现。 - -配置界面提供两种保存方式: - -```text -保存并生效 --> 创建新版本 --> 校验 --> 激活为 CURRENT - -保存候选版本 --> 创建 CANDIDATE --> 预览或测试 --> 确认后激活为 CURRENT -``` - -不需要再额外定义“测试版本”和“正式版本”两套类型。同一个候选版本验证通过后直接激活;被替换的版本自然成为历史版本。运行时绝不能读取正在编辑、尚未验证或执行失败的版本。 - -元数据可以采用“资源修订 + 发布快照”存储:每个资源修订不可变,`meta_release_resource` 记录某个 release 使用哪些实体、字段、页面、动作和权限修订。发布时只原子切换 `current_release_id`,避免复制全部元数据,也避免草稿修改污染当前配置。 - -一个 release 表示当前租户整套配置的一致快照,不只是某一个模块。一次 change_set 可以只修改海运模块,但新 release 会继续引用其他模块原来的资源修订,从而避免模块之间出现半新半旧的组合。 - -### 11.2 Schema 变更执行状态 - -普通名称、布局、菜单和多语言变更只涉及元数据,可以快速创建并激活版本。新增表、字段或修改类型时,需要先完成 Schema 变更: - -```text -VALIDATING --> PREPARING --> APPLYING --> VERIFYING --> SUCCEEDED - -任意阶段失败 -> FAILED -> 修复后 RESUMING -``` - -执行机制必须满足: - -- 当前租户数据库同一时间只能有一个结构变更任务,锁记录直接保存在该数据库中;其他租户不受影响。 -- 每一个迁移步骤记录开始时间、结束时间、checksum 和执行结果。 -- 重启或网络中断后可以从最后一个已确认步骤继续执行。 -- DDL、数据转换和结构校验全部成功后,才能把对应配置版本设为 `CURRENT`。 -- DDL 成功但配置激活失败时保持原当前版本,并进入可修复状态。 -- 激活后只失效当前 `orgId` 的元数据、实体注册和权限缓存。多个后端节点如果都可能服务该 orgId,应加载同一个 `current_release_id`。 -- 完成后重新执行数据库结构对账,确认物理结构可以支持当前配置。 - -### 11.3 破坏性变更 - -删除字段、缩短长度、改变类型和修改唯一约束不能直接一步完成。推荐采用 expand-contract: - -```text -新增兼容结构 --> 双写或分批迁移数据 --> 校验新旧数据 --> 激活对应配置 release --> 停止写入旧结构 --> 经过保留期后归档或删除旧结构 -``` - -类型变更发布前必须扫描不能转换的数据并生成错误清单。大表 DDL 需要评估锁表时间,并支持后台迁移、进度查询、暂停和取消。 - -### 11.4 资源依赖图 - -平台需要维护稳定 key 之间的依赖关系: - -```text -字段 -<- 列表、表单和查询 -<- Select 和实体关系 -<- 注册 SQL 和报表 -<- 权限和业务审计 -<- 特殊 Vue 页面 -<- Java Handler -<- AI 工具和文档抽取模板 -``` - -特殊页面、Handler、注册查询和报表在 manifest 中声明所需字段、动作和最低平台 API 兼容要求。核心资源可以标记: - -```text -protected -requiredByCode -deprecatedSince -removableAfterVersion -``` - -禁用、改名、改类型或删除资源之前必须执行依赖分析。存在不兼容依赖时停止发布并生成处理清单,不能仅依靠运行时报错发现问题。 - -## 12. 售后技术人员的配置流程 - -### 12.1 新增字段 - -```text -选择模块和实体 --> 填写字段编码、名称、类型和引用 --> 系统生成结构差异 --> 自动生成默认列表、表单和查询配置 --> 预览变更 --> 发布 -``` - -### 12.2 修改名称 - -仅修改字段 `label` 或多语言资源,不修改物理字段名,不产生数据库迁移。 - -### 12.3 禁用字段 - -设置 `enabled = false`,通用页面和普通数据接口默认忽略该字段,数据库中的历史数据继续保留。 - -### 12.4 新增实体和表 - -技术人员通过实体设计器创建实体并定义字段,系统自动完成: - -1. 创建物理表和标准技术字段。 -2. 创建索引和引用关系。 -3. 生成默认列表页。 -4. 生成默认编辑表单。 -5. 生成默认查询条件。 -6. 注册菜单和基础操作。 -7. 生成模块变更集。 - -### 12.5 修改字段类型与删除字段 - -使用平台已经支持的字段类型时,新增字段、调整长度或精度、改变字段类型、停用字段和删除字段都不需要开发新代码,也不需要部署或重启后端,但其中一部分需要由 Schema Compiler 执行数据库迁移。 - -应区分三类操作: - -| 操作 | 是否需要 DDL | 是否需要部署或重启 | -| --- | --- | --- | -| 修改显示名称、多语言、页面布局 | 否 | 否 | -| 新增字段、调整已有类型、改变长度或精度 | 通常需要 | 否 | -| 停用字段 | 否,默认保留物理列 | 否 | -| 最终物理删除字段 | 需要,并经过依赖分析和保留期 | 否 | -| 引入平台从未支持的底层类型或映射能力 | 需要 | 需要发布平台代码 | - -例如把已有的 `varchar(50)` 扩大到 `varchar(200)`,或把平台已支持的整数字段迁移为 decimal,属于配置发布和数据库迁移,不属于增加代码能力。变更前仍需完成数据可转换检查、依赖分析、备份检查和影响预览。缩短长度、改变精度或物理删除字段应采用第 11.3 节的渐进式迁移,不能因为“不需要重启”就直接执行危险 DDL。 - -### 12.6 拖拽式主表单与子表配置 - -普通主表单和主子表页面由配置中心通过拖拽设计器生成。设计器提供字段、关系、分组和标准控件的组件面板,技术人员可以把字段拖入表单,把组合关系拖入子表区域,并配置顺序、宽度、分组、只读、必填、默认控件和子表操作。 - -推荐的元数据关系如下: - -```text -meta_form -|- meta_form_field 主实体字段项 -|- meta_form_relation 主子关系区域 -| |- relation_key -| |- child_view_key 子表列配置 -| |- child_form_key 子表编辑配置(可选) -| |- display_mode table / form / tabs -| |- editable -| |- allow_add -| |- allow_delete -| `- allow_sort -``` - -字段、关系、顺序、可见性、宽度和控件类型等稳定属性使用结构化元数据保存;控件特有的属性和布局扩展使用经过 Schema 校验的 JSON 配置。设计器保存时生成 `change_set` 和候选 release,支持预览、校验、发布和回滚,不直接修改当前运行版本。 - -主表和子表保存使用同一个 `saveobjt` 原子事务。请求可以同时提交主实体和子实体的新增、修改、删除,运行时根据 `meta_relation` 校验所有权、外键、唯一性、删除策略、字段权限、乐观锁和业务校验。子表不是简单地把前端数组覆盖到数据库,正式业务状态下的删除、拆分、合并和跨行计算必须调用动作或特殊 Handler。 - -## 13. 通用数据运行时 - -内部 ERP 以灵活性和开发效率为优先。后端保留少量、稳定、全部使用 POST 的通用能力接口,不为海运订舱、空运单、客户等每个普通实体新增 Controller 和 URL。 - -```text -POST /data/loaddata -POST /data/page -POST /data/saveobjt -POST /data/runquery -POST /data/loaddatabysql -POST /action/execute -POST /metadata/publish -``` - -这些接口表达稳定的技术能力,而不是具体业务。新增一百个普通实体也不需要增加后端接口。 - -### 13.1 通用查询协议 - -`loaddata` 和 `page` 保留,并共用一个底层 `EntityQueryEngine`。查询目标优先使用实体 key,不强制要求先创建数据库视图: - -```json -{ - "requestId": "01J...", - "entity": "ocean.booking", - "fields": ["booking_no", "customer_id", "etd", "status"], - "filter": { - "status": { "in": ["draft", "confirmed"] }, - "etd": { "gte": "2026-08-01" } - }, - "sort": ["etd desc"], - "pageNo": 1, - "pageSize": 50, - "context": { - "locale": "zh-CN" - } -} -``` - -两者的职责如下: - -```text -loaddata -> 返回符合条件的数据,可用于小数据集和下拉引用 -page -> 返回当前页、总数、页码和分页大小 -``` - -前端、AI 和外部集成优先使用结构化条件,不拼接 SQL。物理表名、列名和数据库方言由实体元数据及查询引擎映射。 - -复杂查询分为两类: - -- `runquery`:执行已经注册并版本化的查询资源,只传 query key 和参数。 -- `loaddatabysql`:执行临时只读 SQL,供 IT、售后查询工具、迁移工具和高级报表使用。 - -注册查询示例: - -```json -{ - "query": "ocean.available-carriers", - "parameters": { - "route_id": "..." - } -} -``` - -`loaddatabysql` 可以保留,因为它对内部 ERP 的开发、售后排查和迁移非常方便,但普通页面和 AI 不应默认依赖任意 SQL。系统应记录 SQL、参数、执行人、耗时和结果数量,便于定位问题和迁移引用。 - -`loaddatabysql` 必须始终使用当前 `OrgContext` 注入的租户数据库,并使用该数据库对应的只读连接。该接口还需要配置: - -- 最大执行时间和最大返回行数 -- SQL 参数化和结果分页 -- 查询取消 -- 并发数限制 -- 禁止写操作和多语句执行 -- 完整技术审计 - -任意 SQL 无法可靠自动追加实体数据权限,因此它只能作为 IT 技术工具使用。普通业务页面、报表和 AI 应使用 `loaddata`、`page` 或声明了关联实体、输出字段和权限策略的 `runquery`。 - -### 13.2 通用保存协议 - -`saveobjt` 保留,并支持在一个事务内处理一个或多个实体的新增、修改和删除: - -```json -{ - "atomic": true, - "changes": [ - { - "entity": "ocean.booking", - "inserts": [], - "updates": [ - { - "id": "...", - "version": 3, - "values": { - "carrier_id": "...", - "etd": "2026-08-20" - } - } - ], - "deletes": [] - } - ] -} -``` - -对于主子表编辑,前端可以在一次请求中提交主实体和其组合关系下的子实体变更。通用运行时按关系元数据决定保存顺序、主键回填、行排序、删除策略和事务边界;涉及跨行汇总、状态变更、拆分合并或外部副作用时,必须改用 `action.execute` 或特殊 Handler。 - -通用运行时根据当前已发布的元数据完成: - -- 字段解析和类型转换 -- 默认值 -- 必填和格式校验 -- 字段 `writeMode` 和实体业务校验 -- 引用关系查询 -- 分页、筛选和排序 -- 列表和表单数据装配 -- 自动编号 -- 权限和数据范围合并 -- 多语言元数据解析 -- 乐观锁和事务 -- 操作与字段变更记录 -- 批量新增、修改和按 `deletePolicy` 删除 - -`saveobjt` 不允许直接写入 `actionOnly`、`computed` 和 `system` 字段。业务状态变化必须通过动作引擎执行,从而统一应用前置条件、权限、审计和事件。 - -`saveobjt` 收到删除请求时必须读取实体删除策略并检查全部引用。`lifecycle` 实体只有满足“草稿、无下游引用”等配置条件时才允许删除;否则要求调用取消、作废或合并等业务动作。数据库外键冲突需要转换为客户能够理解的引用清单,不能直接返回 SQL 异常。 - -### 13.3 请求幂等与安全重试 - -网络超时并不代表服务器没有执行成功。所有可能产生副作用的请求,包括 `saveobjt`、`action.execute`、导入、外部回调和特殊接口,都应支持 `idempotencyKey`: - -```json -{ - "requestId": "01J...", - "idempotencyKey": "ocean.booking.confirm:123:version-3", - "action": "ocean.booking.confirm", - "payload": { - "bookingId": "123" - } -} -``` - -平台以以下组合作为唯一键: - -```text -operation/action_key + idempotency_key -``` - -租户数据库本身已经是隔离边界,因此幂等表不需要再把租户标识放入唯一键。第一次请求在当前数据库中保存请求摘要、执行状态和结果。相同 key、相同参数再次提交时返回第一次结果;相同 key、不同参数时返回幂等冲突。业务数据、幂等记录、业务审计和 Outbox 事件必须在同一个租户数据库事务中提交;外部调用通过提交后的 Outbox 消费执行,技术日志由日志框架独立输出。 - -普通新增还应支持客户端预生成稳定 ID 或业务唯一键,并由数据库唯一约束作为最后一道重复保护。乐观锁解决并发修改,幂等 key 解决重复执行,两者不能互相替代。 - -### 13.4 对外简单,对内分层 - -通用 POST 接口数量少,不代表所有逻辑堆在一个数据服务中。建议内部按能力分层: - -```text -DataController -|- LoadDataService -|- PageDataService -|- SaveObjectService -|- RegisteredQueryService -|- RawSqlQueryService - -ActionController -|- ActionDispatcher -|- ConfiguredActionHandler -|- CustomActionHandler - -MetadataController -|- MetadataPublishService -``` - -这样对外接口始终稳定,对内每种技术能力仍然可以独立测试、维护和扩展。 - -### 13.5 热发布与重启边界 - -下列变更通过配置 release、Schema 迁移、发布事件和缓存失效,在激活完成后生效;它们不需要新增接口,也不需要重启后端: - -- 新增实体和物理表 -- 新增、改名、禁用和删除字段 -- 在平台已有类型范围内修改字段类型、长度和精度 -- 新增字典和 Select 引用 -- 调整列表、表单和查询 -- 新增普通 CRUD 页面 -- 新增配置型校验、公式和动作 -- 修改菜单、权限和多语言资源 - -激活后,运行时只为当前 `orgId` 加载新的 `current_release_id`,更新对应的实体注册表、字段解析器和权限快照,其他租户缓存不发生变化。 - -真正增加以下代码能力时仍然需要部署新版本,除非未来专门建设动态插件或脚本运行环境: - -- 新的 Java Handler -- 平台从未支持的底层 SQL 类型、数据库方言、自定义序列化方式或查询运算符 -- 新的复杂算法 -- 新的外部系统协议 -- 新的专用 Vue 组件或复杂页面 - -为了减少部署,可以为 L3 能力提供受限制的规则 DSL、公式引擎、流程设计器或脚本沙箱,但不应为了完全避免重启而把所有复杂业务逻辑变成任意动态脚本。 - -### 13.6 通用响应 - -所有通用接口和特殊接口使用统一响应格式: - -```json -{ - "requestId": "01J...", - "success": true, - "code": "OK", - "message": "", - "data": {}, - "meta": { - "configRelease": 42 - } -} -``` - -错误码、校验错误、权限拒绝、乐观锁冲突和配置 release 不一致都应结构化返回,避免前端依赖后端异常字符串。 - -## 14. 页面运行时 - -页面运行时根据实体、列表和表单元数据生成通用页面。 - -适合配置生成的页面包括: - -- 基础资料维护 -- 客户和联系人 -- 字典和分类 -- 普通业务数据列表 -- 简单主表编辑 -- 主表编辑表单和一对多子表编辑表格 -- 通用查询和导出 - -复杂页面和复杂交互仍由 IT 开发人员编写 Vue 组件或 Handler,例如: - -- 海运工作台 -- 订舱操作台 -- 多箱、多提单联动编辑 -- 费用结算 -- 拖拽式单证编排 -- 多层嵌套、跨行公式和复杂状态联动 - -复杂页面仍然使用相同的实体、字段、引用、动作和通用数据能力,不应建立另一套数据定义。 - -### 14.1 通用页面与特殊页面双轨运行 - -页面开发分为两条路径: - -```text -普通页面 --> 售后配置实体、列表和表单 --> 页面运行时动态生成 --> 配置发布后即时生效 - -特殊页面 --> IT 开发 Vue 组件 --> 使用通用数据接口和特殊业务接口 --> 通过模块 manifest 注册 --> 随前端版本部署 -``` - -特殊页面适合海运工作台、多箱多提单联动、费用结算、拖拽编排和高交互批量操作。IT 开发人员可以继续复用同一套实体、关系、通用保存、动作和权限能力;不能为了追求完全配置化而降低开发人员实现复杂页面和复杂业务逻辑的效率。 - -### 14.2 特殊页面注册 - -特殊页面仍然注册到模块元数据中: - -```yaml -pages: - - key: ocean.booking.workbench - nameKey: page.ocean.booking.workbench - component: OceanBookingWorkbench - route: /ocean/booking-workbench - permission: ocean.booking.workbench - entities: - - ocean.booking - - ocean.container - actions: - - ocean.booking.split - - ocean.booking.confirm -``` - -这样特殊页面与通用页面共享菜单、路由、权限、多语言、审计、动作和 AI 语义,不形成第二套孤立体系。 - -## 15. 业务动作与代码扩展 - -通用 CRUD 无法表达确认订舱、审核费用、作废提单等业务行为,因此动作必须是一等资源。 - -```yaml -key: ocean.booking.confirm -name: 确认订舱 -entity: ocean.booking -inputSchema: - bookingId: id - confirmedAt: datetime -preconditions: - - status == draft -result: - status: confirmed -handler: OceanBookingConfirmHandler -``` - -系统提供标准扩展点: - -```text -beforeCreate -afterCreate -beforeUpdate -afterUpdate -beforeDelete -afterDelete -validate -calculate -beforeAction -afterAction -``` - -简单动作可以通过规则配置,复杂动作由开发人员实现 Handler。开发人员提供能力,售后技术人员组合和配置能力。 - -### 15.1 特殊接口 - -通用接口负责普通查询和保存,以下情况允许 IT 开发特殊 POST 接口: - -- 跨多个实体的复杂事务 -- 拆单、并单、冲销和复杂费用计算 -- 长时间运行的批处理任务 -- 特殊报表和性能优化查询 -- 文件生成、打印和流式处理 -- 船公司、海关、财务等外部系统集成 - -特殊接口可以采用明确的业务路径: - -```text -POST /ocean/booking/split -POST /ocean/shipment/merge -POST /finance/settlement/recalculate -POST /integration/carrier/sync -``` - -也可以注册到统一动作入口: - -```json -{ - "action": "ocean.booking.split", - "payload": { - "bookingId": "...", - "items": [] - } -} -``` - -特殊接口必须注册 action key、输入输出 Schema、权限、业务说明、多语言名称和审计策略。这样前端、AI 和运维工具都能理解并调用同一能力。 - -### 15.2 开发扩展 SDK - -平台应为特殊页面和特殊接口提供统一 SDK,避免开发人员每次重复处理基础能力: - -```text -EntityService -QueryService -SaveService -ActionService -PermissionService -I18nService -FileService -AuditService -EventService -``` - -特殊 Handler 只处理真正的业务逻辑: - -```java -@FmsAction("ocean.booking.split") -public class SplitBookingHandler { - public ActionResult execute(SplitBookingCommand command) { - // 只实现拆单规则,事务、权限、审计和事件由平台统一处理。 - } -} -``` - -平台统一完成参数解析、事务、权限和数据范围、多语言错误、审计、事件、统一响应,以及 AI 工具描述注册。 - -### 15.3 部署边界 - -配置型实体、字段、页面、规则和动作可以热发布。新增 Vue 特殊页面、Java Handler、特殊接口或外部系统协议属于新增代码,需要进行前端或后端部署。 - -可以通过滚动发布降低停机影响。初期不建议为了避免后端重启而建设复杂的 Java 动态插件 ClassLoader;只有在模块需要独立交付和频繁热插拔时再评估插件化。 - -## 16. 统一权限中心 - -权限是通用运行时的横切能力,不能由菜单、页面、后端接口和 AI 分别维护。新系统采用以下组合模型: - -```text -统一权限中心 -= RBAC 功能授权 -+ ABAC 数据策略 -+ 字段级访问策略 -+ 配置管理权限 -+ AI 工具权限 -``` - -即:角色负责授予用户可以使用哪些功能,数据策略决定用户可以操作哪些记录,字段级访问策略决定用户可以查看、导出或脱敏哪些字段,配置权限决定谁能修改实体、页面、权限和 Schema。首期只做基于用户、角色或用户组的静态字段策略,不引入复杂的条件脱敏规则。 - -### 16.1 权限主体 - -权限主体应支持: - -```text -用户 -角色 -部门 -岗位 -用户组 -``` - -用户可以通过多个角色、部门和岗位关系获得权限。权限规则应使用稳定业务 key 表达,不能依赖不同环境中不稳定的数据库内部 ID。 - -### 16.2 权限资源 - -权限资源与前述元数据模型直接关联: - -| 资源 | 示例 | -| --- | --- | -| 模块 | `ocean` | -| 页面/视图 | `ocean.booking.default-list` | -| 实体 | `ocean.booking` | -| 动作 | `ocean.booking.confirm` | -| 字段 | `ocean.booking.cost_price` | -| 配置能力 | `platform.metadata.publish` | -| AI 工具 | `data.query`、`action.execute` | - -### 16.3 页面与菜单权限 - -页面访问权限控制用户能否进入页面。菜单显示应尽量从页面访问权限推导,而不是再维护一套容易冲突的菜单授权: - -- 用户拥有页面访问权限时,该页面菜单才可以显示。 -- 父菜单下至少有一个可访问子页面时,父菜单才显示。 -- 页面可以单独配置 `visible = false`,表示允许通过链接访问但不显示在菜单中。 -- 菜单只是页面和模块的导航表现,不是最终权限判断点。 - -菜单资源本身保存父子关系、顺序、图标、页面绑定和可见性;页面资源保存 route 和页面访问权限。权限中心返回可访问页面后,菜单运行时按当前 release 构建树并过滤无权页面,目录节点在没有可见子节点时自动隐藏。这样新增普通页面和菜单只需要提交配置变更集,不需要修改前端路由代码;特殊页面仍需先部署组件,再发布对应页面和菜单元数据。 - -建议的页面权限 key: - -```text -ocean.booking.view -ocean.shipment.view -finance.invoice.view -``` - -### 16.4 按钮与动作权限 - -按钮权限不单独创造一套按钮编码,而是直接绑定 `meta_action`: - -```text -ocean.booking.create -ocean.booking.update -ocean.booking.delete -ocean.booking.confirm -ocean.booking.cancel -ocean.booking.export -``` - -前端根据动作权限显示或禁用按钮,通用数据运行时和动作引擎必须再次检查同一个 action key。 - -权限与业务前置条件必须分开: - -```text -权限:用户是否拥有 ocean.booking.confirm -前置条件:当前订舱是否处于 draft 状态 -``` - -用户拥有权限但业务状态不满足时,动作仍然不能执行。 - -### 16.5 数据权限 - -数据权限决定用户可以访问实体中的哪些记录。系统应内置常用数据范围: - -```text -全部数据 -当前部门 -当前部门及下级部门 -本人创建 -本人负责 -指定业务团队 -指定客户范围 -自定义条件 -``` - -数据策略使用结构化表达式,不直接保存散落的 SQL: - -```yaml -key: ocean.booking.sales-scope -entity: ocean.booking -actions: - - view - - update -scope: - any: - - owner_id == currentUser.id - - sales_department_id in currentUser.departmentTree - - customer_id in currentUser.authorizedCustomers -``` - -权限中心将数据策略编译为通用查询运行时可以理解的过滤树。实际查询等价于: - -```text -用户提交的查询条件 -AND -系统生成的数据权限条件 -``` - -同一数据权限必须应用于: - -- 列表和详情查询 -- 新增、修改和删除 -- 批量操作 -- 导入和导出 -- 报表和统计 -- 后台任务 -- AI 数据查询和动作执行 - -数据权限不能只在前端列表中处理,也不能只限制查询而放过更新和删除。 - -未来启用总分公司后,再增加独立的业务组织范围,例如当前分公司、分公司及下级、指定分公司和全部分公司。默认策略应把查看范围和修改范围分开:总公司可以查看汇总,但不自动获得修改分公司数据的权限。该扩展使用 `business_org_id`,不能与用于选择租户数据库的 `orgId/OrgContext` 混为一谈。 - -### 16.6 字段显示与写入边界 - -字段的页面展示、后端写入和权限可见性是三层不同概念。首期支持按用户、角色或用户组配置字段查看和导出权限,并预留基础脱敏方式;按记录条件、表达式或不同场景动态脱敏暂不纳入首期。 - -```text -页面显示或只读 -> meta_view_field / meta_form_field -字段查看、导出和脱敏 -> auth_field_policy / 统一权限中心 -后端允许普通写入 -> meta_field.writeMode -状态变化和受控字段 -> action.execute -``` - -字段策略至少支持以下维度: - -```text -view allow / deny -export allow / deny -mask none / partial / full -subject 用户、角色或用户组 -``` - -多个策略同时存在时,拒绝优先于允许;没有配置字段策略的普通字段按实体默认规则处理,敏感字段必须显式配置。字段权限必须由后端统一执行:列表、详情、通用查询、导入导出、报表、后台任务、特殊 Handler 和 AI 工具都必须经过同一个字段权限服务,不能只在前端隐藏列。查询引擎应在生成 SQL 投影或响应序列化阶段裁剪无权字段,导出和 AI 返回也不得通过别的接口绕过策略。 - -页面配置只是交互表现,不能把 `actionOnly`、`computed` 或 `system` 字段变成可写。字段可见性、导出权限和 `writeMode` 彼此独立;例如用户可以查看成本但不能导出,也可以导出脱敏后的值但不能修改。复杂的按数据条件脱敏、字段级表达式策略和细粒度临时授权留待后续扩展。 - -### 16.7 配置管理权限 - -售后配置能力需要与普通业务权限分离。建议至少包含: - -```text -platform.metadata.view -platform.metadata.edit -platform.entity.create -platform.field.create -platform.schema.preview -platform.schema.publish -platform.permission.edit -platform.permission.publish -platform.ai.configure -``` - -例如,某个售后人员可以修改字段显示名称和布局,但不一定拥有发布数据库结构变更或权限策略的能力。 - -配置中心和 AI 配置助手生成的变更集,在预览、发布时都需要检查对应的配置管理权限。 - -### 16.8 权限合并规则 - -多个角色和策略叠加时必须采用固定、可解释的规则: - -- 多个角色的功能权限取并集。 -- 显式禁止优先于普通允许。 -- 数据范围在系统强制边界内取允许范围并集。 -- 用户级特殊策略优先于角色普通策略。 -- 系统保留字段和强制数据边界不能被普通配置覆盖。 - -权限中心应提供“权限解释”能力,能够说明某个用户为什么可以或不可以访问某个页面、动作或数据范围。 - -### 16.9 权限快照与缓存 - -权限中心可以为用户生成已编译的权限快照: - -```json -{ - "actions": [ - "ocean.booking.view", - "ocean.booking.confirm" - ], - "dataPolicies": { - "ocean.booking": ["department-tree", "owner"] - } -} -``` - -前端使用快照控制菜单和按钮,后端运行时使用已编译策略控制数据范围。权限发生变化时发布权限失效事件,清除相关用户、角色和部门的缓存。 - -### 16.10 权限元数据 - -建议建立以下权限资源: - -| 权限资源 | 职责 | -| --- | --- | -| `auth_role` | 角色定义 | -| `auth_user_role` | 用户角色关系 | -| `auth_permission` | 页面、实体和动作权限定义 | -| `auth_role_permission` | 角色功能授权 | -| `auth_data_policy` | 数据范围策略 | -| `auth_role_data_policy` | 角色数据策略关系 | -| `auth_field_policy` | 字段查看、导出和脱敏策略 | -| `auth_permission_snapshot` | 可选的编译缓存 | - -权限定义、角色模板、数据策略和字段策略应包含在模块 bundle 中,通过稳定 key 跨环境迁移。具体用户与角色的分配属于环境或租户数据,不应随模块定义覆盖。 - -### 16.11 AI 权限继承 - -AI Gateway 必须继承发起用户的身份和权限上下文: - -```text -用户发起 AI 请求 --> AI Gateway 获取用户权限快照 --> 根据动作权限生成可用工具 --> 查询工具自动追加数据权限和字段访问策略 --> 动作工具再次检查动作权限和业务前置条件 -``` - -用户没有 `finance.invoice.approve` 时,AI 不应获得对应动作工具;用户只能查看自己负责的订舱时,AI 查询也只能得到相同范围的数据。 - -AI 可以帮助生成角色和数据范围候选配置,但权限生效仍通过配置 release、校验和确认流程完成。 - -### 16.12 权限审计 - -权限中心应记录: - -- 角色和策略的创建、修改和发布 -- 用户角色变更 -- 权限拒绝及其原因 -- 高影响动作的执行人和来源 -- AI 代表用户执行的工具和动作 -- 数据导出、批量修改和配置发布 - -审计记录需要保留用户、角色、权限快照版本、动作参数、结果和关联变更集,确保问题可以回溯。 - -## 17. 配置版本、生效与回滚 - -每次配置保存都生成不可变 release 和 `change_set`。默认操作是“保存并生效”,因此日常修改名称、布局、Select 和普通规则不需要额外发布步骤;需要先给用户验证时,选择“保存候选版本”。 - -```text -编辑配置 --> 生成 change_set 和候选 release --> 校验与结构差异分析 --> 可选预览或用户测试 --> 激活 release --> 原子切换 current_release_id --> 清除当前 orgId 的运行时缓存 -``` - -`change_set` 至少包含: - -- 变更资源和稳定 key -- 修改前后内容 -- 生成的 DDL 和数据转换计划 -- 操作人、时间和原因 -- 校验及依赖分析结果 -- 执行状态和结果 -- 变更 checksum - -`POST /metadata/publish` 继续作为唯一配置写入口,通过 `operation` 支持四种稳定操作: - -```text -SAVE_AND_ACTIVATE 保存新版本并立即生效,作为默认操作 -SAVE_CANDIDATE 保存候选版本但不影响普通用户 -ACTIVATE 激活已经验证的候选版本 -ROLLBACK 切换到指定历史版本 -``` - -保存请求必须携带 `baseReleaseId`。如果当前版本已经被其他技术人员修改,平台返回配置并发冲突并展示差异,不能静默覆盖别人的配置。 - -配置版本以当前 `OrgContext` 注入的数据库为边界。release 编号只需在当前租户库内递增;`orgId` 写入集中技术日志,便于售后定位请求。一个租户激活或回滚配置不会改变其他租户的 Schema、配置或缓存。 - -### 17.1 预览与用户测试 - -普通页面布局、名称、Select 和规则可以通过 `preview_release_id` 让指定用户加载候选版本,普通用户仍使用当前版本。候选预览默认只允许查询和页面验证,避免测试操作污染正式业务数据。 - -需要验证完整保存、动作、工作流或外部接口时,应把同一个 bundle 和 checksum 安装到独立测试数据库,让用户在测试数据上验收;通过后再把完全相同的内容导入生产租户数据库。不能把“能够回滚配置”当成直接在生产数据上随意测试的替代品。 - -### 17.2 回滚边界 - -回滚前必须重新执行依赖和 Schema 兼容检查,然后把 `current_release_id` 指向历史版本并记录新的回滚事件。不同变更的回滚能力不同: - -| 变更类型 | 回滚方式 | -| --- | --- | -| 名称、布局、菜单、Select、多语言、普通规则 | 直接切回历史 release | -| 新增表、字段或索引 | 切回旧配置,新增物理结构暂时保留 | -| 扩大字段长度 | 通常可切回旧配置,物理长度不缩回 | -| 删除字段、缩短长度、改变类型、数据转换 | 不能承诺直接回滚,需要保留旧结构、反向迁移或备份恢复 | -| 已经产生业务副作用的动作或外部调用 | 配置回滚不能撤销业务结果,必须使用业务补偿动作 | - -因此删除字段和改变类型继续采用 expand-contract,并设置旧结构保留期。系统可以提供“一键回滚版本”,但只有通过兼容检查的版本才允许直接激活,不能把回滚按钮等同于数据库和业务数据的时间倒流。 - -## 18. 模块迁移与跨环境发布 - -模块需要支持导出为可迁移 bundle: - -```text -ocean-module/ -|- manifest.yaml -|- entities.json -|- relations.json -|- views.json -|- forms.json -|- dictionaries.json -|- actions.json -|- queries.json -|- menus.json -|- permissions.json -|- reports.json -|- workflows.json -|- events.json -|- i18n.json -|- extensions.json -|- ai-tools.json -|- data-transforms/ -|- seeds/ -|- importers/ -``` - -bundle 是某次导出的当前模块配置快照,应包含 `bundle_id`、来源 release、导出时间和整体 checksum。所有资源使用稳定业务 key,不使用环境相关的数据库内部 ID。 - -同一个 bundle 可以安装到多个租户数据库,但每次安装都是一次独立的当前租户操作:注入目标 `OrgContext`,把 bundle 当前快照与目标配置做双向差异,执行备份检查、Schema 变更、元数据导入和结果校验,然后生成目标租户自己的新 release。不要求其他租户同时安装或保持相同 release 编号。 - -跨环境发布流程: - -```text -开发或测试环境配置 --> 导出模块变更集 --> checksum 和依赖校验 --> 在目标环境生成差异和影响预览 --> 应用结构迁移 --> 导入元数据和种子数据 --> 执行校验 --> 生成候选 release --> 激活或保留为候选版本 -``` - -目标租户存在定制时,不做旧版本、新版本和本地版本的三方合并。导入器只比较“bundle 当前快照”和“目标当前配置”,按资源列出新增、修改、删除和冲突。无冲突内容可以自动应用;字段类型不同、稳定 key 冲突、删除仍被依赖资源和特殊代码不兼容必须进入人工处理清单,不能静默覆盖租户定制。 - -数据库迁移应满足: - -- 每次生成的迁移计划有模块、change_set 和 checksum。 -- 已经执行成功的迁移记录不可修改。 -- 支持从空库完整初始化。 -- 根据目标快照与当前数据库状态生成差异,不依赖连续旧版本链。 -- 结构迁移和数据迁移分离。 -- CI 自动测试空库安装、典型现有结构导入和回滚兼容性。 - -## 19. 旧系统数据导入 - -旧数据导入不应混入通用运行时和模块配置逻辑。每个模块拥有独立导入器: - -```text -旧库 --> staging 原始数据 --> 数据清洗和映射 --> 领域数据导入 --> 数量与业务规则校验 --> 对账报告 -``` - -导入器应满足: - -- 可重复执行 -- 支持断点续传 -- 使用稳定业务键或外部引用去重 -- 输出无法映射和校验失败的数据 -- 保存旧系统记录与新系统记录的对应关系 - -建议提供统一外部引用表: - -```text -source_system -source_table -source_id -target_entity -target_id -``` - -## 20. AI 原生架构 - -### 20.1 AI 友好的含义 - -AI 友好不等于增加一个聊天窗口。系统必须让 AI 能够: - -- 理解模块、实体、字段、关系和业务术语。 -- 查询结构化业务数据。 -- 执行可描述的业务动作。 -- 创建配置候选版本。 -- 生成结构化变更集。 -- 解析提单、发票、合同和邮件附件。 -- 根据事件执行自动化任务。 -- 给出执行预览并接受人工确认。 - -### 20.2 AI Gateway - -建议建立独立 AI Gateway / Agent Runtime: - -```text -AI 助手 - | - v -AI Gateway -|- 上下文装配 -|- 模型路由 -|- 工具调用 -|- 任务编排 -|- 人工确认 -|- 执行记录 -|- 结果评估 - | - v -元数据工具 / 数据工具 / 动作工具 / 配置工具 / 文档工具 -``` - -业务模块不直接依赖具体模型 SDK。AI Gateway 通过统一 Provider 接口连接不同模型: - -```text -AiProvider -|- chat -|- toolCall -|- structuredOutput -|- embedding -|- documentVision -|- speech -``` - -### 20.3 AI 工具中心 - -建议提供以下标准工具: - -```text -metadata.listModules -metadata.describeModule -metadata.describeEntity -metadata.describeAction -data.query -data.get -data.create -data.update -data.batchUpdate -action.execute -report.run -document.extract -config.proposeChange -config.validateChange -config.publishChange -``` - -工具的输入和输出使用 JSON Schema 描述。同一份定义可以生成: - -- 固定通用 POST 接口与特殊动作协议 -- OpenAPI 文档 -- 前端 SDK -- AI Tool Calling 定义 -- MCP Server 接口 - -AI 不直接修改数据库和元数据表。AI 产生结构化意图或变更集,由平台校验和执行。 - -AI 工具不是另一套后端逻辑,而是现有通用能力的适配层: - -```text -data.create / data.update / data.batchUpdate --> 调用与 POST /data/saveobjt 相同的 SaveService - -action.execute --> 调用与 POST /action/execute 相同的动作引擎 - -config.proposeChange / config.publishChange --> 调用与配置中心相同的校验、Schema Compiler 和 release 机制 -``` - -因此 AI 写入同样受到 `writeMode`、数据权限、业务前置条件、乐观锁、幂等、事务和业务审计约束。确认订舱、审核费用和作废发票必须调用动作,不能用 `data.update` 直接修改状态字段。文档、邮件和用户输入只是待解析数据,不能因为其中包含类似“调用工具”或“忽略规则”的文字就绕过平台校验。 - -### 20.4 AI 配置助手 - -例如售后输入: - -> 在海运订舱增加“船公司”字段,数据来源为合作伙伴中类型为承运人的公司,并加入编辑表单、列表和查询,列表宽度 180。 - -AI 将请求转换为确定性的变更集: - -```json -{ - "changes": [ - { - "type": "addField", - "entity": "ocean.booking", - "field": { - "key": "carrier_id", - "label": "船公司", - "dataType": "reference", - "targetEntity": "base.company", - "filter": { "company_type": "carrier" } - } - }, - { - "type": "addListField", - "view": "ocean.booking.default-list", - "field": "carrier_id", - "width": 180 - }, - { - "type": "addQueryField", - "view": "ocean.booking.default-query", - "field": "carrier_id" - } - ] -} -``` - -执行流程: - -```text -AI 理解需求 --> 读取模块和实体语义 --> 生成候选变更集 --> 元数据校验 --> Schema Compiler 生成 DDL --> 显示影响范围 --> 技术人员确认 --> 执行并发布 -``` - -### 20.5 文档智能 - -货代 ERP 应重点建设文档处理能力: - -```text -文件上传 --> OCR --> 文档分类 --> 字段抽取 --> 实体匹配 --> 置信度校验 --> 人工确认 --> 写入业务实体 -``` - -适用文档包括: - -- 提单 -- 订舱确认书 -- 发票 -- 装箱单 -- 报关资料 -- 合同 -- 邮件及附件 - -每一个抽取值都应记录: - -- 来源文件和页码 -- 原始文本 -- 目标实体和字段 -- 置信度 -- 使用的模型和提示词版本 -- 人工是否修正 - -结构化 ERP 数据通过实体查询工具访问;合同、邮件和附件等非结构化内容才进入全文索引或向量检索系统。 - -### 20.6 AI 可替换和可评估 - -AI 能力应独立于具体供应商,模型、提示词和任务配置都需要版本化: - -```text -ai_provider -ai_model -ai_prompt -ai_task -ai_execution -ai_evaluation -``` - -需要持续记录和评估: - -- 工具调用成功率 -- 结构化输出合法率 -- 文档字段抽取准确率 -- 人工修改率 -- 配置建议通过率 -- 任务耗时和成本 - -## 21. 事件与自动化 - -系统应发布版本化的业务事件: - -```text -ocean.booking.created -ocean.booking.confirmed -document.uploaded -invoice.received -shipment.delayed -``` - -事件可以触发: - -- 工作流 -- 通知 -- 数据同步 -- AI 文档解析 -- AI 风险分析 -- 自动生成任务 - -建议采用事务 Outbox 保存业务事件,保证业务数据和事件的一致性。 - -### 21.1 可靠任务执行 - -Outbox 只保证事件不会随着业务事务丢失,后台消费者仍需要统一任务运行框架。长任务和异步动作返回 `job_id`,任务记录保存在当前租户数据库中: - -```text -job_id -job_type -dedup_key -status -progress -attempt_count -next_run_at -lease_owner -lease_expires_at -input -result -last_error -``` - -任务表位于独立租户库中,所以不需要重复保存租户字段。调度消息或 Worker 的执行入口需要携带 `orgId`,先恢复 `OrgContext` 并选中数据源,再读取和执行该租户库中的任务,最后清理上下文。 - -任务状态建议为: - -```text -PENDING -> RUNNING -> SUCCEEDED - |-> RETRY_WAIT -> RUNNING - |-> FAILED -> MANUAL_RETRY - |-> CANCELLED - |-> DEAD_LETTER -``` - -任务框架需要支持: - -- 消费幂等和 `dedup_key` -- 指数退避重试 -- 最大重试次数和死信 -- 带租约的任务锁,Worker 异常退出后可以接管 -- 进度、暂停、取消和人工重跑 -- 执行日志和业务审计 -- 单租户并发限制,避免一个租户任务占满全部资源 -- 失败数据清单和对账报告 - -### 21.2 外部系统集成 - -船公司、海关、财务和 EDI 集成需要统一保存外部交互记录: - -```text -integration_message -external_reference -webhook_delivery -reconciliation_result -``` - -出站消息使用 Outbox 和幂等 key,入站回调使用外部消息 ID 去重。每个集成定义超时、重试、限流、错误映射、人工重发和对账规则。不能仅依靠技术日志判断某条业务数据是否已经被外部系统接受。 - -### 21.3 工作流版本 - -工作流定义必须版本化。流程实例启动时固定使用一个流程版本,后续发布的新版本默认只影响新实例。需要把运行中的实例迁移到新版本时,必须提供节点映射、条件校验和人工确认,不能自动替换。 - -工作流至少需要处理人工任务、定时器、超时、撤回、退回、取消、补偿和重新打开,并通过业务动作调用实体和 Handler,而不是直接绕过动作修改状态字段。 - -### 21.4 报表与大查询隔离 - -普通列表使用租户业务库即可。大报表、复杂统计、批量导出和 AI 分析需要设置异步阈值、最大扫描量和超时;数据量增长后应支持租户只读副本、报表读模型或独立分析库,避免复杂查询阻塞在线保存事务。 - -报表指标应使用稳定 metric key、统计口径、时间范围、币种和权限策略定义。注册 SQL 只是技术实现,不能代替业务指标定义。 - -## 22. 双层日志与审计 - -系统需要严格区分两类日志: - -```text -技术可观测日志 --> 给开发、运维和售后排查问题 - -业务操作日志 --> 给客户、业务人员和管理人员查看业务发生了什么 -``` - -两类日志来源于同一次请求,可以通过 `request_id` 和 `trace_id` 关联,但不能混在同一张表或保存成同一种文本。 - -| 对比项 | 技术日志 | 业务操作日志 | -| --- | --- | --- | -| 使用者 | 开发、运维、售后 | 客户、业务人员、管理人员 | -| 目标 | 排查错误、性能和调用链 | 了解业务变化和责任追踪 | -| 内容 | SQL、异常、耗时、节点和版本 | 谁在什么时候对什么做了什么 | -| 表达 | 技术语言 | 可多语言渲染的业务语言 | -| 存储 | 日志平台或日志文件 | 业务审计数据库 | -| 保留时间 | 按级别和容量设置 | 通常长期保留 | -| 修改方式 | 按日志平台策略归档和清理 | 只追加,原则上不修改 | - -### 22.1 统一追踪上下文 - -每个页面请求、通用 POST 接口、特殊接口、后台任务、导入任务和 AI 工具调用都生成统一上下文: - -```text -request_id -trace_id -org_id -user_id -source -module_key -entity_key -action_key -application_version -``` - -`source` 至少支持: - -```text -WEB -AI -IMPORT -SCHEDULED_JOB -EXTERNAL_API -SYSTEM -``` - -业务人员在页面上看到一条失败或成功记录时,售后可以使用 `trace_id` 找到对应的请求、SQL、外部接口和异常信息。 - -### 22.2 技术可观测日志 - -技术日志用于定位系统问题,主要包括: - -- HTTP 和通用 POST 请求日志 -- SQL、慢查询和数据库连接日志 -- 系统异常和异常堆栈 -- 外部接口请求、响应和重试日志 -- 定时任务和批处理日志 -- 文件上传、下载和转换日志 -- Schema Compiler 和配置发布日志 -- 缓存命中、缓存失效和配置 release 日志 -- AI 模型、提示词和工具调用日志 -- 性能指标、服务器节点和应用版本 - -建议技术日志统一输出结构化 JSON,并进入 Loki、OpenSearch、Elasticsearch 或类似日志平台,不要把所有技术日志长期写入业务数据库。 - -技术日志至少包含: - -```text -timestamp -level -request_id -trace_id -org_id -user_id -source -module -operation -entity -action -duration -result -error_code -exception -application_version -server_node -``` - -日志级别、保留时间和采样规则应当可配置。错误、发布、外部接口失败和高影响动作应完整保留,普通成功查询可以缩短保留时间或采样。 - -### 22.3 业务操作日志 - -业务日志是不可变的结构化事件,不能只保存一句已经渲染好的中文。建议的数据结构: - -```json -{ - "event": "ocean.booking.confirmed", - "entity": "ocean.booking", - "entityId": "123", - "businessNo": "BK20260817001", - "operatorId": "10023", - "operatorName": "张三", - "source": "WEB", - "occurredAt": "2026-08-17T14:32:00+08:00", - "changes": [ - { - "field": "status", - "before": "draft", - "after": "confirmed" - } - ], - "traceId": "01J...", - "visibility": "customer" -} -``` - -业务日志至少记录: - -- 稳定事件 key -- 模块、实体、记录 ID 和业务编号 -- 操作人、组织和来源 -- 动作和状态变化 -- 重要字段修改前后的值 -- 发生时间 -- 关联文件、任务或外部业务号 -- `request_id` 和 `trace_id` -- 可见范围 -- 配置 `release_id` - -推荐使用以下业务审计表: - -```text -audit_business_event -audit_field_change -``` - -业务事件只追加不覆盖。订正错误时产生新的订正事件,而不是修改历史记录。 - -### 22.4 客户可见时间线 - -业务日志根据稳定事件 key 和当前语言渲染。例如同一事件: - -```text -中文:张三确认了订舱 BK20260817001 -英文:Zhang San confirmed booking BK20260817001 -``` - -翻译 key 可以采用: - -```text -audit.ocean.booking.confirmed -audit.ocean.booking.field_changed -audit.finance.invoice.approved -``` - -为了保证字段改名后历史日志仍可理解,应同时保存稳定 field key、原始值以及必要的字段名称快照。显示时优先使用当前多语言资源,资源不存在时回退到快照。 - -业务日志需要配置可见范围: - -```text -customer 客户可见 -business 内部业务人员可见 -internal 仅内部管理或售后可见 -technical 仅技术日志系统可见 -``` - -例如: - -- “订舱已确认”可以对客户可见。 -- “客户要求修改 ETD”可以仅业务人员可见。 -- “船公司接口自动重试 3 次”属于内部信息。 -- SQL 和异常堆栈只进入技术日志。 - -客户可见时间线只展示有业务含义的动作、状态和重要字段变化,不能把每一次查询、缓存刷新和技术字段修改都显示出来。 - -### 22.5 字段变更审计 - -`saveobjt` 在运行时读取修改前后的数据,自动生成重要字段变更: - -```text -ETD:2026-08-20 -> 2026-08-22 -船公司:COSCO -> EMC -件数:100 -> 120 -``` - -字段元数据增加审计配置: - -```yaml -key: etd -audit: true -auditVisibility: business -auditLabelKey: field.ocean.booking.etd -``` - -以下技术字段默认不进入客户时间线: - -```text -updated_at -updated_by -version -cache_key -internal_status -``` - -对于引用字段,日志应保存引用 ID,并保存当时的显示值快照,避免关联对象改名后无法理解历史变更。 - -### 22.6 平台自动接入日志 - -日志不应依靠每个页面和 Handler 手工拼接。平台在以下位置统一生成: - -```text -loaddata/page -> 技术查询日志和性能指标 -saveobjt -> 技术执行日志、字段变更和业务审计 -action.execute -> 技术执行日志和业务动作事件 -特殊接口/Handler -> 通过扩展 SDK 自动接入两类日志 -Schema Compiler -> 技术日志和配置发布审计 -权限中心 -> 权限拒绝和高影响授权变更审计 -AI Gateway -> AI 执行日志和业务动作审计 -``` - -特殊 Handler 可以声明业务审计事件: - -```java -@FmsAction("ocean.booking.split") -@BusinessAudit("audit.ocean.booking.split") -public class SplitBookingHandler { - // 只实现业务逻辑,日志上下文和审计事件由平台处理。 -} -``` - -### 22.7 AI 双层日志 - -AI 同样需要生成两类日志。 - -技术日志记录: - -- 模型和模型版本 -- 提示词版本 -- 工具调用和参数 -- 结构化输出 -- Token 数量、耗时和成本 -- 重试、错误和评估结果 - -客户或业务人员看到的日志应表达为业务行为: - -```text -张三通过 AI 助手从订舱确认书中提取了船名、航次和 ETD。 -张三确认并保存了 AI 提取结果。 -``` - -AI 修改业务数据时,业务日志仍归属真实用户,并额外记录: - -```text -source = AI -ai_execution_id -confirmed_by -``` - -AI 生成但未被确认、未写入业务数据的建议只进入 AI 执行日志,不应伪装成已经发生的业务事件。 - -### 22.8 日志与事件的关系 - -业务事件、业务审计和技术日志含义不同: - -- 业务事件用于驱动工作流、通知和集成。 -- 业务审计用于展示历史和责任追踪。 -- 技术日志用于排查系统运行问题。 - -一次业务动作可以同时产生三者,并通过相同的 `trace_id` 和 `business_event_id` 关联。事务内先保存业务数据、业务审计和 Outbox 事件,技术日志由日志框架输出。 - -## 23. ERP 基础数据规则 - -元数据和通用接口解决的是“如何存取”,但 ERP 还必须统一定义“数据代表什么”。时间、金额、编号和文件如果由各模块自行处理,即使接口统一,最终仍会产生无法对账的数据。 - -### 23.1 日期、时间与时区 - -时间至少分为两种语义: - -| 类型 | 示例 | 存储规则 | -| --- | --- | --- | -| 时间点 | 创建时间、确认时间、航班实际起飞时间 | 使用 UTC 时间点存储,显示时按用户时区转换 | -| 业务本地日期 | 开票日期、账期、ETD 日期 | 使用 `date` 保存,不做时区换算 | - -字段元数据需要明确 `temporalType`、精度和业务时区来源。不能仅用一个没有时区语义的字符串或 datetime 表达所有时间。当前阶段区分租户默认时区和用户显示时区;跨时区船期、航班和操作节点还应保存原始地点时区,避免只保存转换后的显示值。未来启用总分公司后再增加业务组织时区。 - -### 23.2 金额、币种与汇率 - -金额和数量使用确定精度的 decimal,不能使用浮点数。字段或金额对象必须明确: - -```text -原币金额 -币种代码 -汇率 -汇率日期和来源 -本位币金额 -舍入规则 -``` - -每个租户需要配置本位币。汇率在业务确认、结算或记账时形成快照,历史单据不能因为汇率表后来更新而自动改变。税额、费用合计、毛利和分摊结果应由统一金额计算组件处理,并记录计算规则版本,避免前端、报表和后端各自舍入后无法对账。未来存在多个独立核算法人时,再允许按业务组织配置本位币。 - -### 23.3 业务编号 - -内部主键和客户可见业务编号必须分离。编号规则应作为版本化元数据管理,支持前缀、日期、组织、业务类型和流水位数等组成部分。 - -唯一范围需要明确,例如: - -```text -entity + number_rule + period -``` - -租户数据库已经提供最外层唯一范围,因此编号表和唯一约束不需要再包含 tenant 字段。未来启用总分公司且各分公司独立编号时,再把 `business_org_id` 加入唯一范围。 - -编号分配应使用数据库序列、号段或带锁的编号服务,不能通过查询 `max(number) + 1` 生成。已经正式签发的发票号、提单号等不能被修改或重复使用;作废时保留原编号和作废记录。是否允许流水号有空洞由具体编号规则决定,不能假设所有业务编号都必须连续。 - -### 23.4 文件与附件 - -提单、发票、合同、邮件附件和打印件不应直接作为大字段散落在业务表中。推荐由文件服务统一保存对象内容,租户数据库保存文件元数据和业务关联: - -```text -file_id -storage_key -original_name -content_type -size -checksum -version -scan_status -created_by -created_at -``` - -文件元数据保存在当前租户数据库中,不需要 tenant 字段;对象存储的 bucket 或路径前缀使用当前 `orgId` 隔离,这也与现有 `FileService` 的处理方式一致。文件服务还需要支持访问权限、版本、校验和、病毒扫描、上传失败清理、生命周期和保留策略。AI 文档解析必须引用不可变的文件版本;用户替换附件后创建新版本,不能让原有抽取和审计记录失去来源。 - -### 23.5 归档、备份与恢复 - -一租户一数据库使单个客户恢复更容易。租户数据库、对应文件存储、搜索索引和消息状态必须按依赖关系设计恢复方案: - -- 租户数据库支持全量备份和时间点恢复。 -- Schema 发布和大批量导入前创建可验证的恢复点。 -- 文件内容与文件元数据使用一致的保留策略,并定期检查孤立文件和缺失文件。 -- 搜索索引和报表读模型应能从业务数据库重建,不作为唯一事实来源。 -- 数据源配置文件或未来替代它的配置服务需要单独备份,确保恢复后仍能根据 `orgId` 找到数据库。 -- 恢复先在隔离环境校验数据库版本、文件引用、关键数量和业务合计,再切换正式流量。 - -正式上线前必须由业务确定恢复目标。建议初期至少以“生产数据最多丢失 15 分钟(RPO 15 分钟)、单租户核心业务 4 小时内恢复(RTO 4 小时)”作为讨论基线,再根据成本和客户承诺调整。备份成功不等于可以恢复,必须定期执行单租户恢复演练并记录实际 RPO、RTO 和校验结果。 - -## 24. 测试与非功能要求 - -这套系统大量行为来自元数据,不能只测试 Java 和 Vue 代码。元数据定义、Schema 迁移、租户定制和模块 bundle 都是需要自动化验证的正式产品代码。 - -### 24.1 自动化测试范围 - -最低测试矩阵包括: - -| 范围 | 重点验证 | -| --- | --- | -| 元数据 | 稳定 key、类型、关系、引用、公式和依赖合法性 | -| Schema Compiler | 空库安装、快照差异计算、失败恢复、重复执行、结构对账和回滚兼容检查 | -| 通用运行时 | 类型转换、关系保存、`writeMode`、删除策略、乐观锁、事务和批量操作 | -| 权限 | 菜单、动作、数据范围、字段查看/导出/基础脱敏、后台任务和 AI 使用相同决策 | -| 数据源上下文 | 保持现有 Web 请求注入机制,并验证任务、发布和消息入口会正确设置及清理 `OrgContext` | -| 幂等与任务 | 超时重试、重复回调、Worker 崩溃接管、死信和人工重跑 | -| 模块兼容 | bundle 双向差异、稳定 key 冲突、特殊页面与 Handler 的 manifest 契约 | -| 日志与审计 | 成功、失败、回滚、批量和 AI 操作都能正确关联且不泄漏技术异常 | -| AI | 工具输入输出 Schema、权限继承、确认流程和固定样本回归评估 | - -每个 module bundle 的持续集成至少执行:元数据静态校验、空库安装、典型现有结构差异导入、关键业务动作、回滚兼容和依赖契约测试。生产激活前先在与目标租户结构一致的测试库演练;不能只验证一个“标准租户”,还需要选择存在典型定制的租户样本。 - -### 24.2 性能与容量 - -性能目标必须通过数据而不是“感觉够快”来验收。上线前按典型租户和最大租户分别确认: - -```text -在线用户数和峰值并发 -主要实体的数据量和年增长量 -普通 page 查询与保存的 P95/P99 延迟 -批量导入、导出和任务吞吐量 -最大报表扫描量和完成时间 -单租户与全平台数据库连接上限 -日志、审计、Outbox 和文件的增长与保留周期 -``` - -所有列表必须分页并限制最大页大小;导出、大报表、大文件处理和 AI 批量任务超过阈值后自动转为后台任务。压测应使用接近真实的关联关系、数据权限和字段数量,因为空表上的简单 SQL 不能代表 ERP 实际性能。 - -### 24.3 可用性与运维 - -平台需要统一提供健康检查、指标、告警和运行面板,至少覆盖: - -- 各租户数据源可用性和连接池使用率。 -- 通用接口与特殊动作的错误率和延迟。 -- Schema 变更、bundle 安装、`current_release_id` 和缓存一致性。 -- Outbox 堆积、任务延迟、重试和死信。 -- 外部系统调用成功率和对账差异。 -- 日志、数据库、文件和消息存储容量。 -- AI 调用失败率、耗时、成本和人工修正率。 - -每项告警必须对应责任人和处置手册。配置激活、代码发布和数据库迁移都应支持停止继续放量;发生问题时优先回退应用流量或切换到兼容的历史 release,数据已经发生不可逆变化时按迁移补偿和恢复流程处理,不能承诺所有 DDL 都能一键回滚。 - -### 24.4 契约与兼容 - -配置 release 不依赖连续的新旧模块升级链,但特殊 Vue 页面、Java Handler 和 extension bundle 仍需要声明最低平台 API、所需实体、字段和动作。稳定 key 的废弃采用“标记废弃、停止新引用、迁移已有依赖、经过保留期后移除”的流程。 - -每次激活或回滚至少应明确: - -- 当前应用代码和扩展是否支持目标 release。 -- 目标 release 需要的表、字段、动作和事件是否存在。 -- 哪些资源已经废弃或被特殊代码依赖。 -- 哪些 Schema 变更不可逆以及恢复前提。 -- bundle 与当前租户定制存在哪些稳定 key 冲突。 - -## 25. 开发人员与售后技术人员的职责边界 - -| 能力 | 开发人员 | 售后技术人员 | -| --- | --- | --- | -| 新增普通实体和表 | 提供平台能力 | 通过设计器完成 | -| 新增普通字段 | 提供字段类型 | 通过配置完成 | -| 修改名称和布局 | 无需参与 | 通过配置完成 | -| 配置 Select 引用 | 提供引用运行时 | 选择目标实体和显示规则 | -| 普通 CRUD 页面 | 提供通用页面运行时 | 配置生成 | -| 复杂业务页面 | 编写代码 | 调整允许开放的配置 | -| 复杂业务动作 | 编写 Handler | 绑定和配置动作 | -| 特殊业务接口 | 使用扩展 SDK 开发 | 配置权限、菜单和参数 | -| 权限中心 | 提供决策引擎和策略类型 | 配置角色、菜单、动作、数据范围和字段可见性 | -| 双层日志 | 提供追踪、日志和审计框架 | 配置字段审计和客户可见范围 | -| 模块迁移机制 | 提供 Compiler 和发布工具 | 导出、导入和发布变更集 | -| AI 能力 | 提供工具和扩展点 | 使用 AI 生成配置候选版本 | - -总体原则: - -> 开发人员提供稳定能力,售后技术人员组合能力,AI 理解意图并生成方案,平台负责确定性执行。 - -## 26. 不建议采用的方案 - -新系统应避免以下设计: - -- 使用一个模块表同时表达菜单、页面、实体、数据源和权限。 -- 所有字段都采用 EAV 存储。 -- 每新增实体都必须手工创建视图。 -- 每新增字段都需要手工拉取和刷新。 -- 直接使用显示名称作为数据库字段名。 -- 在前端配置中大量保存物理表名和 SQL 片段。 -- 让复杂 SQL 散落在 Select、列表和表单配置中。 -- 为每个普通实体重复开发一套查询和保存接口。 -- Handler 绕过 `EntityService`、`ActionService` 和审计机制直接修改其他模块的数据表。 -- 强迫复杂页面和复杂业务全部通过低代码配置实现。 -- 特殊页面和接口绕开统一实体、权限、多语言、审计和动作体系。 -- 技术异常、SQL 和堆栈直接显示在客户业务时间线中。 -- 技术日志和业务日志使用同一张表或只保存不可解析的文本。 -- 依靠每个页面和 Handler 手工编写审计日志。 -- 配置修改后立即无版本地影响生产运行。 -- 所有实体不分业务含义统一软删除,或正式业务单据允许无条件物理删除。 -- 只在前端隐藏菜单或按钮,不在数据和动作运行时执行相同权限判断。 -- 为菜单、按钮、数据、报表和 AI 分别维护互不关联的权限体系。 -- AI 直接生成并执行任意 SQL。 -- 业务模块直接依赖某一个 AI 模型供应商。 -- 依赖 `max(number) + 1` 生成并发业务编号。 -- 金额使用浮点数,或历史单据随最新汇率自动变化。 -- 只做备份不做恢复演练,或承诺所有数据库变更都可自动回滚。 -- 为单个客户复制并长期维护另一套平台架构。 - -## 27. 建议实施顺序 - -测试、权限、审计、可观测性和租户隔离不是最后增加的阶段,而是每个阶段的完成条件。实施过程始终选择一条真实业务纵向验证,例如“基础资料 + 海运订舱 + 确认动作”,避免先花很长时间建设没有业务验证的通用平台。 - -### 第一阶段:建立基础元数据模型 - -- 沿用并固化现有 `orgId -> OrgContext -> OrgRoutingDataSource` 数据源注入机制。 -- 完成模块、实体、字段、关系、字典模型。 -- 完成页面入口和菜单树模型,明确页面路由、菜单绑定、排序、可见性和权限继承规则。 -- 建立稳定 key 规则和语义描述规范。 -- 确定真实列与扩展字段的边界。 -- 实现不可变配置 release、当前指针、候选预览、回滚和变更集。 -- 完成多语言资源、稳定 i18n key 和语言回退规则。 -- 完成权限主体、权限资源和稳定权限 key 设计。 -- 完成时间、金额、汇率、编号和文件的基础模型。 - -### 第二阶段:建立 Schema Compiler - -- 支持创建实体、字段、索引和关系。 -- 支持结构差异预览、Schema 执行状态机、当前数据库变更锁和中断恢复。 -- 支持数据库结构对账。 -- 支持空库初始化和模块 bundle 导入。 -- 建立依赖图、破坏性变更保护和当前 `orgId` 的 `current_release_id` 缓存收敛。 -- 自动测试空库安装、快照差异、重复执行、失败恢复和回滚兼容。 - -### 第三阶段:建立通用运行时 - -- 实现通用数据查询和保存。 -- 实现 `writeMode`、乐观锁、请求幂等和数据库唯一约束。 -- 实现字典和引用字段。 -- 实现默认列表、表单和查询页面。 -- 实现页面运行时路由解析、按权限过滤菜单树和目录节点裁剪。 -- 实现拖拽式表单设计器、主子关系区域和一对多子表编辑表格。 -- 实现运行时多语言解析、租户默认语言和用户语言切换。 -- 实现售后配置中心。 -- 实现动作和数据范围权限决策引擎。 -- 实现按用户、角色或用户组的字段查看、导出和基础脱敏策略,并在查询、保存、导出、报表、特殊 Handler 和 AI 工具中统一执行。 -- 实现用户权限快照、缓存失效和权限解释。 -- 实现统一 request/trace 上下文和结构化技术日志。 -- 实现业务审计事件、字段变更和客户时间线。 -- 完成 `OrgContext` 数据源注入、权限和通用接口的集成测试与基准压测。 - -### 第四阶段:建立动作与扩展机制 - -- 实现动作注册中心。 -- 实现 Handler 和生命周期扩展点。 -- 实现开发扩展 SDK 和特殊接口注册规范。 -- 实现特殊 Vue 页面 manifest 注册机制。 -- 实现事件中心和 Outbox。 -- 实现可靠任务框架、重试、死信、进度、取消和人工重跑。 -- 建立外部消息幂等、交互记录和对账机制。 -- 确保特殊 Handler 自动接入技术日志和业务审计。 -- 开始开发海运、空运等复杂业务模块。 - -### 第五阶段:建立 AI 基础设施 - -- 完成语义元数据查询工具。 -- 完成通用数据和动作工具。 -- 提供 OpenAPI、Tool Calling 和 MCP 适配。 -- 确保 AI 工具继承动作和数据范围权限。 -- 实现 AI 技术执行日志和客户可见业务日志的分离。 -- 实现 AI 配置助手和人工确认流程。 -- 建立模型、提示词、执行和评估记录。 - -### 第六阶段:建设货代文档智能 - -- 建立文件分类、OCR 和结构化抽取管道。 -- 建立提单、发票和合同抽取模板。 -- 建立置信度、人工校验和来源追踪。 -- 将抽取结果与海运、空运和财务实体关联。 -- 建立固定文档样本集,持续评估抽取准确率和人工修正率。 - -### 第七阶段:上线准备与持续演进 - -- 根据真实租户数据量完成容量评估和性能验收。 -- 完成代码、配置、Schema 和租户定制的联合发布演练。 -- 完成单租户数据库、对应文件和数据源配置的恢复演练。 -- 建立监控告警、值班责任、问题处置和外部集成对账流程。 -- 选取首批租户逐步放量,验证后再扩大范围。 -- 把生产问题沉淀为自动化测试、依赖规则和发布检查。 - -## 28. 最终结论 - -FMS 新系统应定位为一个以业务模块为中心、以实体语义模型为基础的内部 ERP 低代码平台。系统采用一租户一业务数据库的物理隔离方式,客户定制通过同一套元数据、配置 release、module bundle 和 extension bundle 交付,不复制另一套平台架构。 - -模块负责业务边界,实体负责数据结构,页面负责展示,动作负责业务行为,统一权限中心负责页面、动作、数据范围和配置访问决策,双层日志体系负责技术可观测和客户业务追踪,Schema Compiler 负责数据库演进,配置 release 负责候选验证、激活和回滚,通用运行时负责普通业务能力,特殊页面、特殊接口和扩展 SDK 负责复杂逻辑,AI Gateway 负责连接模型与系统工具。 - -最终目标不是让所有功能都由配置完成,也不是让所有需求都回到开发人员,而是建立清晰的能力层次: - -```text -简单数据结构和页面:售后配置 -复杂业务规则、特殊页面和特殊接口:IT 开发实现 -自然语言理解和方案生成:AI 完成 -结构校验、数据库变更和业务执行:平台完成 -``` - -这套架构可以同时解决旧系统迁移困难、配置繁琐、维护成本高、权限规则分散和日志难以追踪的问题,并为未来 AI 在继承用户权限、保留完整技术与业务审计记录的前提下深度参与 ERP 查询、操作、配置、分析和文档处理提供稳定基础。 diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md index e327c9ff..75e4a222 100644 --- a/code/fms/FMS新系统核心表结构设计.md +++ b/code/fms/FMS新系统核心表结构设计.md @@ -5,12 +5,13 @@ ## 1. 设计原则 -针对旧系统 `s_module` 职责混杂的问题,本方案坚持四个分离: +针对旧系统 `s_module` 职责混杂的问题,本方案坚持五项原则: 1. **模块与实体分离**:`s_module` 只表达业务能力边界与依赖关系,不承载表、路由、菜单;数据归属由 `s_entity` 承担。 2. **实体与字段分离**:实体是数据所有者,声明查询源/保存源/主键字段;字段挂实体,同时携带数据库列定义与 UI 行为。 3. **菜单与模块分离**:`s_menu` 是纯导航树,只引用实体或固定页面,不掺入数据源配置。 4. **字段定义与视图配置合并**:旧系统把字段拆成 `s_module_field` + `_edit` + `_list` + `_query` 四张表,维护极易失配;本方案合并为单表 `s_field`,用标志列表达列表/表单/查询行为。 +5. **配置入口与存储职责分离**:管理员始终从菜单树定位页面,在一个配置工作台内完成数据源、字段、列表、表单、查询、编码和权限配置;底层数据仍按模块、实体、菜单和权限分别存储。 其余约定: @@ -20,6 +21,8 @@ - 时间使用 `datetime2`,默认 `sysdatetime()`。 - 审计字段:定义类表保留 `b_inputuser_id / b_inputdatetime / b_updateuser_id / b_updatedatetime`;关联类表只保留 `b_inputdatetime`。 - 模块归属保留树形(`b_parent_id`,仅用于业务分组),模块间依赖使用独立的 DAG 表,两者语义互不混淆。 +- 菜单树是管理端的统一配置入口;模块树只用于业务归属和依赖管理,不要求管理员在两棵树之间来回切换。 +- 实体字段定义保留一套规范元数据;同一实体被多个菜单复用时,通过页面配置档保存菜单级布局和行为差异。 ## 2. 表清单 @@ -31,17 +34,19 @@ | 4 | `s_field` | 字段(列定义 + 列表/表单/查询行为合并单表) | | 5 | `s_field_group` | 字段分组(实体级) | | 6 | `s_relation` | 实体关系(引用/主子,驱动外键校验与级联) | -| 7 | `s_menu` | 菜单/页面(纯导航,挂模块做组织维度) | -| 8 | `s_autocode` | 自动编码规则 | -| 9 | `s_role` | 角色 | -| 10 | `s_user_role` | 用户-角色 | -| 11 | `s_power` | 权限点(menu/entity/action/field) | -| 12 | `s_role_power` | 角色-权限 | -| 13 | `s_i18n` | 多语言资源 | -| 14 | `s_log_technical` | 技术日志(请求链路/SQL/异常/性能) | -| 15 | `s_log_login` | 登录日志(登录/退出/失败/锁定) | -| 16 | `s_log_audit` | 业务审计事件 | -| 17 | `s_log_audit_field` | 审计字段变更明细 | +| 7 | `s_menu` | 菜单/页面导航(同时作为统一配置入口) | +| 8 | `s_page_profile` | 菜单页面配置档(一个菜单入口的一套页面行为) | +| 9 | `s_page_field` | 页面字段覆盖(菜单级显示、排序、表单/查询覆盖) | +| 10 | `s_autocode` | 自动编码规则 | +| 11 | `s_role` | 角色 | +| 12 | `s_user_role` | 用户-角色 | +| 13 | `s_power` | 权限点(menu/entity/action/field) | +| 14 | `s_role_power` | 角色-权限 | +| 15 | `s_i18n` | 多语言资源 | +| 16 | `s_log_technical` | 技术日志(请求链路/SQL/异常/性能) | +| 17 | `s_log_login` | 登录日志(登录/退出/失败/锁定) | +| 18 | `s_log_audit` | 业务审计事件 | +| 19 | `s_log_audit_field` | 审计字段变更明细 | ## 3. 各表结构 ### 3.1 s_module 模块 @@ -144,7 +149,7 @@ hard 临时记录:校验权限后直接物理删除 ### 3.4 s_field 字段 -合并旧系统 `s_module_field` + `_edit` + `_list` + `_query` 四张表,字段增删改只需维护一处。 +合并旧系统 `s_module_field` + `_edit` + `_list` + `_query` 四张表,保存实体级默认定义。菜单页面的个性化显示、排序和交互覆盖放在 `s_page_field`,避免同一实体被多个菜单引用时互相影响。 ```sql create table s_field ( @@ -234,7 +239,7 @@ create index ix_s_relation_target on s_relation (b_target_entity_id); ### 3.7 s_menu 菜单/页面 -纯导航树,通过 `b_module_id` 挂到模块做组织维度,通过 `b_entity_id` 引用实体;模块与菜单可互相独立演进(一个模块多个菜单入口、一个菜单也可属于无模块的公共区)。 +数据层的菜单仍是导航树,不直接保存字段、列表或表单配置;管理端把它作为统一配置入口。通过 `b_module_id` 挂到模块做组织维度,通过 `b_entity_id` 引用实体;模块与菜单可互相独立演进(一个模块多个菜单入口、一个菜单也可属于无模块的公共区)。选中菜单后,配置工作台聚合展示其页面档、实体、字段、关联实体、自动编码和权限。 ```sql create table s_menu ( @@ -265,7 +270,67 @@ create index ix_s_menu_entity on s_menu (b_entity_id); 菜单类型约束由元数据校验器执行:`directory` 不填业务路由;`page` 必须填 `b_route`;`data` 必须填 `b_entity_id`;`external` 必须填 `b_route` 和 `b_power_code`。目录仅在存在至少一个可访问子页面时显示。 -### 3.8 s_autocode 自动编码 +### 3.8 s_page_profile 页面配置档 + +页面配置档是菜单入口与实体默认元数据之间的配置上下文。一个 `data` 菜单通常对应一个默认配置档;同一实体被多个菜单复用时,每个菜单可以拥有独立配置档。配置档不复制实体字段定义,只保存页面级行为。 + +```sql +create table s_page_profile ( + b_id bigint identity(1, 1) not null, + b_menu_id bigint not null, -- 配置入口 + b_entity_id bigint not null, -- 页面主实体 + b_code varchar(50) not null, + b_name nvarchar(100) not null, + b_list_enabled int not null default 1, + b_edit_enabled int not null default 1, + b_query_enabled int not null default 1, + b_canuse int not null default 1, + b_inputuser_id bigint null, + b_inputdatetime datetime2 null default sysdatetime(), + b_updateuser_id bigint null, + b_updatedatetime datetime2 null, + constraint pk_s_page_profile primary key (b_id), + constraint ux_s_page_profile_menu unique (b_menu_id), + constraint ux_s_page_profile_code unique (b_code), + constraint fk_s_page_profile_menu foreign key (b_menu_id) references s_menu (b_id), + constraint fk_s_page_profile_entity foreign key (b_entity_id) references s_entity (b_id) +); +``` + +配置工作台按以下顺序呈现:页面基本信息 → 数据源/实体 → 字段与分组 → 列表 → 表单 → 查询 → 关联实体 → 自动编码 → 权限。保存时由服务层校验菜单类型、实体类型和模块依赖关系。 + +没有独立菜单的子实体、费用明细、选择器和公共字典,通过主实体的 `s_relation` 或页面关联配置挂在所属页面下;它们不需要重复创建菜单,也可以在同一个配置工作台中维护。 + +### 3.9 s_page_field 页面字段覆盖 + +只记录相对于 `s_field` 默认值的页面级覆盖;为空表示继承实体字段默认配置。这样既保留旧系统“进入菜单即可完整配置”的体验,又避免不同菜单修改同一实体时产生配置串扰。 + +```sql +create table s_page_field ( + b_id bigint identity(1, 1) not null, + b_page_profile_id bigint not null, + b_field_id bigint not null, + b_group_id bigint null, + b_visible int null, + b_xh int null, + b_width int null, + b_required int null, + b_readonly int null, + b_disabled int null, + b_queryable int null, + b_querytype varchar(30) null, + b_queryop varchar(20) null, + constraint pk_s_page_field primary key (b_id), + constraint ux_s_page_field unique (b_page_profile_id, b_field_id), + constraint fk_s_page_field_profile foreign key (b_page_profile_id) references s_page_profile (b_id), + constraint fk_s_page_field_field foreign key (b_field_id) references s_field (b_id), + constraint fk_s_page_field_group foreign key (b_group_id) references s_field_group (b_id) + ); + +create index ix_s_page_field_profile on s_page_field (b_page_profile_id, b_xh, b_id); +``` + +### 3.10 s_autocode 自动编码 独立于模块,挂实体 + 目标字段,规则可复用。并发控制:应用层使用原子更新取号(`update ... set b_currentvalue = b_currentvalue + 1 output inserted.b_currentvalue`),避免自增竞争。 @@ -289,7 +354,7 @@ create table s_autocode ( ); ``` -### 3.9 s_role 角色 +### 3.11 s_role 角色 ```sql create table s_role ( @@ -308,7 +373,7 @@ create table s_role ( ); ``` -### 3.10 s_user_role 用户-角色 +### 3.12 s_user_role 用户-角色 ```sql create table s_user_role ( @@ -325,7 +390,7 @@ create table s_user_role ( create index ix_s_user_role_role on s_user_role (b_role_id, b_canuse); ``` -### 3.11 s_power 权限点 +### 3.13 s_power 权限点 ```sql create table s_power ( @@ -344,7 +409,7 @@ create table s_power ( create index ix_s_power_resource on s_power (b_power_type, b_resource, b_canuse); ``` -### 3.12 s_role_power 角色-权限 +### 3.14 s_role_power 角色-权限 ```sql create table s_role_power ( @@ -364,7 +429,7 @@ create index ix_s_role_power_power on s_role_power (b_power_id, b_canuse); 菜单显示继承页面访问权限:用户拥有 `s_menu.b_power_code` 对应权限时菜单才显示;前端只控制显示,后端运行时必须再次校验同一个权限编码。 -### 3.13 s_i18n 多语言 +### 3.15 s_i18n 多语言 ```sql create table s_i18n ( @@ -381,7 +446,7 @@ create table s_i18n ( create index ix_s_i18n_locale on s_i18n (b_locale, b_canuse); ``` -### 3.14 s_log_technical 技术日志 +### 3.16 s_log_technical 技术日志 用于请求链路、SQL、异常、性能与外部接口排查。高频日志推荐写入 Loki/OpenSearch/Elasticsearch 等日志平台,本表用于集中日志库或短期数据库兜底,不建议长期写入租户业务库。 @@ -414,7 +479,7 @@ create index ix_s_log_technical_org on s_log_technical (b_org_id, b_occurdatetim create index ix_s_log_technical_level on s_log_technical (b_loglevel, b_occurdatetime desc); ``` -### 3.15 s_log_login 登录日志 +### 3.17 s_log_login 登录日志 记录登录成功、失败、退出、刷新令牌与账号锁定等安全事件,可通过 `b_request_id` / `b_trace_id` 与技术日志关联。 @@ -447,7 +512,7 @@ create index ix_s_log_login_account on s_log_login (b_account, b_occurdatetime d create index ix_s_log_login_result on s_log_login (b_login_result, b_occurdatetime desc); ``` -### 3.16 业务审计事件与字段变更 +### 3.18 业务审计事件与字段变更 审计事件与字段变更分表存储:事件一条、字段变更多条,通过 `b_event_id` 关联。字段级明细是**面向客户的硬需求**(客户可在门户查看某个单号发生过哪些字段变更),**字段一旦发生修改即写入明细**,不按字段配置开关;`b_visibility` 控制内部/客户可见。 @@ -507,18 +572,21 @@ create index ix_s_log_audit_field_customer on s_log_audit_field (b_entity_code, 树形(`b_parent_id`)表达"业务分组归属"(基础资料 > 海运 > 订舱),是货代业务的真实组织诉求,且操作直观;但树无法表达"finance 依赖 master-data"这类跨分支依赖,所以依赖单独用 `s_module_dependency` 表达并强制 DAG。树归树、依赖归依赖,语义不再混淆。 ### 4.3 为什么字段合并单表 -旧系统字段配置拆 4 张表,字段新增要同步维护 4 处、分组标题另存一份,配置漂移是常态。合并后字段的"定义 + 列表 + 表单 + 查询"行为集中在一行,增删改一处完成;代价是行较宽,但对元数据表(数量级远小于业务表)完全可接受。 +旧系统字段配置拆 4 张表,字段新增要同步维护 4 处、分组标题另存一份,配置漂移是常态。合并后字段的定义与默认列表/表单/查询行为集中在一行,实体字段增删改只需维护一处;菜单级差异由 `s_page_field` 覆盖,既避免重复定义,又保留不同页面的独立布局。代价是行较宽,但对元数据表(数量级远小于业务表)完全可接受。 ### 4.4 为什么菜单独立且引用实体 -导航是展示层概念,数据是业务层概念。`s_menu` 引用 `b_entity_id` 而非模块,同一实体可在多个菜单入口出现(如"订舱列表"与"订舱审核"共用 `ocean_booking` 实体但绑定不同权限)。 +导航是展示层概念,数据是业务层概念。`s_menu` 引用 `b_entity_id` 而非模块,同一实体可在多个菜单入口出现(如"订舱列表"与"订舱审核"共用 `ocean_booking` 实体但绑定不同权限)。菜单树同时是管理员的统一配置入口,配置工作台通过菜单聚合其页面档、字段覆盖、关联实体和权限;因此数据库职责分离不会牺牲旧系统的定位效率。 -### 4.5 为什么权限引入角色层 +### 4.5 为什么增加页面配置档 +实体是可复用的数据定义,菜单是具体的使用入口。同一实体可能需要不同的列表列、表单可编辑性或查询条件,不能把这些差异继续写回实体字段默认值。`s_page_profile` 表示一个菜单入口的页面上下文,`s_page_field` 只记录覆盖项;管理员仍然只需进入对应菜单即可完成完整配置。 + +### 4.6 为什么权限引入角色层 旧系统 `b_user_power` 是用户直挂权限,用户一多授权矩阵爆炸。本方案 `s_role` + `s_user_role` + `s_role_power` 三层,权限点 `s_power` 支持到 menu/entity/action/field 四级粒度,为后续数据范围、字段脱敏策略留出扩展位。 -### 4.6 自动编码的并发处理 +### 4.7 自动编码的并发处理 `b_currentvalue` 若采用"读-改-写"必然在并发下产生重号。方案要求应用层用原子自增语句取号(`update ... set b_currentvalue = b_currentvalue + 1 output inserted.b_currentvalue`),并把取号与业务保存放同一事务,确保单号不重不漏。 -### 4.7 日志的写入去向 +### 4.8 日志的写入去向 三类日志职责不同、量级不同,不建议全部落租户业务库: - 技术日志高频(每条请求、SQL、异常),推荐写 Loki/ES 等日志平台,`s_log_technical` 仅作集中日志库或短期兜底。 - 登录日志与业务审计属于安全与合规数据,低频但必须长期留存,落库保存;审计字段明细在字段发生修改时全量写入,不做按字段开关,数据量按变更频率自然收敛。 @@ -532,6 +600,9 @@ create index ix_s_log_audit_field_customer on s_log_audit_field (b_entity_code, | --- | --- | --- | | `s_user_role` | `b_user_id` | 当前身份系统的用户 `b_id` | | `s_menu` | `b_module_id` | `s_module.b_id` | +| `s_page_profile` | `b_entity_id` | 必须与所属 `s_menu.b_entity_id` 一致,且实体已启用 | +| `s_page_field` | `b_field_id` | 必须属于所属 `s_page_profile.b_entity_id` | +| `s_page_field` | `b_group_id` | 必须属于所属实体的 `s_field_group.b_id` | | `s_field` | `b_group_id` | `s_field_group.b_id` | | `s_menu` | `b_power_code` | `s_power.b_code` | | `s_entity` | `b_viewtable` / `b_savetable` | Schema Compiler 生成的物理表/视图 | @@ -558,6 +629,7 @@ create index ix_s_log_audit_field_customer on s_log_audit_field (b_entity_code, | `s_module_field_group` | `s_field_group` | 分组挂实体 | | `s_module_auto_code` | `s_autocode` | 挂实体+字段,原子取号 | | `s_module_power` + `b_user_power` | `s_power` + `s_role` + `s_user_role` + `s_role_power` | 引入角色层 | +| (旧系统由 module 直接承载) | `s_page_profile` + `s_page_field` | 菜单级页面配置与字段覆盖,保留集中配置体验 | | `b_i18n` | `s_i18n` | 增加资源类型维度 | | `s_login_log` | `s_log_login` | 字段扩充(设备/UA/请求链路) | | (无) | `s_module_dependency` | 新增依赖 DAG | diff --git a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue index fe021afd..71409843 100644 --- a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue +++ b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue @@ -55,22 +55,42 @@ const tableTheme = computed(() => props.theme ?? (appStore.darkMode ? 'dark' : ' // Tab 面板从隐藏恢复时,等到容器拥有有效尺寸再刷新虚拟滚动布局。 // rAF 将同一帧内的多次 ResizeObserver 通知合并,避免重复重排。 let refreshRafId = 0 -function refreshLayout() { +function refreshLayout(xOnly = false) { if (refreshRafId) cancelAnimationFrame(refreshRafId) refreshRafId = requestAnimationFrame(async () => { refreshRafId = 0 await nextTick() - tableRef.value?.initVirtualScroll?.() + // 高度未变、仅宽度变化时只重算 X 方向虚拟滚动,省掉 Y 方向重算 + if (xOnly) tableRef.value?.initVirtualScrollX?.() + else tableRef.value?.initVirtualScroll?.() await nextTick() }) } +/* 尺寸防抖(trailing):折叠动画 / 窗口缩放 / 拖拽调宽等连续尺寸变化期间, + * ResizeObserver 每帧都会通知,但中间尺寸是瞬态的——逐帧 initVirtualScroll + * 会每帧强制 reflow 并重渲染可见行列,造成动画掉帧(如侧栏折叠时 dashboard + * 10 万单元格表格的卡顿)。这里合并通知,尺寸稳定(约 150ms 无变化)后才 + * 刷新一次,与 stk-table 库 autoResize 的 200ms 防抖语义对齐(库 L4130-4133)。 + * 动画期间表格仅随 CSS 拉伸,结束后一次到位。 */ +const RESIZE_DEBOUNCE_MS = 150 +let resizeDebounceTimer = 0 +// 上次生效尺寸:用于判断是否仅宽度变化(只重算 X 方向) +let lastSize = null + let resizeObserver = null onMounted(() => { if (typeof ResizeObserver === 'undefined' || !rootRef.value) return resizeObserver = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect - if (width > 0 && height > 0) refreshLayout() + if (width <= 0 || height <= 0) return + clearTimeout(resizeDebounceTimer) + resizeDebounceTimer = setTimeout(() => { + resizeDebounceTimer = 0 + const xOnly = lastSize != null && lastSize.height === height && lastSize.width !== width + lastSize = { width, height } + refreshLayout(xOnly) + }, RESIZE_DEBOUNCE_MS) }) resizeObserver.observe(rootRef.value) }) @@ -515,6 +535,7 @@ watch( onBeforeUnmount(() => { if (refreshRafId) cancelAnimationFrame(refreshRafId) + clearTimeout(resizeDebounceTimer) resizeObserver?.disconnect() resizeObserver = null removeNavigationListeners() @@ -589,6 +610,11 @@ defineExpose({ .fms-table__inner { width: 100%; height: 100%; + + /* transform 虚拟滚动模式下,把 tbody 提升为合成层,translateY 走合成器、避免每帧重绘整表 */ + .stk-tbody-main { + will-change: transform; + } } diff --git a/code/fms/fms-vue/src/components/fms-table/utils/createCellRenderer.js b/code/fms/fms-vue/src/components/fms-table/utils/createCellRenderer.js index 97939669..75608f7e 100644 --- a/code/fms/fms-vue/src/components/fms-table/utils/createCellRenderer.js +++ b/code/fms/fms-vue/src/components/fms-table/utils/createCellRenderer.js @@ -42,6 +42,8 @@ function actionButton(action, row, triggerAction, className = 'fms-action-btn') ) } +// 确认弹窗:状态化渲染(替代 antdv 的 Modal.confirm 命令式调用), +// 由内部 Modal 承载;okType=danger(默认)时用 danger 按钮定制 footer。 function createConfirmDialog(state, close) { const { action, row } = state const config = typeof action.confirm === 'string' ? { title: action.confirm } : action.confirm || {} @@ -203,23 +205,24 @@ function createActionsCell(actions) { } } -// ===== A/B 变体:模拟优化前的行为 ===== -// 所有列(含纯展示列、非编辑态可编辑列)一律注入 customCell 函数式组件, -// 即每格都是一个函数式组件,滚动时每帧全部重建。 -function displayCell(col, row) { - const content = row[col.dataIndex] - return h('span', { class: 'fms-cell-display', title: String(content ?? '') }, content ?? '') -} - export function resolveCustomCell(col, editingContext, editingActive = true) { if (col.customCell) return col - if (col.editor) { + // 可编辑列:仅在存在活动编辑行时注入编辑器渲染(非编辑态走库原生 td,避免滚动时 + // 每格函数式组件重渲染)。checkbox 列始终可交互、select 列需解析 label 展示, + // 两者保持注入 customCell。 + if ( + col.editor && + (editingActive || col.editor.type === 'checkbox' || col.editor.type === 'select') + ) { return { ...col, customCell: createEditableCell(col, editingContext) } } + if (col.actions?.length) { const customCell = createActionsCell(col.actions) if (customCell) return { ...col, customCell } } - // 纯展示列:也注入 customCell(旧行为) - return { ...col, customCell: ({ row }) => displayCell(col, row) } + // 纯展示列(无 editor、无 actions)与非编辑态的可编辑列:不再注入 customCell, + // 走 StkTable 原生 td 渲染路径(减少每格函数式组件数量)。mousedown 照常冒泡到 + // tbody 以支持点击激活行编辑,文字选中通过 CSS 层 user-select 保障。 + return col } diff --git a/code/test-demo/index.html b/code/test-demo/index.html index 69d2dd5d..19607be5 100644 --- a/code/test-demo/index.html +++ b/code/test-demo/index.html @@ -4,7 +4,7 @@ - FMS 新系统 · 元数据配置 Demo + FMS 售后配置中心 Demo
diff --git a/code/test-demo/package.json b/code/test-demo/package.json index 3c1ed652..9d0ff5d7 100644 --- a/code/test-demo/package.json +++ b/code/test-demo/package.json @@ -15,6 +15,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.3.3", + "lucide-react": "^1.33.0", "react": "^19.2.8", "react-dom": "^19.2.8", "tailwindcss": "^4.3.3" diff --git a/code/test-demo/pnpm-lock.yaml b/code/test-demo/pnpm-lock.yaml index ce4f8938..97782bc0 100644 --- a/code/test-demo/pnpm-lock.yaml +++ b/code/test-demo/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)) + lucide-react: + specifier: ^1.33.0 + version: 1.33.0(react@19.2.8) react: specifier: ^19.2.8 version: 19.2.8 @@ -628,6 +631,11 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lucide-react@1.33.0: + resolution: {integrity: sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1112,6 +1120,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lucide-react@1.33.0(react@19.2.8): + dependencies: + react: 19.2.8 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/code/test-demo/public/favicon.svg b/code/test-demo/public/favicon.svg deleted file mode 100644 index 6893eb13..00000000 --- a/code/test-demo/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/code/test-demo/public/icons.svg b/code/test-demo/public/icons.svg deleted file mode 100644 index e9522193..00000000 --- a/code/test-demo/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/code/test-demo/src/App.tsx b/code/test-demo/src/App.tsx index 091c9156..7e2f7e8f 100644 --- a/code/test-demo/src/App.tsx +++ b/code/test-demo/src/App.tsx @@ -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 = { + basic: "基本信息", + fields: "字段定义", + list: "列表配置", + edit: "编辑配置", + query: "查询配置", + autocode: "自动编码", + power: "权限配置", + i18n: "多语言", +}; + +const typeLabels: Record = { directory: "目录", page: "页面", data: "数据" }; +const fieldTypes = [ + ["input", "输入框"], ["textarea", "文本域"], ["number", "数字"], ["money", "金额"], + ["date", "日期"], ["datetime", "日期时间"], ["select", "下拉选择"], +]; + +const sectionIcons: Record> = { + 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 ; +} + +function PanelHeader({ title, desc, action }: { title: string; desc: string; action?: ReactNode }) { + return

{title}

{desc}

{action}
; +} export default function App() { - const [view, setView] = useState<"tree" | "permission">("tree"); - const [selection, setSelection] = useState(null); - const [modules, setModules] = useState(initialModules); - const [dependencies, setDependencies] = useState(initialDependencies); - const [entities, setEntities] = useState(initialEntities); - const [fields, setFields] = useState(initialFields); - const [groups, setGroups] = useState(initialFieldGroups); - const [menus, setMenus] = useState(initialMenus); - const [autocodes, setAutocodes] = useState(initialAutocodes); - const [i18ns, setI18ns] = useState(initialI18ns); - const [roles, setRoles] = useState(initialRoles); - const [userRoles, setUserRoles] = useState(initialUserRoles); - const [powers, setPowers] = useState(initialPowers); - const [rolePowers, setRolePowers] = useState(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) => { + setNodes((items) => items.map((node) => node.id === selected.id ? { ...node, ...patch } : node)); + setDirty(true); + }; + const updateField = (id: number, patch: Partial) => { + setFields((items) => items.map((field) => field.id === id ? { ...field, ...patch } : field)); + setDirty(true); }; - return ( -
- {/* 顶栏 */} -
-
-
-

FMS 新系统 · 元数据配置 Demo

-

- 统一树维护:菜单 → 模块 → 数据(实体) → 编码 · 字段/分组在实体面板内维护 · 对应 17 张表 · 命名风格 s_表 / b_字段 -

-
-
-
- {stats.map((s) => ( -
-
{s.value}
-
{s.label}
-
- ))} -
- -
+ return
+
+
FMS 模块管理菜单、页面与数据配置
+
当前系统国际货运管理系统
+
{dirty ? "有未保存修改" : "所有修改已保存"}林
+
+ +
+
+ + +
目录页面数据
+
页面由开发人员编写;这里维护菜单、路由以及页面所使用的数据配置。
+ - {/* 内容区 */} -
- {view === "tree" ? ( -
- -
- {selection ? ( - - ) : ( -
- 请在左侧树中选择一个节点开始配置 -
- 顺着导航维护:菜单 → 模块 → 数据(实体) → 编码 · 字段/分组在实体面板内维护 -
-
- )} -
-
- ) : ( - - )} +
+
+
{getPath(nodes, selected).join(" / ")}

{selected.name}

{typeLabels[selected.type]}{selected.code}
+
{selected.type !== "data" && }
+
+ +
+ +
+ {section === "basic" && } + {section === "fields" && } + {section === "list" && } + {section === "edit" && } + {section === "query" && } + {section === "autocode" && } + {section === "power" && } + {section === "i18n" && } +
+
- -
- 演示环境:仅前端内存状态,改动不落库。生产环境由服务层校验后触发 Schema Compiler 同步 DDL。 -
- ); + ; +} + +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) => [ + , + ...render(node.id, depth + 1), + ]); + return
{render(null)}
; +} + +function BasicPanel({ node, nodes, onChange, onSelectNode }: { node: ModuleNode; nodes: ModuleNode[]; onChange: (patch: Partial) => void; onSelectNode: (node: ModuleNode) => void }) { + const dataNodes = nodes.filter((item) => item.type === "data"); + const relatedData = dataNodes.find((item) => item.id === node.dataNodeId); + return
+ +
+ + + + + {node.type === "page" && <> + + + + + } + {node.type === "data" && <> + + + + + } +
+
{node.type === "page" ? "这里只登记已开发页面的入口与关联数据,不会生成或改写页面代码。" : node.type === "data" ? "数据节点集中维护字段、列表、表单和查询规则,可被一个或多个页面使用。" : "目录与用户实际看到的菜单层级保持一致。"}
+
; +} + +function FieldsPanel({ fields, update }: { fields: Field[]; update: (id: number, patch: Partial) => void }) { + return
同步字段} />
字段名显示名称控件类型默认值启用
{fields.map((field) =>
{field.code} update(field.id, { name: event.target.value })} /> update(field.id, { defaultValue: event.target.value })} /> update(field.id, { enabled: !field.enabled })} />
)}
; +} + +function ListPanel({ fields }: { fields: Field[] }) { + return {fields.slice(0, 8).map((field, index) =>
{field.code}{field.name} undefined} />
)}
; +} + +function EditPanel({ fields }: { fields: Field[] }) { + return {fields.slice(0, 8).map((field, index) =>
{field.code}{field.name} undefined} /> undefined} />
)}
; +} + +function QueryPanel({ fields }: { fields: Field[] }) { + return 新增条件} heads={["查询字段", "查询组件", "操作符", "默认值", "启用"]}>{fields.filter((field) => field.code !== "b_freight").slice(0, 7).map((field, index) =>
{field.name}{field.code} undefined} />
)}
; +} + +function ConfigTable({ title, desc, action, heads, children }: { title: string; desc: string; action?: ReactNode; heads: string[]; children: ReactNode }) { + return
{heads.map((head) => {head})}
{children}
; +} + +function AutoCodePanel() { + return
目标字段b_ywno · 业务单号
示例结果NBSE2608200001
undefined} />
1
2
3
位,每日重置
; +} + +function PowerPanel() { + const powers = [["查看订舱", "booking.view"], ["新增订舱", "booking.create"], ["编辑订舱", "booking.update"], ["审核订舱", "booking.audit"], ["导出订舱", "booking.export"]]; + return
新增权限} />
{powers.map(([name, code], index) =>
{name}{code} undefined} />
)}
; +} + +function I18nPanel({ node, fields }: { node: ModuleNode; fields: Field[] }) { + return
批量翻译} />
资源键简体中文English日本語
module.{node.code}
{fields.slice(0, 5).map((field) =>
field.{node.code}.{field.code}
)}
; } diff --git a/code/test-demo/src/components/AutoCodePanel.tsx b/code/test-demo/src/components/AutoCodePanel.tsx deleted file mode 100644 index 55cf1cc9..00000000 --- a/code/test-demo/src/components/AutoCodePanel.tsx +++ /dev/null @@ -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("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) => { - 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) => { - 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 ( -
- {/* 自动编码 */} - - + 新增规则 - - } - > - - - - - - - - - - - - {autocodes.length === 0 && } - {autocodes.map((a) => ( - - - - - - - - ))} - -
实体目标字段规则当前流水操作
- - {a.b_field} - - {a.b_prefix} - {a.b_separator} - {a.b_dateformat} - {a.b_separator} - {"0".repeat(a.b_seqwidth)} - - (重置:{a.b_resettype}) - - {a.b_currentperiod ?? "-"} / {a.b_currentvalue} - - -
- {autocodes.length > 0 && ( -
- 取号使用原子自增(update ... set b_currentvalue = b_currentvalue + 1 output inserted.b_currentvalue), - 与业务保存同一事务,避免并发重号。 -
- )} -
- - {/* 自动编码规则明细(选中第一项展开编辑) */} - {autocodes[0] && ( - -
- - updateAutocode(autocodes[0].b_id, { b_prefix: e.target.value })} /> - - - updateAutocode(autocodes[0].b_id, { b_dateformat: e.target.value })} - /> - - - updateAutocode(autocodes[0].b_id, { b_separator: e.target.value })} - /> - - - updateAutocode(autocodes[0].b_id, { b_seqwidth: Number(e.target.value) })} - /> - - - - - - updateAutocode(autocodes[0].b_id, { b_startvalue: Number(e.target.value) })} - /> - - - updateAutocode(autocodes[0].b_id, { b_currentperiod: e.target.value || null })} - /> - - - updateAutocode(autocodes[0].b_id, { b_currentvalue: Number(e.target.value) })} - /> - -
-
- 下一单号预览:{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")} -
-
- )} - - {/* 多语言 */} - - + 新增资源 - - } - className="lg:col-span-2" - > -
- {["all", "module", "entity", "field", "menu"].map((t) => ( - - ))} -
- - - - - - - - - - - - {filteredI18ns.length === 0 && } - {filteredI18ns.map((i) => ( - - - - - - - - ))} - -
b_key 资源 keyb_locale 语言b_value 文本b_i18ntype 类型操作
{i.b_key} - {i.b_locale} - - updateI18n(i.b_id, { b_value: e.target.value })} /> - - - - -
-

- 资源 key 全局唯一(b_key + b_locale 唯一约束);模块/实体/字段/菜单通过 b_i18n 引用这里的 key。 -

-
-
- ); -} diff --git a/code/test-demo/src/components/EntityPanel.tsx b/code/test-demo/src/components/EntityPanel.tsx deleted file mode 100644 index 9646f092..00000000 --- a/code/test-demo/src/components/EntityPanel.tsx +++ /dev/null @@ -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(2); // ocean - const [selectedEntityId, setSelectedEntityId] = useState(101); - const [expandedField, setExpandedField] = useState(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) => { - 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) => { - onChangeFields(fields.map((f) => (f.b_id === fid ? { ...f, ...patch } : f))); - }; - - return ( -
- {/* 左:模块 -> 实体列表 */} - - - - -
- {moduleEntities.map((e) => { - const active = e.b_id === selectedEntityId; - return ( - - ); - })} -
- -
- - {/* 右:实体详情 + 字段 */} - {selectedEntity ? ( -
- {/* 实体基本信息 */} - -
- - updateEntity({ b_code: e.target.value })} /> - - - updateEntity({ b_name: e.target.value })} /> - - - - - - updateEntity({ b_viewtable: e.target.value })} /> - - - updateEntity({ b_savetable: e.target.value || null })} - /> - - - updateEntity({ b_keyfield: e.target.value })} /> - - - - - - - - -
- updateEntity({ b_canuse: v ? 1 : 0 })} /> - {selectedEntity.b_canuse === 1 ? "启用" : "停用"} -
-
-
-
- -