diff --git a/code/fms/.playwright-cli/check-demo.mjs b/code/fms/.playwright-cli/check-demo.mjs new file mode 100644 index 00000000..609ce33a --- /dev/null +++ b/code/fms/.playwright-cli/check-demo.mjs @@ -0,0 +1,28 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) })) + +// demo page (no auth needed for ui demo probably, but mock anyway) +await page.goto(`${BASE}/demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(2500) + +const demo = await page.evaluate(() => { + const splitter = document.querySelector('.fms-splitter') + if (!splitter) return { error: 'no splitter on demo' } + const panels = Array.from(splitter.querySelectorAll(':scope > .fms-splitter-panel')).map((el) => Math.round(el.getBoundingClientRect().width)) + const bars = Array.from(splitter.querySelectorAll(':scope > .fms-splitter-bar')).map((el) => Math.round(el.getBoundingClientRect().width)) + return { containerW: Math.round(splitter.getBoundingClientRect().width), panels, bars } +}) +console.log(JSON.stringify(demo, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/check-filecard-css.mjs b/code/fms/.playwright-cli/check-filecard-css.mjs new file mode 100644 index 00000000..9531b827 --- /dev/null +++ b/code/fms/.playwright-cli/check-filecard-css.mjs @@ -0,0 +1,18 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage() + +// fetch compiled CSS for FileCard style module +const url = `${BASE}/src/components/fms-file/FileCard.vue?vue&type=style&index=0&lang.scss&t=${Date.now()}` +const r = await page.request.get(url) +const t = await r.text() +console.log('status', r.status(), 'len', t.length) +console.log('HAS popover-trigger rule:', t.includes('popover-trigger')) +const i = t.indexOf('popover-trigger') +console.log(t.slice(Math.max(0, i - 300), i + 600)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/check-module.mjs b/code/fms/.playwright-cli/check-module.mjs new file mode 100644 index 00000000..d203bf01 --- /dev/null +++ b/code/fms/.playwright-cli/check-module.mjs @@ -0,0 +1,18 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage() + +// fetch the served module source directly +const resp = await page.request.get(`${BASE}/src/components/ui/splitter/splitter.vue`) +const text = await resp.text() +console.log('STATUS', resp.status()) +console.log('HAS camelize:', text.includes('camelize')) +console.log('HAS default-size note:', text.includes('kebab 属性')) +console.log('----- first 1200 chars -----') +console.log(text.slice(0, 1200)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/check-module2.mjs b/code/fms/.playwright-cli/check-module2.mjs new file mode 100644 index 00000000..a40f408c --- /dev/null +++ b/code/fms/.playwright-cli/check-module2.mjs @@ -0,0 +1,25 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage() + +// The way Vite serves an SFC module from a browser import (with t= cache-busting) +const resp = await page.request.get(`${BASE}/src/components/ui/splitter/splitter.vue?t=${Date.now()}`) +const text = await resp.text() +console.log('STATUS', resp.status()) +console.log('HAS camelize:', text.includes('camelize')) +console.log('HAS kebab 属性 note:', text.includes('kebab 属性')) +console.log('LEN', text.length) +// also check DefaultLayout and FileCard served versions +for (const p of ['src/layouts/DefaultLayout.vue', 'src/components/fms-file/FileCard.vue']) { + const r2 = await page.request.get(`${BASE}/${p}?t=${Date.now()}`) + const t2 = await r2.text() + console.log(p, '-> status', r2.status(), 'len', t2.length, + 'has > .fms-splitter-bar:', t2.includes('> .fms-splitter-bar'), + 'has popover-trigger flex:', t2.includes('popover-trigger')) +} +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/check-module3.mjs b/code/fms/.playwright-cli/check-module3.mjs new file mode 100644 index 00000000..2b0d07c2 --- /dev/null +++ b/code/fms/.playwright-cli/check-module3.mjs @@ -0,0 +1,15 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage() + +const resp = await page.request.get(`${BASE}/src/components/ui/splitter/splitter.vue?t=${Date.now()}`) +const text = await resp.text() +const i = text.indexOf('panelItems') +console.log('--- around panelItems ---') +console.log(text.slice(Math.max(0, i - 400), i + 900)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/check-module4.mjs b/code/fms/.playwright-cli/check-module4.mjs new file mode 100644 index 00000000..7d8d1ce2 --- /dev/null +++ b/code/fms/.playwright-cli/check-module4.mjs @@ -0,0 +1,34 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage() + +// What does the browser actually get for each module? +const results = {} +for (const [name, p] of [ + ['splitter', 'src/components/ui/splitter/splitter.vue'], + ['defaultLayout', 'src/layouts/DefaultLayout.vue'], + ['fileCard', 'src/components/fms-file/FileCard.vue'], +]) { + const r = await page.request.get(`${BASE}/${p}?t=${Date.now()}`) + const t = await r.text() + results[name] = { + status: r.status(), + len: t.length, + hasCamelize: t.includes('camelize'), + hasChildSelector: t.includes('> .fms-splitter-bar'), + hasPopoverFlex: t.includes('.popover-trigger') && t.includes('flex: 1'), + snippet: t.slice(t.indexOf('panelItems') - 100, t.indexOf('panelItems') + 300), + } +} +// Also get the compiled CSS portion of DefaultLayout +const rl = await page.request.get(`${BASE}/src/layouts/DefaultLayout.vue?vue&type=style&index=0&lang.scss&t=${Date.now()}`) +const tl = await rl.text() +console.log('DefaultLayout style status', rl.status()) +console.log(tl.slice(0, 2000)) +console.log(JSON.stringify(results, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/check-module5.mjs b/code/fms/.playwright-cli/check-module5.mjs new file mode 100644 index 00000000..02749963 --- /dev/null +++ b/code/fms/.playwright-cli/check-module5.mjs @@ -0,0 +1,22 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage() + +// fetch from within the page context with cache bypass — mirrors what the browser module loader would get +const out = await page.evaluate(async () => { + const res = await fetch(`http://localhost:5082/src/components/ui/splitter/splitter.vue?t=${Date.now()}`, { cache: 'no-store' }) + const text = await res.text() + const i = text.indexOf('panelItems') + return { + status: res.status, + hasCamelize: text.includes('camelize'), + snippet: text.slice(i - 200, i + 400), + } +}) +console.log(JSON.stringify(out, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/debug-login.mjs b/code/fms/.playwright-cli/debug-login.mjs new file mode 100644 index 00000000..77754267 --- /dev/null +++ b/code/fms/.playwright-cli/debug-login.mjs @@ -0,0 +1,47 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 30000 }) +await page.waitForTimeout(1500) +console.log('URL after login page:', page.url()) + +// see what keys exist +const keys = await page.evaluate(() => { + const out = {} + for (let i = 0; i < sessionStorage.length; i++) { + const k = sessionStorage.key(i) + out[k] = sessionStorage.getItem(k) + } + return out +}) +console.log('sessionStorage:', JSON.stringify(keys, null, 2)) + +// Try setting auth directly in the live page +await page.evaluate(() => { + sessionStorage.setItem( + 'fms-login', + JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'admin' } } }), + ) +}) + +// navigate via router (SPA) — use history API so pinia store picks it up +await page.evaluate(() => { + window.location.href = '/file-demo' +}) +await page.waitForTimeout(4000) +console.log('URL after nav:', page.url()) + +const tree = await page.evaluate(() => { + const t = document.querySelector('.tree') + return { tree: !!t, bodyText: (document.body.innerText || '').slice(0, 200).replace(/\s+/g, ' ') } +}) +console.log('tree:', JSON.stringify(tree)) + +await page.screenshot({ path: '.playwright-cli/fmsfile-debug.png' }) +await browser.close() diff --git a/code/fms/.playwright-cli/fmsfile-1.png b/code/fms/.playwright-cli/fmsfile-1.png new file mode 100644 index 00000000..7eccc750 Binary files /dev/null and b/code/fms/.playwright-cli/fmsfile-1.png differ diff --git a/code/fms/.playwright-cli/fmsfile-2-dragged.png b/code/fms/.playwright-cli/fmsfile-2-dragged.png new file mode 100644 index 00000000..0e9bb7bb Binary files /dev/null and b/code/fms/.playwright-cli/fmsfile-2-dragged.png differ diff --git a/code/fms/.playwright-cli/fmsfile-3-list.png b/code/fms/.playwright-cli/fmsfile-3-list.png new file mode 100644 index 00000000..e549d66e Binary files /dev/null and b/code/fms/.playwright-cli/fmsfile-3-list.png differ diff --git a/code/fms/.playwright-cli/fmsfile-debug.png b/code/fms/.playwright-cli/fmsfile-debug.png new file mode 100644 index 00000000..2c15b2bb Binary files /dev/null and b/code/fms/.playwright-cli/fmsfile-debug.png differ diff --git a/code/fms/.playwright-cli/fmsfile-final.png b/code/fms/.playwright-cli/fmsfile-final.png new file mode 100644 index 00000000..d58b113b Binary files /dev/null and b/code/fms/.playwright-cli/fmsfile-final.png differ diff --git a/code/fms/.playwright-cli/fmsfile-grid.png b/code/fms/.playwright-cli/fmsfile-grid.png new file mode 100644 index 00000000..f954ef07 Binary files /dev/null and b/code/fms/.playwright-cli/fmsfile-grid.png differ diff --git a/code/fms/.playwright-cli/fmsfile-verified.png b/code/fms/.playwright-cli/fmsfile-verified.png new file mode 100644 index 00000000..371b6c28 Binary files /dev/null and b/code/fms/.playwright-cli/fmsfile-verified.png differ diff --git a/code/fms/.playwright-cli/inspect-ff-element.mjs b/code/fms/.playwright-cli/inspect-ff-element.mjs new file mode 100644 index 00000000..f5d61821 --- /dev/null +++ b/code/fms/.playwright-cli/inspect-ff-element.mjs @@ -0,0 +1,63 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { body = req.postDataJSON() || {} } catch {} + if (url.includes('/data/loaddata')) { + const rows = body.view_name === 's_module' ? [{ b_id: 1, b_code: 'b_port', b_name: '港口管理' }] : + body.view_name === 'bf_files_cateid' ? [{ b_id: '10', b_name: '合同文件' }] : [] + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: rows }) }) + } + if (url.includes('/data/page')) { + const files = Array.from({ length: 2 }, (_, i) => ({ + subid: String(1000 + i), father: '1001', mx_moduleid: '1', mx_cate_id: '10', + mx_filename: `文件-${i + 1}.pdf`, mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', mx_mapfilename: '', + })) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }) }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) }) +}) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3000) + +const result = await page.evaluate(() => { + const ff = document.querySelector('.ff-splitter') + if (!ff) return { error: 'no .ff-splitter' } + const out = { hasSplitterEl: true, cls: ff.className } + const inst = ff.__vueParentComponent + out.parentType = inst ? (inst.type?.__name || inst.type?.name || '(anon)') : null + out.parentProps = inst ? { ...(inst.props || {}) } : null + if (inst) { + const ss = inst.setupState + out.setupKeys = ss ? Object.keys(ss) : null + out.panelItems = ss?.panelItems?.value + ? ss.panelItems.value.map((p) => ({ props: p.props, keys: Object.keys(p.props || {}) })) + : null + out.innerSizes = ss?.innerSizes?.value ? [...ss.innerSizes.value] : null + out.containerSize = ss?.containerSize?.value ?? null + } + // also check children + const child = ff.firstElementChild + out.childCls = child ? child.className : null + const childInst = child?.__vueParentComponent + out.childType = childInst ? (childInst.type?.__name || childInst.type?.name || '(anon)') : null + return out +}) + +console.log(JSON.stringify(result, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/inspect-ff-state2.mjs b/code/fms/.playwright-cli/inspect-ff-state2.mjs new file mode 100644 index 00000000..f962a370 --- /dev/null +++ b/code/fms/.playwright-cli/inspect-ff-state2.mjs @@ -0,0 +1,68 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { body = req.postDataJSON() || {} } catch {} + if (url.includes('/data/loaddata')) { + const rows = body.view_name === 's_module' ? [{ b_id: 1, b_code: 'b_port', b_name: '港口管理' }] : + body.view_name === 'bf_files_cateid' ? [{ b_id: '10', b_name: '合同文件' }] : [] + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: rows }) }) + } + if (url.includes('/data/page')) { + const files = Array.from({ length: 2 }, (_, i) => ({ + subid: String(1000 + i), father: '1001', mx_moduleid: '1', mx_cate_id: '10', + mx_filename: `文件-${i + 1}.pdf`, mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', mx_mapfilename: '', + })) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }) }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) }) +}) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3000) + +const result = await page.evaluate(() => { + const ff = document.querySelector('.ff-splitter') + if (!ff) return { error: 'no .ff-splitter' } + const inst = ff.__vueParentComponent + if (!inst) return { error: 'no parent instance' } + const ss = inst.setupState + const out = { type: inst.type?.__name || inst.type?.name } + // Vue 3.5: setupState proxy unwraps refs. Try both shapes. + const raw = ss.panelItems + const pi = Array.isArray(raw) ? raw : raw?.value + out.panelItemsShape = Array.isArray(raw) ? 'unwrapped-array' : (raw && 'value' in raw) ? 'ref' : typeof raw + out.panelItems = Array.isArray(pi) + ? pi.map((p) => ({ props: p.props, keys: Object.keys(p.props || {}), nodeType: typeof p.node })) + : null + const is = ss.innerSizes + out.innerSizes = Array.isArray(is) ? [...is] : is?.value ? [...is.value] : null + const cs = ss.containerSize + out.containerSize = typeof cs === 'number' ? cs : cs?.value ?? null + // also dump displaySize results + const ds = ss.displaySize + out.displaySizeType = typeof ds + if (typeof ds === 'function') { + try { out.displayResults = [ds(0), ds(1), ds(2)] } catch (e) { out.displayError = String(e) } + } else if (ds && typeof ds.value === 'function') { + try { out.displayResults = [ds.value(0), ds.value(1), ds.value(2)] } catch (e) { out.displayError = String(e) } + } + return out +}) + +console.log(JSON.stringify(result, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/inspect-fmsfile.mjs b/code/fms/.playwright-cli/inspect-fmsfile.mjs new file mode 100644 index 00000000..8c80ad5e --- /dev/null +++ b/code/fms/.playwright-cli/inspect-fmsfile.mjs @@ -0,0 +1,140 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const out = {} +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) +const logs = [] +page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`)) +page.on('pageerror', (e) => logs.push(`[pageerror] ${e.message}`)) + +// bypass auth: inject sessionStorage token before app boot +await page.addInitScript(() => { + sessionStorage.setItem( + 'fms-login', + JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev' } } }), + ) +}) + +try { + // first load the app (redirects to /dashboard) + await page.goto(BASE, { waitUntil: 'networkidle', timeout: 30000 }) + await page.waitForTimeout(1000) + // now navigate to file-demo + await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) + await page.waitForTimeout(3000) + + out.url = page.url() + + // screenshot full page + await page.screenshot({ path: '.playwright-cli/fmsfile-1.png', fullPage: false }) + + // find the splitter panels + const panels = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('.fms-splitter-panel')) + return els.map((el, i) => ({ + i, + w: el.getBoundingClientRect().width, + h: el.getBoundingClientRect().height, + cls: el.className, + text: (el.textContent || '').slice(0, 60).replace(/\s+/g, ' '), + })) + }) + out.panels = panels + + // find splitter bars + const bars = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('.fms-splitter-bar')) + return els.map((el, i) => { + const r = el.getBoundingClientRect() + return { i, x: r.x, y: r.y, w: r.width, h: r.height, cls: el.className } + }) + }) + out.bars = bars + + // check tree width & min-width + const treeInfo = await page.evaluate(() => { + const tree = document.querySelector('.tree') + const panel = document.querySelector('.fc-panel') + return { + treeExists: !!tree, + treeRect: tree ? { w: tree.getBoundingClientRect().width, h: tree.getBoundingClientRect().height } : null, + treeMinWidth: tree ? getComputedStyle(tree).minWidth : null, + panelRect: panel ? { w: panel.getBoundingClientRect().width } : null, + } + }) + out.treeInfo = treeInfo + + // try dragging first bar right by +80px and check panel widths + if (bars.length) { + const b = bars[0] + const cx = b.x + b.w / 2 + const cy = b.y + b.h / 2 + await page.mouse.move(cx, cy) + await page.mouse.down() + await page.mouse.move(cx + 80, cy, { steps: 8 }) + await page.mouse.up() + await page.waitForTimeout(600) + const after = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('.fms-splitter-panel')) + return els.map((el, i) => ({ i, w: Math.round(el.getBoundingClientRect().width) })) + }) + out.afterDragBar0 = after + } + + // now drag first bar left by -120px + if (bars.length) { + const b = await page.evaluate(() => { + const el = document.querySelector('.fms-splitter-bar') + const r = el.getBoundingClientRect() + return { x: r.x, w: r.width, y: r.y, h: r.height } + }) + const cx = b.x + b.w / 2 + const cy = b.y + b.h / 2 + await page.mouse.move(cx, cy) + await page.mouse.down() + await page.mouse.move(cx - 120, cy, { steps: 8 }) + await page.mouse.up() + await page.waitForTimeout(600) + const after = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('.fms-splitter-panel')) + return els.map((el, i) => ({ i, w: Math.round(el.getBoundingClientRect().width) })) + }) + out.afterDragBar0Left = after + } + + // list-mode table: check action column widths + const listInfo = await page.evaluate(() => { + const ths = Array.from(document.querySelectorAll('.fl-table th')).map((th) => ({ + text: (th.textContent || '').trim(), + w: Math.round(th.getBoundingClientRect().width), + })) + const actionCells = Array.from(document.querySelectorAll('.fl-table td.is-center:last-child, .fl-table td .fl-actions')) + return { + headerCols: ths, + actionCellCount: actionCells.length, + firstActionCellW: actionCells[0] ? Math.round(actionCells[0].getBoundingClientRect().width) : null, + } + }) + out.listInfo = listInfo + + // check fl-footer bar + const footerInfo = await page.evaluate(() => { + const footer = document.querySelector('.fl-footer__bar') + const btns = Array.from(document.querySelectorAll('.fl-footer__btns .button')).map((b) => ({ + text: (b.textContent || '').trim(), + w: Math.round(b.getBoundingClientRect().width), + })) + return { exists: !!footer, btns } + }) + out.footerInfo = footerInfo +} catch (e) { + out.error = String(e) +} + +out.logs = logs +console.log(JSON.stringify(out, null, 2)) +await browser.close() diff --git a/code/fms/.playwright-cli/inspect-fmsfile2.mjs b/code/fms/.playwright-cli/inspect-fmsfile2.mjs new file mode 100644 index 00000000..97d4e233 --- /dev/null +++ b/code/fms/.playwright-cli/inspect-fmsfile2.mjs @@ -0,0 +1,191 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) +const out = { logs: [] } +page.on('console', (m) => { + if (m.type() === 'error' || m.type() === 'warning') out.logs.push(`[${m.type()}] ${m.text()}`) +}) +page.on('pageerror', (e) => out.logs.push(`[pageerror] ${e.message}`)) + +// 1) inject fake auth before app boot +await page.addInitScript(() => { + sessionStorage.setItem( + 'fms-login', + JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } }), + ) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) + +// 2) mock every /api call so the fake token is never validated by the real backend +const mockData = { + s_module: [ + { b_id: 1, b_code: 'b_port', b_name: '港口管理', b_module_type: 'data', b_canuse: 1, b_parent_id: null }, + { b_id: 2, b_code: 'b_file', b_name: '附件管理', b_module_type: 'data', b_canuse: 1, b_parent_id: null }, + ], + bf_files_cateid: [ + { b_id: '10', b_name: '合同文件', b_code: 'cate_contract', b_xh: 1, b_canuse: 1 }, + { b_id: '11', b_name: '验收资料', b_code: 'cate_accept', b_xh: 2, b_canuse: 1 }, + { b_id: '12', b_name: '日常管理文件(长名称分类测试)', b_code: 'cate_daily', b_xh: 3, b_canuse: 1 }, + ], +} +const files = Array.from({ length: 12 }, (_, i) => ({ + subid: String(1000 + i), + father: '1001', + mx_moduleid: '1', + mx_cate_id: i % 3 === 0 ? '10' : i % 3 === 1 ? '11' : '12', + mx_filename: `项目文件-${i + 1}-合同附件扫描件.pdf`, + mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', + mx_mapfilename: '', +})) + +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { + body = req.postDataJSON() || {} + } catch {} + if (url.includes('/auth/login')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { token: 'dev-token', orgid: 'G3HD', user: { id: 1, account: 'g3soft', name: 'dev' } }, + }), + }) + } + if (url.includes('/data/loaddata')) { + const rows = mockData[body.view_name] || [] + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, data: rows }), + }) + } + if (url.includes('/data/page')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }), + }) + } + if (url.includes('/file/config')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, data: { storageType: 'local' } }), + }) + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, data: null }), + }) +}) + +try { + await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) + await page.waitForTimeout(3000) + out.url = page.url() + + await page.screenshot({ path: '.playwright-cli/fmsfile-1.png' }) + + // splitter panels + out.panels = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('.fms-splitter-panel')) + return els.map((el, i) => ({ + i, + w: Math.round(el.getBoundingClientRect().width), + h: Math.round(el.getBoundingClientRect().height), + text: (el.textContent || '').slice(0, 40).replace(/\s+/g, ' '), + })) + }) + out.bars = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('.fms-splitter-bar')) + return els.map((el, i) => { + const r = el.getBoundingClientRect() + return { i, x: Math.round(r.x), w: Math.round(r.width), y: Math.round(r.y), h: Math.round(r.height) } + }) + }) + + // tree info + out.treeInfo = await page.evaluate(() => { + const tree = document.querySelector('.tree') + const panel = document.querySelector('.fc-panel') + const nodes = document.querySelectorAll('.tree-node') + return { + tree: !!tree, + treeW: tree ? Math.round(tree.getBoundingClientRect().width) : null, + treeMinW: tree ? getComputedStyle(tree).minWidth : null, + panelW: panel ? Math.round(panel.getBoundingClientRect().width) : null, + nodeCount: nodes.length, + } + }) + + // drag test: bar 0 → right +120 + if (out.bars.length) { + const b = out.bars[0] + const cx = b.x + b.w / 2 + const cy = b.y + b.h / 2 + await page.mouse.move(cx, cy) + await page.mouse.down() + await page.mouse.move(cx + 120, cy, { steps: 10 }) + await page.mouse.up() + await page.waitForTimeout(500) + out.afterDragRight = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('.fms-splitter-panel')) + return els.map((el, i) => ({ i, w: Math.round(el.getBoundingClientRect().width) })) + }) + await page.screenshot({ path: '.playwright-cli/fmsfile-2-dragged.png' }) + } + + // switch to list view + const listBtn = page.locator('.fl-header__modes .button', { hasText: '' }).nth(1) + try { + await listBtn.click({ timeout: 3000 }) + await page.waitForTimeout(800) + out.listMode = await page.evaluate(() => { + const table = document.querySelector('.fl-table') + if (!table) return { exists: false } + const ths = Array.from(table.querySelectorAll('thead th')).map((th) => ({ + text: (th.textContent || '').trim(), + w: Math.round(th.getBoundingClientRect().width), + })) + const rows = Array.from(table.querySelectorAll('tbody tr')).slice(0, 4) + const rowCells = rows.map((tr) => + Array.from(tr.querySelectorAll('td')).map((td) => Math.round(td.getBoundingClientRect().width)), + ) + const actions = Array.from(table.querySelectorAll('.fl-actions')).slice(0, 4).map((el) => ({ + w: Math.round(el.getBoundingClientRect().width), + btnCount: el.querySelectorAll('.button').length, + })) + return { exists: true, ths, rowCells, actions } + }) + await page.screenshot({ path: '.playwright-cli/fmsfile-3-list.png' }) + } catch (e) { + out.listModeError = String(e) + } + + // grid card footer check + out.cardFooter = await page.evaluate(() => { + const cards = Array.from(document.querySelectorAll('.fcard')) + if (!cards.length) return { count: 0 } + const footers = Array.from(document.querySelectorAll('.fcard__footer')).slice(0, 4).map((el) => { + const btns = Array.from(el.querySelectorAll('.button')).map((b) => Math.round(b.getBoundingClientRect().width)) + return { w: Math.round(el.getBoundingClientRect().width), btns } + }) + return { count: cards.length, footers } + }) +} catch (e) { + out.error = String(e) +} + +console.log(JSON.stringify(out, null, 2)) +await browser.close() diff --git a/code/fms/.playwright-cli/inspect-fmsfile3.mjs b/code/fms/.playwright-cli/inspect-fmsfile3.mjs new file mode 100644 index 00000000..e1c0674f --- /dev/null +++ b/code/fms/.playwright-cli/inspect-fmsfile3.mjs @@ -0,0 +1,83 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) + +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { body = req.postDataJSON() || {} } catch {} + if (url.includes('/auth/login')) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { token: 'dev-token', orgid: 'G3HD', user: { id: 1, account: 'g3soft', name: 'dev' } } }) }) + if (url.includes('/data/loaddata')) { + const rows = body.view_name === 's_module' ? [{ b_id: 1, b_code: 'b_port', b_name: '港口管理' }] : + body.view_name === 'bf_files_cateid' ? [ + { b_id: '10', b_name: '合同文件', b_code: 'cate_contract', b_xh: 1, b_canuse: 1 }, + { b_id: '11', b_name: '验收资料', b_code: 'cate_accept', b_xh: 2, b_canuse: 1 }, + ] : [] + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: rows }) }) + } + if (url.includes('/data/page')) { + const files = Array.from({ length: 5 }, (_, i) => ({ + subid: String(1000 + i), father: '1001', mx_moduleid: '1', + mx_cate_id: i % 2 === 0 ? '10' : '11', + mx_filename: `文件-${i + 1}.pdf`, mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', mx_mapfilename: '', + })) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }) }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) }) +}) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3000) + +// Get the ff-splitter panels' computed styles +const result = await page.evaluate(() => { + // Find the FmsFile splitter (ff-splitter) — the inner one + const ffSplitter = document.querySelector('.ff-splitter') + if (!ffSplitter) return { error: 'no ff-splitter' } + const panels = Array.from(ffSplitter.querySelectorAll('.fms-splitter-panel')) + return { + containerWidth: ffSplitter.getBoundingClientRect().width, + panelCount: panels.length, + panels: panels.map((el, i) => { + const cs = getComputedStyle(el) + return { + i, + offsetW: Math.round(el.getBoundingClientRect().width), + offsetH: Math.round(el.getBoundingClientRect().height), + flexBasis: cs.flexBasis, + flexGrow: cs.flexGrow, + flexShrink: cs.flexShrink, + minWidth: cs.minWidth, + maxWidth: cs.maxWidth, + overflow: cs.overflow, + // Check if style attribute has flexBasis + styleAttr: el.getAttribute('style'), + } + }), + bars: Array.from(ffSplitter.querySelectorAll('.fms-splitter-bar')).map((el, i) => { + const r = el.getBoundingClientRect() + const cs = getComputedStyle(el) + return { + i, x: Math.round(r.x), w: Math.round(r.width), h: Math.round(r.height), + csWidth: cs.width, + csMargin: cs.margin, + csPosition: cs.position, + } + }), + } +}) + +console.log(JSON.stringify(result, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/inspect-footer.mjs b/code/fms/.playwright-cli/inspect-footer.mjs new file mode 100644 index 00000000..ccea84c8 --- /dev/null +++ b/code/fms/.playwright-cli/inspect-footer.mjs @@ -0,0 +1,74 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { body = req.postDataJSON() || {} } catch {} + if (url.includes('/data/loaddata')) { + const rows = body.view_name === 's_module' ? [{ b_id: 1, b_code: 'b_port', b_name: '港口管理' }] : + body.view_name === 'bf_files_cateid' ? [{ b_id: '10', b_name: '合同文件' }] : [] + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: rows }) }) + } + if (url.includes('/data/page')) { + const files = Array.from({ length: 4 }, (_, i) => ({ + subid: String(1000 + i), father: '1001', mx_moduleid: '1', mx_cate_id: '10', + mx_filename: `文件-${i + 1}.pdf`, mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', mx_mapfilename: '', + })) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }) }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) }) +}) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3500) + +const info = await page.evaluate(() => { + const card = document.querySelector('.fcard') + if (!card) return { error: 'no card' } + const footer = card.querySelector('.fcard__footer') + const btn0 = footer.querySelector(':scope > .button') + const trigger = footer.querySelector('.popover-trigger') + const btn2 = trigger?.querySelector('.button') + const gs = (el) => { + if (!el) return null + const cs = getComputedStyle(el) + return { + rectW: Math.round(el.getBoundingClientRect().width), + flex: cs.flex, + flexBasis: cs.flexBasis, + padding: cs.padding, + boxSizing: cs.boxSizing, + display: cs.display, + minWidth: cs.minWidth, + width: cs.width, + margin: cs.margin, + } + } + return { + footerW: Math.round(footer.getBoundingClientRect().width), + btn0: gs(btn0), + divider: gs(footer.querySelector(':scope > .fcard__divider')), + trigger: gs(trigger), + btn2: gs(btn2), + footerChildren: Array.from(footer.children).map((el) => ({ + cls: el.className, + w: Math.round(el.getBoundingClientRect().width), + flex: getComputedStyle(el).flex, + })), + } +}) +console.log(JSON.stringify(info, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/inspect-grid.mjs b/code/fms/.playwright-cli/inspect-grid.mjs new file mode 100644 index 00000000..edf04cd8 --- /dev/null +++ b/code/fms/.playwright-cli/inspect-grid.mjs @@ -0,0 +1,61 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { body = req.postDataJSON() || {} } catch {} + if (url.includes('/data/loaddata')) { + const rows = body.view_name === 's_module' ? [{ b_id: 1, b_code: 'b_port', b_name: '港口管理' }] : + body.view_name === 'bf_files_cateid' ? [{ b_id: '10', b_name: '合同文件' }] : [] + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: rows }) }) + } + if (url.includes('/data/page')) { + const files = Array.from({ length: 4 }, (_, i) => ({ + subid: String(1000 + i), father: '1001', mx_moduleid: '1', mx_cate_id: '10', + mx_filename: `文件-${i + 1}.pdf`, mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', mx_mapfilename: '', + })) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }) }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) }) +}) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3000) + +// grid view: measure card footers +const grid = await page.evaluate(() => { + const cards = Array.from(document.querySelectorAll('.fcard')) + return { + cardCount: cards.length, + footers: cards.slice(0, 3).map((card) => { + const footer = card.querySelector('.fcard__footer') + const btns = footer ? Array.from(footer.querySelectorAll('.button')) : [] + const wrappers = footer ? Array.from(footer.children).map((el) => ({ + tag: el.tagName, + cls: el.className, + w: Math.round(el.getBoundingClientRect().width), + })) : [] + return { + footerW: footer ? Math.round(footer.getBoundingClientRect().width) : null, + btns: btns.map((b) => ({ w: Math.round(b.getBoundingClientRect().width), cls: b.className })), + wrappers, + } + }), + } +}) +console.log(JSON.stringify(grid, null, 2)) +await page.screenshot({ path: '.playwright-cli/fmsfile-grid.png' }) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/inspect-panel-props.mjs b/code/fms/.playwright-cli/inspect-panel-props.mjs new file mode 100644 index 00000000..0ff7261a --- /dev/null +++ b/code/fms/.playwright-cli/inspect-panel-props.mjs @@ -0,0 +1,47 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) })) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3000) + +// Hook into the app: find SplitterPanel component instances and inspect what props they actually receive +const result = await page.evaluate(async () => { + // Vue 3 internal: traverse the root component instance to find SplitterPanel + const rootEl = document.querySelector('#app') + const root = rootEl?.__vue_app__?._instance + function findPanels(instance, acc = []) { + if (!instance) return acc + const name = instance.type?.__name || instance.type?.name || '' + if (name === 'SplitterPanel') { + acc.push({ + name, + props: instance.props ? { ...instance.props } : null, + parentName: instance.parent?.type?.__name || instance.parent?.type?.name || '', + }) + } + if (instance.subTree) walkVNode(instance.subTree, acc) + return acc + } + function walkVNode(vnode, acc) { + if (!vnode) return + if (vnode.component) findPanels(vnode.component, acc) + if (Array.isArray(vnode.children)) vnode.children.forEach((c) => walkVNode(c, acc)) + else if (vnode.children && typeof vnode.children === 'object') walkVNode(vnode.children, acc) + } + return findPanels(root) +}) + +console.log(JSON.stringify(result, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/inspect-panel-props2.mjs b/code/fms/.playwright-cli/inspect-panel-props2.mjs new file mode 100644 index 00000000..ceb324eb --- /dev/null +++ b/code/fms/.playwright-cli/inspect-panel-props2.mjs @@ -0,0 +1,50 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) })) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3000) + +// Walk all component instances and dump those whose subTree contains .fms-splitter-panel +const result = await page.evaluate(() => { + const out = [] + const seen = new Set() + function walk(instance, depth) { + if (!instance || seen.has(instance)) return + seen.add(instance) + const typeName = instance.type?.__name || instance.type?.name || '(anon)' + const sub = instance.subTree + let hasPanel = false + let propsCopy = null + const visit = (v) => { + if (!v) return + if (typeof v.type === 'object' && v.type?.__name === 'SplitterPanel') hasPanel = true + if (Array.isArray(v.children)) v.children.forEach(visit) + else if (v.children && typeof v.children === 'object' && !v.component) visit(v.children) + if (v.component) walk(v.component, depth + 1) + } + if (sub) visit(sub) + // capture all refs to splitter panel instances + if (hasPanel) { + propsCopy = instance.props ? { ...instance.props } : null + out.push({ typeName, depth, props: propsCopy, keys: instance.props ? Object.keys(instance.props) : [] }) + } + } + const app = document.querySelector('#app')?.__vue_app__ + walk(app?._instance, 0) + return out +}) + +console.log(JSON.stringify(result, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/inspect-panel-props3.mjs b/code/fms/.playwright-cli/inspect-panel-props3.mjs new file mode 100644 index 00000000..304ed1bd --- /dev/null +++ b/code/fms/.playwright-cli/inspect-panel-props3.mjs @@ -0,0 +1,50 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) })) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3000) + +const result = await page.evaluate(() => { + const out = { components: [] } + const seen = new Set() + function walk(instance) { + if (!instance || seen.has(instance)) return + seen.add(instance) + const sub = instance.subTree + if (sub) visit(sub) + } + function visit(v) { + if (!v) return + if (v.component) { + const p = v.component.props + if (p && (p.defaultSize !== undefined || p['default-size'] !== undefined || p.default_size !== undefined)) { + out.components.push({ + name: v.type?.__name || v.type?.name || '(anon)', + props: { ...p }, + propKeys: Object.keys(p), + }) + } + walk(v.component) + } + if (Array.isArray(v.children)) v.children.forEach(visit) + else if (v.children && typeof v.children === 'object' && !v.component) visit(v.children) + } + const app = document.querySelector('#app')?.__vue_app__ + walk(app?._instance) + return out +}) + +console.log(JSON.stringify(result, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/inspect-splitter-state.mjs b/code/fms/.playwright-cli/inspect-splitter-state.mjs new file mode 100644 index 00000000..e3542050 --- /dev/null +++ b/code/fms/.playwright-cli/inspect-splitter-state.mjs @@ -0,0 +1,74 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { body = req.postDataJSON() || {} } catch {} + if (url.includes('/data/loaddata')) { + const rows = body.view_name === 's_module' ? [{ b_id: 1, b_code: 'b_port', b_name: '港口管理' }] : + body.view_name === 'bf_files_cateid' ? [{ b_id: '10', b_name: '合同文件' }] : [] + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: rows }) }) + } + if (url.includes('/data/page')) { + const files = Array.from({ length: 2 }, (_, i) => ({ + subid: String(1000 + i), father: '1001', mx_moduleid: '1', mx_cate_id: '10', + mx_filename: `文件-${i + 1}.pdf`, mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', mx_mapfilename: '', + })) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }) }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) }) +}) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3000) + +const result = await page.evaluate(() => { + const out = { splitters: [] } + const seen = new Set() + function walk(instance) { + if (!instance || seen.has(instance)) return + seen.add(instance) + const typeName = instance.type?.__name || instance.type?.name || '' + if (typeName === 'Splitter') { + const ss = instance.setupState + const items = ss?.panelItems?.value + out.splitters.push({ + props: { ...(instance.props || {}) }, + panelItems: items + ? items.map((p) => ({ + props: p.props, + propKeys: Object.keys(p.props || {}), + })) + : null, + innerSizes: ss?.innerSizes?.value ? [...ss.innerSizes.value] : null, + containerSize: ss?.containerSize?.value ?? null, + }) + } + const sub = instance.subTree + if (sub) visit(sub, 0) + } + function visit(v, depth) { + if (!v) return + if (v.component) walk(v.component) + if (Array.isArray(v.children)) v.children.forEach((c) => visit(c, depth + 1)) + else if (v.children && typeof v.children === 'object' && !v.component) visit(v.children, depth + 1) + } + walk(document.querySelector('#app')?.__vue_app__?._instance) + return out +}) + +console.log(JSON.stringify(result, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/inspect-vue-root.mjs b/code/fms/.playwright-cli/inspect-vue-root.mjs new file mode 100644 index 00000000..dc5d4214 --- /dev/null +++ b/code/fms/.playwright-cli/inspect-vue-root.mjs @@ -0,0 +1,60 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { body = req.postDataJSON() || {} } catch {} + if (url.includes('/data/loaddata')) { + const rows = body.view_name === 's_module' ? [{ b_id: 1, b_code: 'b_port', b_name: '港口管理' }] : + body.view_name === 'bf_files_cateid' ? [{ b_id: '10', b_name: '合同文件' }] : [] + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: rows }) }) + } + if (url.includes('/data/page')) { + const files = Array.from({ length: 2 }, (_, i) => ({ + subid: String(1000 + i), father: '1001', mx_moduleid: '1', mx_cate_id: '10', + mx_filename: `文件-${i + 1}.pdf`, mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', mx_mapfilename: '', + })) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }) }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) }) +}) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3000) + +const result = await page.evaluate(() => { + const appEl = document.querySelector('#app') + const out = { + appKeys: appEl ? Object.keys(appEl) : null, + hasVueApp: !!(appEl && appEl.__vue_app__), + vueAppKeys: appEl?.__vue_app__ ? Object.keys(appEl.__vue_app__) : null, + rootInstanceType: appEl?.__vue_app__?._instance?.type?.__name || appEl?.__vue_app__?._instance?.type?.name || null, + } + // also try global getCurrentInstance style: collect all elements with __vueParentComponent + const found = [] + document.querySelectorAll('*').forEach((el) => { + if (found.length >= 5) return + const inst = el.__vueParentComponent + if (inst && (inst.type?.__name === 'Splitter' || inst.type?.name === 'Splitter')) { + found.push({ tag: el.tagName, cls: el.className, props: { ...(inst.props || {}) } }) + } + }) + out.foundSplitters = found + return out +}) + +console.log(JSON.stringify(result, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/verify-fixes.mjs b/code/fms/.playwright-cli/verify-fixes.mjs new file mode 100644 index 00000000..934b6457 --- /dev/null +++ b/code/fms/.playwright-cli/verify-fixes.mjs @@ -0,0 +1,102 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { body = req.postDataJSON() || {} } catch {} + if (url.includes('/data/loaddata')) { + const rows = body.view_name === 's_module' ? [{ b_id: 1, b_code: 'b_port', b_name: '港口管理' }] : + body.view_name === 'bf_files_cateid' ? [{ b_id: '10', b_name: '合同文件' }] : [] + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: rows }) }) + } + if (url.includes('/data/page')) { + const files = Array.from({ length: 4 }, (_, i) => ({ + subid: String(1000 + i), father: '1001', mx_moduleid: '1', mx_cate_id: '10', + mx_filename: `文件-${i + 1}.pdf`, mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', mx_mapfilename: '', + })) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }) }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) }) +}) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3500) + +// ---- Issue 1+2: inner splitter panels & bars ---- +const splitterInfo = await page.evaluate(() => { + const ff = document.querySelector('.ff-splitter') + if (!ff) return { error: 'no .ff-splitter' } + const rect = ff.getBoundingClientRect() + const panels = Array.from(ff.querySelectorAll(':scope > .fms-splitter-panel')).map((el) => ({ + w: Math.round(el.getBoundingClientRect().width), + style: el.getAttribute('style') || '(none)', + cs: getComputedStyle(el).flex, + })) + const bars = Array.from(ff.querySelectorAll(':scope > .fms-splitter-bar')).map((el) => ({ + w: Math.round(el.getBoundingClientRect().width), + cs: getComputedStyle(el).width, + visible: el.getBoundingClientRect().width > 0, + })) + return { containerW: Math.round(rect.width), panels, bars } +}) + +// ---- Issue 1: tree panel width ---- +const treeInfo = await page.evaluate(() => { + const tree = document.querySelector('.ff-splitter .tree, .ff-splitter .fms-tree, .ff-splitter [class*="tree"]') + return tree ? { cls: tree.className, w: Math.round(tree.getBoundingClientRect().width) } : null +}) + +// ---- Issue 3: grid card footers ---- +const gridInfo = await page.evaluate(() => { + const cards = Array.from(document.querySelectorAll('.fcard')) + return { + count: cards.length, + footers: cards.slice(0, 2).map((card) => { + const footer = card.querySelector('.fcard__footer') + const btns = footer ? Array.from(footer.querySelectorAll('.button')).map((b) => Math.round(b.getBoundingClientRect().width)) : [] + return { footerW: footer ? Math.round(footer.getBoundingClientRect().width) : null, btns } + }), + } +}) + +// ---- Drag test on inner bar ---- +let dragResult = null +try { + const bar = await page.locator('.ff-splitter > .fms-splitter-bar').first() + const bb = await bar.boundingBox() + if (bb) { + const before = await page.evaluate(() => { + const ps = Array.from(document.querySelectorAll('.ff-splitter > .fms-splitter-panel')).map((el) => Math.round(el.getBoundingClientRect().width)) + return ps + }) + await page.mouse.move(bb.x + bb.width / 2, bb.y + bb.height / 2) + await page.mouse.down() + await page.mouse.move(bb.x + bb.width / 2 + 60, bb.y + bb.height / 2, { steps: 12 }) + await page.mouse.up() + await page.waitForTimeout(500) + const after = await page.evaluate(() => { + const ps = Array.from(document.querySelectorAll('.ff-splitter > .fms-splitter-panel')).map((el) => Math.round(el.getBoundingClientRect().width)) + return ps + }) + dragResult = { before, after } + } +} catch (e) { + dragResult = { error: String(e) } +} + +console.log(JSON.stringify({ splitterInfo, treeInfo, gridInfo, dragResult }, null, 2)) +await page.screenshot({ path: '.playwright-cli/fmsfile-verified.png' }) +await browser.close() \ No newline at end of file diff --git a/code/fms/.playwright-cli/verify-layout.mjs b/code/fms/.playwright-cli/verify-layout.mjs new file mode 100644 index 00000000..da5f28c7 --- /dev/null +++ b/code/fms/.playwright-cli/verify-layout.mjs @@ -0,0 +1,78 @@ +import { chromium } from 'playwright-core' + +const BASE = 'http://localhost:5082' +const browser = await chromium.launch({ + headless: true, + executablePath: `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1232\\chrome-win64\\chrome.exe`, +}) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + +await page.addInitScript(() => { + sessionStorage.setItem('fms-login', JSON.stringify({ loginInfo: { token: 'dev-token', user: { id: 1, name: 'dev', account: 'g3soft' } } })) + localStorage.setItem('fms-user', JSON.stringify({ userInfo: { orgId: 'G3HD', account: 'g3soft' } })) +}) +await page.route('**/api/**', async (route) => { + const req = route.request() + const url = req.url() + let body = {} + try { body = req.postDataJSON() || {} } catch {} + if (url.includes('/data/loaddata')) { + const rows = body.view_name === 's_module' ? [{ b_id: 1, b_code: 'b_port', b_name: '港口管理' }] : + body.view_name === 'bf_files_cateid' ? [{ b_id: '10', b_name: '合同文件' }] : [] + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: rows }) }) + } + if (url.includes('/data/page')) { + const files = Array.from({ length: 4 }, (_, i) => ({ + subid: String(1000 + i), father: '1001', mx_moduleid: '1', mx_cate_id: '10', + mx_filename: `文件-${i + 1}.pdf`, mx_filesize: (i + 1) * 1024 * 1024, + mx_fileext: 'pdf', mx_mapfilename: '', + })) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { rows: files, total: files.length } }) }) + } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: null }) }) +}) + +await page.goto(`${BASE}/file-demo`, { waitUntil: 'networkidle', timeout: 30000 }) +await page.waitForTimeout(3500) + +// 1) Layout sidebar bar still visible & draggable? +const layout = await page.evaluate(() => { + const layoutSplitter = document.querySelector('.fms-layout-splitter') + const bars = layoutSplitter ? Array.from(layoutSplitter.querySelectorAll(':scope > .fms-splitter-bar')) : [] + const panels = layoutSplitter ? Array.from(layoutSplitter.querySelectorAll(':scope > .fms-splitter-panel')) : [] + return { + barCount: bars.length, + barWidths: bars.map((b) => Math.round(b.getBoundingClientRect().width)), + panelWidths: panels.map((p) => Math.round(p.getBoundingClientRect().width)), + } +}) + +// drag the layout sidebar bar +let layoutDrag = null +try { + const bar = await page.locator('.fms-layout-splitter > .fms-splitter-bar').first() + const bb = await bar.boundingBox() + if (bb) { + const before = await page.evaluate(() => Math.round(document.querySelector('.fms-layout-splitter > .fms-splitter-panel').getBoundingClientRect().width)) + await page.mouse.move(bb.x + bb.width / 2, bb.y + bb.height / 2) + await page.mouse.down() + await page.mouse.move(bb.x + bb.width / 2 + 80, bb.y + bb.height / 2, { steps: 12 }) + await page.mouse.up() + await page.waitForTimeout(500) + const after = await page.evaluate(() => Math.round(document.querySelector('.fms-layout-splitter > .fms-splitter-panel').getBoundingClientRect().width)) + layoutDrag = { before, after } + } +} catch (e) { + layoutDrag = { error: String(e) } +} + +// 2) Switch to list view & check action column +const listInfo = await page.evaluate(() => { + // find list toggle (if any) - click buttons with title/icon + const btns = Array.from(document.querySelectorAll('.fl-toolbar button, .fms-file .button, .fl-header .button')) + return { toolbarBtns: btns.length } +}) +await page.screenshot({ path: '.playwright-cli/fmsfile-final.png' }) + +console.log(JSON.stringify({ layout, layoutDrag, listInfo }, null, 2)) +await browser.close() \ No newline at end of file diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md index 75e4a222..bea845a4 100644 --- a/code/fms/FMS新系统核心表结构设计.md +++ b/code/fms/FMS新系统核心表结构设计.md @@ -1,17 +1,17 @@ -# FMS 新系统模块区核心表结构设计 +# FMS 新系统模块核心表结构设计 -> 本方案为模块元数据中心的重新设计,表名与字段名命名风格参考旧系统: +> 本方案沿用旧系统以 `s_module` 为核心的元数据模型,删除独立的 `s_entity`;分类节点、数据模块和查询模块统一由 `s_module` 自关联树表达。 > 平台表以 `s_` 开头,字段以 `b_` 开头,复合词采用驼峰拼接(如 `b_viewtable`、`b_inputdatetime`),逻辑关联字段使用 `b_xxx_id`。 ## 1. 设计原则 -针对旧系统 `s_module` 职责混杂的问题,本方案坚持五项原则: +本方案坚持五项原则: -1. **模块与实体分离**:`s_module` 只表达业务能力边界与依赖关系,不承载表、路由、菜单;数据归属由 `s_entity` 承担。 -2. **实体与字段分离**:实体是数据所有者,声明查询源/保存源/主键字段;字段挂实体,同时携带数据库列定义与 UI 行为。 -3. **菜单与模块分离**:`s_menu` 是纯导航树,只引用实体或固定页面,不掺入数据源配置。 -4. **字段定义与视图配置合并**:旧系统把字段拆成 `s_module_field` + `_edit` + `_list` + `_query` 四张表,维护极易失配;本方案合并为单表 `s_field`,用标志列表达列表/表单/查询行为。 -5. **配置入口与存储职责分离**:管理员始终从菜单树定位页面,在一个配置工作台内完成数据源、字段、列表、表单、查询、编码和权限配置;底层数据仍按模块、实体、菜单和权限分别存储。 +1. **沿用模块主体**:`s_module` 继续作为旧系统的数据配置主体,分类节点和数据节点在同一棵树中维护,不再增加 `s_entity`。 +2. **模块与字段分离**:`data/query` 模块声明查询源、保存源和主键字段;字段挂模块保存数据库列定义,列表、表单、查询行为由字段配置子表承载。 +3. **菜单与模块分离**:`s_menu` 是纯导航树,只引用数据模块或固定页面;本方案只恢复旧系统的模块数据主体,不把菜单、路由和角色授权重新塞回 `s_module`。 +4. **字段定义与视图配置分责**:字段定义、列表、表单、查询是四类不同职责;本方案保留 `s_field` 作为字段定义表,并将列表、表单、查询行为分别放入 `s_field_list`、`s_field_edit`、`s_field_query`,避免一张宽表充满互不相关的空字段。 +5. **配置入口与存储职责分离**:管理员可从模块树或菜单树定位数据模块,在一个配置工作台内完成数据源、字段、列表、表单、查询、编码和权限配置;底层仍按模块、菜单和权限分别存储。 其余约定: @@ -20,47 +20,51 @@ - 布尔语义使用 `int`(0/1),与旧系统 `s_module` 风格一致。 - 时间使用 `datetime2`,默认 `sysdatetime()`。 - 审计字段:定义类表保留 `b_inputuser_id / b_inputdatetime / b_updateuser_id / b_updatedatetime`;关联类表只保留 `b_inputdatetime`。 -- 模块归属保留树形(`b_parent_id`,仅用于业务分组),模块间依赖使用独立的 DAG 表,两者语义互不混淆。 -- 菜单树是管理端的统一配置入口;模块树只用于业务归属和依赖管理,不要求管理员在两棵树之间来回切换。 -- 实体字段定义保留一套规范元数据;同一实体被多个菜单复用时,通过页面配置档保存菜单级布局和行为差异。 +- `s_module.b_parent_id` 只表达模块树位置;模块间真实的引用、主从、外键和级联关系由 `s_relation` 表达。 +- `b_module_type` 使用 `category/data/query`:分类节点不配置数据源,数据节点可查可存,查询节点只读。 +- 一个数据模块默认只有一套字段和页面配置;菜单只是模块的导航入口,不额外引入菜单级字段覆盖层。 ## 2. 表清单 | # | 表名 | 说明 | | --- | --- | --- | -| 1 | `s_module` | 模块(业务能力边界 + 业务分组树) | -| 2 | `s_module_dependency` | 模块依赖(DAG,禁止循环) | -| 3 | `s_entity` | 实体(数据所有者,声明查/存数据源) | -| 4 | `s_field` | 字段(列定义 + 列表/表单/查询行为合并单表) | -| 5 | `s_field_group` | 字段分组(实体级) | -| 6 | `s_relation` | 实体关系(引用/主子,驱动外键校验与级联) | -| 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` | 审计字段变更明细 | +| 1 | `s_module` | 模块树(分类/数据/查询节点,数据所有者) | +| 2 | `s_field` | 字段定义(数据库列与通用控件元数据) | +| 3 | `s_field_group` | 字段分组(模块级) | +| 4 | `s_field_list` | 字段列表配置(显示、排序、列宽、分组) | +| 5 | `s_field_edit` | 字段表单配置(显示、排序、分组、必填、只读、禁用) | +| 6 | `s_field_query` | 字段查询配置(启用、排序、组件、操作符、默认值) | +| 7 | `s_relation` | 模块关系(引用/主从,驱动外键校验与级联) | +| 8 | `s_menu` | 菜单/页面导航(data 菜单直接绑定数据模块) | +| 9 | `s_autocode` | 自动编码规则 | +| 10 | `s_role` | 角色 | +| 11 | `s_user_role` | 用户-角色 | +| 12 | `s_power` | 权限点(menu/module/action/field) | +| 13 | `s_role_power` | 角色-权限 | +| 14 | `s_i18n` | 多语言资源 | +| 15 | `s_log_technical` | 技术日志(请求链路/SQL/异常/性能) | +| 16 | `s_log_login` | 登录日志(登录/退出/失败/锁定) | +| 17 | `s_log_audit` | 业务审计事件 | +| 18 | `s_log_audit_field` | 审计字段变更明细 | ## 3. 各表结构 ### 3.1 s_module 模块 -只保存模块身份、业务分组与启用状态,不承载菜单、路由或数据源。 +沿用旧系统核心语义:`s_module` 既是模块树节点,也是数据配置主体。`category` 节点只负责分类;`data` 节点可查询和保存;`query` 节点只查询不保存。菜单、角色和权限仍由独立表维护。 ```sql create table s_module ( b_id bigint identity(1, 1) not null, -- 内部主键 - b_code varchar(50) not null, -- 模块编码(如 ocean/finance/master-data) + b_code varchar(50) not null, -- 模块编码(全局唯一) + b_parent_id bigint null, -- 父模块(配置树位置) b_name nvarchar(100) not null, -- 模块名称 b_i18n varchar(150) null, -- 显示名多语言 key - b_parent_id bigint null, -- 父模块(仅业务分组,不承担导航) - b_module_type varchar(20) not null default 'business', -- 类型:platform 平台/tenant 租户/business 业务 + b_module_type varchar(20) not null, -- category/data/query + b_viewtable varchar(128) null, -- 查询数据源(data/query 使用) + b_savetable varchar(128) null, -- 保存目标(data 可用,query 必须为空) + b_keyfield varchar(50) null, -- 主键字段(data/query 使用) + b_orderfield varchar(500) null, -- 默认排序表达式 + b_delete_policy varchar(20) null, -- restrict/lifecycle/cascade/hard b_canuse int not null default 1, -- 是否启用 b_xh int not null default 0, -- 排序号 b_icon varchar(50) null, -- 图标 @@ -71,71 +75,21 @@ create table s_module ( b_updatedatetime datetime2 null, constraint pk_s_module primary key (b_id), constraint ux_s_module_code unique (b_code), + constraint ck_s_module_type check (b_module_type in ('category', 'data', 'query')), + constraint ck_s_module_parent check (b_parent_id is null or b_parent_id <> b_id), constraint fk_s_module_parent foreign key (b_parent_id) references s_module (b_id) ); -create index ix_s_module_parent on s_module (b_parent_id, b_xh, b_canuse); +create index ix_s_module_parent on s_module (b_parent_id, b_xh, b_canuse, b_id); +create index ix_s_module_savetable on s_module (b_savetable); ``` -### 3.2 s_module_dependency 模块依赖 - -模块依赖必须形成有向无环图,由服务层校验禁止循环。 - -```sql -create table s_module_dependency ( - b_id bigint identity(1, 1) not null, -- 内部主键 - b_module_id bigint not null, -- 依赖方模块 - b_depend_module_id bigint not null, -- 被依赖模块 - b_inputdatetime datetime2 null default sysdatetime(), - constraint pk_s_module_dependency primary key (b_id), - constraint ux_s_module_dependency unique (b_module_id, b_depend_module_id), - constraint fk_s_module_dependency_module foreign key (b_module_id) references s_module (b_id), - constraint fk_s_module_dependency_depend foreign key (b_depend_module_id) references s_module (b_id) -); - -create index ix_s_module_dependency_depend on s_module_dependency (b_depend_module_id); -``` - -### 3.3 s_entity 实体 - -实体是数据所有者,同时声明查询数据源与保存数据源;主子表通过 `b_parent_id` 表达(与菜单树无关)。 - -```sql -create table s_entity ( - b_id bigint identity(1, 1) not null, -- 内部主键 - b_module_id bigint not null, -- 所属模块 - b_parent_id bigint null, -- 父实体(主子表关系) - b_code varchar(50) not null, -- 实体编码(如 ocean_booking) - b_name nvarchar(100) not null, -- 实体名称 - b_i18n varchar(150) null, -- 显示名多语言 key - b_viewtable varchar(128) null, -- 查询数据源:真实表或只读视图 - b_savetable varchar(128) null, -- 保存目标:真实表(纯查询实体可空) - b_keyfield varchar(50) null, -- 保存对象主键字段(如 b_id) - b_entity_type varchar(20) not null default 'table', -- table 简单实体/view 复杂列表/query 纯查询 - b_delete_policy varchar(20) not null default 'lifecycle', -- restrict/lifecycle/cascade/hard - b_canuse int not null default 1, -- 是否启用 - b_xh int not null default 0, -- 排序号 - b_bz nvarchar(1000) null, -- 描述 - b_inputuser_id bigint null, - b_inputdatetime datetime2 null default sysdatetime(), - b_updateuser_id bigint null, - b_updatedatetime datetime2 null, - constraint pk_s_entity primary key (b_id), - constraint ux_s_entity_module_code unique (b_module_id, b_code), - constraint fk_s_entity_module foreign key (b_module_id) references s_module (b_id), - constraint fk_s_entity_parent foreign key (b_parent_id) references s_entity (b_id) -); - -create index ix_s_entity_parent on s_entity (b_parent_id); -create index ix_s_entity_savetable on s_entity (b_savetable); -``` - -实体的查/存配置方式: +模块的查/存配置方式: ```text -简单实体: b_viewtable = b_savetable = 同一张真实表 -复杂列表: b_viewtable = 带关联/聚合的 SQL 视图(只读), b_savetable = 真实表 -纯查询实体: b_viewtable = 视图或表, b_savetable = 空(只读) +分类模块: b_module_type = category, 查/存字段全部为空 +数据模块: b_module_type = data, b_viewtable 为查询源, b_savetable 为保存表 +查询模块: b_module_type = query, b_viewtable 为查询源, b_savetable 必须为空 ``` 删除策略取值(`b_delete_policy`): @@ -147,14 +101,14 @@ cascade 主表拥有的从数据:随主表同一事务级联删除 hard 临时记录:校验权限后直接物理删除 ``` -### 3.4 s_field 字段 +### 3.2 s_field 字段 -合并旧系统 `s_module_field` + `_edit` + `_list` + `_query` 四张表,保存实体级默认定义。菜单页面的个性化显示、排序和交互覆盖放在 `s_page_field`,避免同一实体被多个菜单引用时互相影响。 +只保存字段本身的规范定义,不混入列表、表单和查询布局。字段同步、数据库列校验和业务审计均以本表为准;三类界面行为分别放在后续配置表中。 ```sql create table s_field ( b_id bigint identity(1, 1) not null, -- 内部主键 - b_entity_id bigint not null, -- 所属实体 + b_module_id bigint not null, -- 所属数据模块 b_field varchar(50) not null, -- 物理列名(与真实表列一致) b_name nvarchar(100) not null, -- 字段名称 b_i18n varchar(150) null, -- 显示名多语言 key @@ -165,93 +119,150 @@ create table s_field ( b_nullable int not null default 1, -- 数据库层是否可空 b_default_value nvarchar(500) null, -- 默认值(文本表达) b_options nvarchar(max) null, -- 下拉/单选选项(JSON 文本) - b_edittype varchar(30) null, -- 编辑组件:input/select/date/datetime/textarea/number/checkbox/radio - b_required int not null default 0, -- 编辑必填 - b_readonly int not null default 0, -- 编辑只读 - b_disabled int not null default 0, -- 编辑禁用 - b_listvisible int not null default 1, -- 是否显示在列表 - b_listwidth int null, -- 列表列宽(px) - b_queryable int not null default 0, -- 是否出现在查询条件 - b_querytype varchar(30) null, -- 查询组件:input/select/date - b_queryop varchar(20) null, -- 查询操作符:eq/like/gt/ge/lt/le/in/between - b_group_id bigint null, -- 所属分组(逻辑外键 s_field_group.b_id) - b_xh int not null default 0, -- 列表/表单排序 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_field primary key (b_id), - constraint ux_s_field_entity_field unique (b_entity_id, b_field), - constraint fk_s_field_entity foreign key (b_entity_id) references s_entity (b_id) + constraint ux_s_field_module_field unique (b_module_id, b_field), + constraint fk_s_field_module foreign key (b_module_id) references s_module (b_id) ); -create index ix_s_field_group on s_field (b_group_id, b_xh, b_canuse); +create index ix_s_field_module on s_field (b_module_id, b_canuse, b_id); ``` -写入模式由 `b_readonly` 与 `b_canuse` 组合表达;如需更细的写入语义(computed/actionOnly/system),可增加 `b_writemode varchar(20)` 列(direct/createOnly/readOnly/computed/actionOnly/system)。 +字段是否允许用户写入属于表单配置,由 `s_field_edit` 表达;如果需要声明数据库计算列、系统维护列等全局写入语义,可在本表增加 `b_writemode varchar(20)`(direct/createOnly/readOnly/computed/actionOnly/system)。 -### 3.5 s_field_group 字段分组 +### 3.3 s_field_group 字段分组 -分组是实体级独立定义,字段通过 `b_group_id` 引用,支持分组树。 +分组是模块级独立定义,支持分组树。列表和表单可以分别通过自己的配置表引用同一个分组,也可以选择不同分组。 ```sql create table s_field_group ( b_id bigint identity(1, 1) not null, -- 内部主键 - b_entity_id bigint not null, -- 所属实体 + b_module_id bigint not null, -- 所属数据模块 b_parent_id bigint null, -- 父分组(分组树) b_title nvarchar(100) not null, -- 分组标题 b_i18n varchar(150) null, -- 标题多语言 key b_xh int not null default 0, -- 分组排序 b_canuse int not null default 1, -- 是否启用(停用整组隐藏) constraint pk_s_field_group primary key (b_id), - constraint fk_s_field_group_entity foreign key (b_entity_id) references s_entity (b_id), + constraint fk_s_field_group_module foreign key (b_module_id) references s_module (b_id), constraint fk_s_field_group_parent foreign key (b_parent_id) references s_field_group (b_id) ); -create index ix_s_field_group_entity on s_field_group (b_entity_id, b_xh, b_canuse); +create index ix_s_field_group_module on s_field_group (b_module_id, b_xh, b_canuse); ``` -### 3.6 s_relation 实体关系 +### 3.4 s_field_list 字段列表配置 -描述实体间的引用与主子关系,运行时据此执行外键校验、主子事务与级联删除。 +保存字段在列表中的显示、顺序、宽度和分组。每个字段最多一条列表配置;未创建配置记录表示该字段不进入列表。 + +```sql +create table s_field_list ( + b_id bigint identity(1, 1) not null, + b_field_id bigint not null, -- 字段 + b_group_id bigint null, -- 列表分组 + b_i18n varchar(150) null, -- 列表标题多语言 key(空则继承字段) + b_visible int not null default 1, -- 是否显示 + b_xh int not null default 0, -- 列表排序 + b_width int null, -- 列宽(px) + constraint pk_s_field_list primary key (b_id), + constraint ux_s_field_list_field unique (b_field_id), + constraint fk_s_field_list_field foreign key (b_field_id) references s_field (b_id), + constraint fk_s_field_list_group foreign key (b_group_id) references s_field_group (b_id) +); + +create index ix_s_field_list_order on s_field_list (b_xh, b_id); +``` + +### 3.5 s_field_edit 字段表单配置 + +保存字段在新增、编辑表单中的布局和交互行为。控件类型属于表单行为,因此放在本表,而不是放在字段物理定义中。 + +```sql +create table s_field_edit ( + b_id bigint identity(1, 1) not null, + b_field_id bigint not null, -- 字段 + b_group_id bigint null, -- 表单分组 + b_component varchar(30) null, -- input/select/date/datetime/textarea/number/checkbox/radio + b_visible int not null default 1, -- 是否显示 + b_required int not null default 0, -- 是否必填 + b_readonly int not null default 0, -- 是否只读 + b_disabled int not null default 0, -- 是否禁用 + b_xh int not null default 0, -- 表单排序 + constraint pk_s_field_edit primary key (b_id), + constraint ux_s_field_edit_field unique (b_field_id), + constraint fk_s_field_edit_field foreign key (b_field_id) references s_field (b_id), + constraint fk_s_field_edit_group foreign key (b_group_id) references s_field_group (b_id) +); + +create index ix_s_field_edit_order on s_field_edit (b_xh, b_id); +``` + +数据库可空性以 `s_field.b_nullable` 为准,表单必填以 `s_field_edit.b_required` 为准。元数据校验器应保证:数据库不可空且无数据库默认值的可编辑字段,表单不能配置为非必填。 + +### 3.6 s_field_query 字段查询配置 + +查询条件是可选集合,只有需要进入查询区的字段才创建记录。查询组件、操作符和默认查询值独立于字段默认值。 + +```sql +create table s_field_query ( + b_id bigint identity(1, 1) not null, + b_field_id bigint not null, -- 字段 + b_component varchar(30) not null, -- input/select/date-picker/number-range/radio-group + b_operator varchar(20) not null, -- eq/like/gt/ge/lt/le/in/between + b_default_value nvarchar(500) null, -- 默认查询值 + b_canuse int not null default 1, -- 是否启用 + b_xh int not null default 0, -- 查询条件排序 + constraint pk_s_field_query primary key (b_id), + constraint ux_s_field_query_field unique (b_field_id), + constraint fk_s_field_query_field foreign key (b_field_id) references s_field (b_id) +); + +create index ix_s_field_query_order on s_field_query (b_canuse, b_xh, b_id); +``` + +### 3.7 s_relation 模块关系 + +描述模块间的引用与主子关系,运行时据此执行外键校验、主子事务与级联删除。 ```sql create table s_relation ( b_id bigint identity(1, 1) not null, -- 内部主键 - b_entity_id bigint not null, -- 主实体 - b_target_entity_id bigint not null, -- 目标实体 + b_module_id bigint not null, -- 主模块 + b_target_module_id bigint not null, -- 目标模块 b_relation_type varchar(20) not null, -- one-to-one/one-to-many/many-to-one/many-to-many b_foreign_field varchar(50) null, -- 子表侧外键字段 - b_link_field varchar(50) null, -- 主实体侧关联字段 + b_link_field varchar(50) null, -- 主模块侧关联字段 b_ownership varchar(20) not null default 'reference', -- reference 引用/owned 主从 b_delete_policy varchar(20) null, -- restrict/set-null/cascade/archive b_orderfield varchar(500) null, -- 子表排序字段表达式 b_canuse int not null default 1, -- 是否启用 b_xh int not null default 0, -- 排序号 constraint pk_s_relation primary key (b_id), - constraint fk_s_relation_entity foreign key (b_entity_id) references s_entity (b_id), - constraint fk_s_relation_target foreign key (b_target_entity_id) references s_entity (b_id) + constraint fk_s_relation_module foreign key (b_module_id) references s_module (b_id), + constraint fk_s_relation_target_module foreign key (b_target_module_id) references s_module (b_id) ); -create index ix_s_relation_target on s_relation (b_target_entity_id); +create index ix_s_relation_target on s_relation (b_target_module_id); ``` -### 3.7 s_menu 菜单/页面 +### 3.8 s_menu 菜单/页面 -数据层的菜单仍是导航树,不直接保存字段、列表或表单配置;管理端把它作为统一配置入口。通过 `b_module_id` 挂到模块做组织维度,通过 `b_entity_id` 引用实体;模块与菜单可互相独立演进(一个模块多个菜单入口、一个菜单也可属于无模块的公共区)。选中菜单后,配置工作台聚合展示其页面档、实体、字段、关联实体、自动编码和权限。 +菜单是独立导航树,不保存字段、列表或表单配置。`data` 菜单通过 `b_module_id` 绑定一个 `data/query` 模块,并直接使用该模块的字段、列表、表单和查询配置。 ```sql create table s_menu ( b_id bigint identity(1, 1) not null, -- 内部主键 b_parent_id bigint null, -- 父菜单(导航树自引用) - b_module_id bigint null, -- 所属模块(菜单的组织维度,可空) b_code varchar(50) not null, -- 菜单编码 b_name nvarchar(100) not null, -- 菜单名称 b_i18n varchar(150) null, -- 显示名多语言 key b_menu_type varchar(20) not null, -- directory/page/data/external b_route varchar(200) null, -- 固定页面路由或外链 - b_entity_id bigint null, -- data 页面绑定的实体 + b_module_id bigint null, -- data 页面绑定的数据/查询模块 b_power_code varchar(50) null, -- 页面访问权限编码(对应 s_power.b_code) b_icon varchar(50) null, -- 图标 b_canmenu int not null default 1, -- 是否显示在菜单 @@ -260,84 +271,27 @@ create table s_menu ( constraint pk_s_menu primary key (b_id), constraint ux_s_menu_code unique (b_code), constraint fk_s_menu_parent foreign key (b_parent_id) references s_menu (b_id), - constraint fk_s_menu_module foreign key (b_module_id) references s_module (b_id), - constraint fk_s_menu_entity foreign key (b_entity_id) references s_entity (b_id) + constraint fk_s_menu_module foreign key (b_module_id) references s_module (b_id) ); create index ix_s_menu_parent on s_menu (b_parent_id, b_xh, b_canuse); -create index ix_s_menu_entity on s_menu (b_entity_id); +create index ix_s_menu_module on s_menu (b_module_id); ``` -菜单类型约束由元数据校验器执行:`directory` 不填业务路由;`page` 必须填 `b_route`;`data` 必须填 `b_entity_id`;`external` 必须填 `b_route` 和 `b_power_code`。目录仅在存在至少一个可访问子页面时显示。 +菜单类型约束由元数据校验器执行:`directory` 不填业务路由;`page` 必须填 `b_route`;`data` 必须填 `b_module_id`;`external` 必须填 `b_route` 和 `b_power_code`。目录仅在存在至少一个可访问子页面时显示。 -### 3.8 s_page_profile 页面配置档 +配置工作台按以下顺序呈现:页面基本信息 → 绑定模块 → 字段与分组 → 列表 → 表单 → 查询 → 关联模块 → 自动编码 → 权限。保存时由服务层校验菜单类型、模块类型和模块关系。 -页面配置档是菜单入口与实体默认元数据之间的配置上下文。一个 `data` 菜单通常对应一个默认配置档;同一实体被多个菜单复用时,每个菜单可以拥有独立配置档。配置档不复制实体字段定义,只保存页面级行为。 +没有独立菜单的子模块、费用明细、选择器和公共字典,通过主模块的 `s_relation` 挂在所属数据模块下;它们不需要重复创建菜单,也可以在同一个配置工作台中维护。 -```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) -); -``` +### 3.9 s_autocode 自动编码 -配置工作台按以下顺序呈现:页面基本信息 → 数据源/实体 → 字段与分组 → 列表 → 表单 → 查询 → 关联实体 → 自动编码 → 权限。保存时由服务层校验菜单类型、实体类型和模块依赖关系。 - -没有独立菜单的子实体、费用明细、选择器和公共字典,通过主实体的 `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`),避免自增竞争。 +挂数据模块 + 目标字段,规则可复用。并发控制:应用层使用原子更新取号(`update ... set b_currentvalue = b_currentvalue + 1 output inserted.b_currentvalue`),避免自增竞争。 ```sql create table s_autocode ( b_id bigint identity(1, 1) not null, -- 内部主键 - b_entity_id bigint not null, -- 所属实体 + b_module_id bigint not null, -- 所属数据模块 b_field varchar(50) not null, -- 目标字段(物理列名) b_prefix nvarchar(100) not null default '', -- 前缀 b_dateformat varchar(30) not null default 'yyyyMM', -- 日期格式 @@ -349,12 +303,12 @@ create table s_autocode ( b_currentvalue bigint not null default 0, -- 当前流水值 b_canuse int not null default 1, -- 是否启用 constraint pk_s_autocode primary key (b_id), - constraint ux_s_autocode_entity_field unique (b_entity_id, b_field), - constraint fk_s_autocode_entity foreign key (b_entity_id) references s_entity (b_id) + constraint ux_s_autocode_module_field unique (b_module_id, b_field), + constraint fk_s_autocode_module foreign key (b_module_id) references s_module (b_id) ); ``` -### 3.11 s_role 角色 +### 3.10 s_role 角色 ```sql create table s_role ( @@ -373,7 +327,7 @@ create table s_role ( ); ``` -### 3.12 s_user_role 用户-角色 +### 3.11 s_user_role 用户-角色 ```sql create table s_user_role ( @@ -390,7 +344,7 @@ create table s_user_role ( create index ix_s_user_role_role on s_user_role (b_role_id, b_canuse); ``` -### 3.13 s_power 权限点 +### 3.12 s_power 权限点 ```sql create table s_power ( @@ -398,8 +352,8 @@ create table s_power ( b_code varchar(50) not null, -- 权限编码(如 booking.confirm) b_name nvarchar(100) not null, -- 权限名称 b_i18n varchar(150) null, -- 显示名多语言 key - b_power_type varchar(20) not null, -- menu/entity/action/field - b_resource varchar(128) null, -- 资源标识(菜单 code/实体 code/动作 code/字段) + b_power_type varchar(20) not null, -- menu/module/action/field + b_resource varchar(128) null, -- 资源标识(菜单 code/模块 code/动作 code/字段) b_canuse int not null default 1, -- 是否启用 b_xh int not null default 0, -- 排序号 constraint pk_s_power primary key (b_id), @@ -409,7 +363,7 @@ create table s_power ( create index ix_s_power_resource on s_power (b_power_type, b_resource, b_canuse); ``` -### 3.14 s_role_power 角色-权限 +### 3.13 s_role_power 角色-权限 ```sql create table s_role_power ( @@ -429,7 +383,7 @@ create index ix_s_role_power_power on s_role_power (b_power_id, b_canuse); 菜单显示继承页面访问权限:用户拥有 `s_menu.b_power_code` 对应权限时菜单才显示;前端只控制显示,后端运行时必须再次校验同一个权限编码。 -### 3.15 s_i18n 多语言 +### 3.14 s_i18n 多语言 ```sql create table s_i18n ( @@ -437,7 +391,7 @@ create table s_i18n ( b_key varchar(150) not null, -- 资源 key(如 booking.name) b_locale varchar(20) not null, -- 语言区域(如 zh-CN/en-US) b_value nvarchar(500) not null, -- 该语言下的显示文本 - b_i18ntype varchar(20) null, -- 资源类型:module/entity/field/menu/action + b_i18ntype varchar(20) null, -- 资源类型:module/field/menu/action b_canuse int not null default 1, -- 是否启用 constraint pk_s_i18n primary key (b_id), constraint ux_s_i18n_key_locale unique (b_key, b_locale) @@ -446,7 +400,7 @@ create table s_i18n ( create index ix_s_i18n_locale on s_i18n (b_locale, b_canuse); ``` -### 3.16 s_log_technical 技术日志 +### 3.15 s_log_technical 技术日志 用于请求链路、SQL、异常、性能与外部接口排查。高频日志推荐写入 Loki/OpenSearch/Elasticsearch 等日志平台,本表用于集中日志库或短期数据库兜底,不建议长期写入租户业务库。 @@ -479,7 +433,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.17 s_log_login 登录日志 +### 3.16 s_log_login 登录日志 记录登录成功、失败、退出、刷新令牌与账号锁定等安全事件,可通过 `b_request_id` / `b_trace_id` 与技术日志关联。 @@ -512,7 +466,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.18 业务审计事件与字段变更 +### 3.17 业务审计事件与字段变更 审计事件与字段变更分表存储:事件一条、字段变更多条,通过 `b_event_id` 关联。字段级明细是**面向客户的硬需求**(客户可在门户查看某个单号发生过哪些字段变更),**字段一旦发生修改即写入明细**,不按字段配置开关;`b_visibility` 控制内部/客户可见。 @@ -521,7 +475,6 @@ create table s_log_audit ( b_id bigint identity(1, 1) not null, -- 内部主键 b_event_code varchar(50) not null, -- 业务事件编码(如 booking.submit) b_module_code varchar(50) null, -- 模块编码 - b_entity_code varchar(50) null, -- 实体编码 b_data_id varchar(50) null, -- 被审计的业务数据 ID b_business_no varchar(50) null, -- 业务单号(如订舱号) b_operation varchar(100) null, -- 操作 key(如 saveObject.submit) @@ -536,7 +489,7 @@ create table s_log_audit ( constraint pk_s_log_audit primary key (b_id) ); -create index ix_s_log_audit_entity on s_log_audit (b_entity_code, b_data_id, b_occurdatetime desc); +create index ix_s_log_audit_module on s_log_audit (b_module_code, b_data_id, b_occurdatetime desc); create index ix_s_log_audit_trace on s_log_audit (b_trace_id); ``` @@ -546,7 +499,7 @@ create index ix_s_log_audit_trace on s_log_audit (b_trace_id); create table s_log_audit_field ( b_id bigint identity(1, 1) not null, -- 内部主键 b_event_id bigint not null, -- 所属审计事件 - b_entity_code varchar(50) null, -- 实体编码 + b_module_code varchar(50) null, -- 模块编码 b_data_id varchar(50) null, -- 业务数据 ID b_field varchar(50) not null, -- 变更字段(物理列名) b_field_label nvarchar(100) null, -- 字段标签快照 @@ -560,28 +513,29 @@ create table s_log_audit_field ( ); create index ix_s_log_audit_field_event on s_log_audit_field (b_event_id); -create index ix_s_log_audit_field_customer on s_log_audit_field (b_entity_code, b_data_id, b_visibility, b_event_id); +create index ix_s_log_audit_field_customer on s_log_audit_field (b_module_code, b_data_id, b_visibility, b_event_id); ``` ## 4. 关键设计决策说明 -### 4.1 为什么模块与实体分离 -旧系统把表、视图、路由、菜单、权限全部压在 `s_module` 一条记录上,导致"一个模块只能配一张主表"、菜单与数据源互相绑架。本方案中模块只回答"这是什么业务能力、依赖谁",实体回答"数据在哪、怎么存",两者可独立演进:一个模块可拥有多个实体(订舱主表 + 集装箱/费用/运输子表),一个实体也可被多个菜单入口引用。 +### 4.1 为什么保留 s_module 并删除 s_entity +旧系统已经以 `s_module` 作为配置、查询和保存的核心主体,现有接口、字段表和迁移工具也普遍使用 `b_module_id`。继续拆出 `s_entity` 会增加概念、外键和迁移成本,却没有形成足够独立的业务职责。因此新方案保留 `s_module` 自关联树,由 `category/data/query` 区分分类节点和数据节点;菜单和权限仍保持独立,避免恢复旧系统全部职责混杂的问题。 -### 4.2 为什么保留模块树 + 独立依赖 DAG -树形(`b_parent_id`)表达"业务分组归属"(基础资料 > 海运 > 订舱),是货代业务的真实组织诉求,且操作直观;但树无法表达"finance 依赖 master-data"这类跨分支依赖,所以依赖单独用 `s_module_dependency` 表达并强制 DAG。树归树、依赖归依赖,语义不再混淆。 +### 4.2 为什么不设置模块依赖表 +当前 `s_module` 不是可独立安装、发布的软件包,因此不额外维护模块依赖 DAG。数据模块之间的引用和主从关系由 `s_relation` 表达,数据库对象依赖由实际表、视图和 Schema Compiler 校验,部署依赖由构建或插件系统管理。 -### 4.3 为什么字段合并单表 -旧系统字段配置拆 4 张表,字段新增要同步维护 4 处、分组标题另存一份,配置漂移是常态。合并后字段的定义与默认列表/表单/查询行为集中在一行,实体字段增删改只需维护一处;菜单级差异由 `s_page_field` 覆盖,既避免重复定义,又保留不同页面的独立布局。代价是行较宽,但对元数据表(数量级远小于业务表)完全可接受。 +### 4.3 为什么字段定义与页面行为分表 +旧系统的字段、列表、编辑、查询虽然都挂在 `b_module_id` 下,但运行时和管理端都把它们当作四类独立配置。列表与表单的显示、排序、分组并不相同,查询条件也只覆盖部分字段。强行并入 `s_field` 会形成宽表,并混淆数据库列定义与页面行为。因此本方案保留四张职责明确的表,但统一改为通过 `b_field_id` 关联字段,字段归属再由 `s_field.b_module_id` 确定。 -### 4.4 为什么菜单独立且引用实体 -导航是展示层概念,数据是业务层概念。`s_menu` 引用 `b_entity_id` 而非模块,同一实体可在多个菜单入口出现(如"订舱列表"与"订舱审核"共用 `ocean_booking` 实体但绑定不同权限)。菜单树同时是管理员的统一配置入口,配置工作台通过菜单聚合其页面档、字段覆盖、关联实体和权限;因此数据库职责分离不会牺牲旧系统的定位效率。 +### 4.4 为什么菜单独立且引用模块 +导航是展示层概念,数据模块是业务数据概念。`s_menu` 引用 `b_module_id`,不重复保存数据源;同一数据模块可以被多个菜单入口引用,但默认共享模块上的同一套字段、列表、表单和查询配置。 -### 4.5 为什么增加页面配置档 -实体是可复用的数据定义,菜单是具体的使用入口。同一实体可能需要不同的列表列、表单可编辑性或查询条件,不能把这些差异继续写回实体字段默认值。`s_page_profile` 表示一个菜单入口的页面上下文,`s_page_field` 只记录覆盖项;管理员仍然只需进入对应菜单即可完成完整配置。 +### 4.5 为什么暂不增加页面配置档 +旧系统实际以一个 `data` 模块承载一套字段、列表、编辑和查询配置,没有菜单级字段覆盖机制。当前若再增加 `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 四级粒度,为后续数据范围、字段脱敏策略留出扩展位。 +旧系统 `b_user_power` 是用户直挂权限,用户一多授权矩阵爆炸。本方案 `s_role` + `s_user_role` + `s_role_power` 三层,权限点 `s_power` 支持到 menu/module/action/field 四级粒度,为后续数据范围、字段脱敏策略留出扩展位。 + ### 4.7 自动编码的并发处理 `b_currentvalue` 若采用"读-改-写"必然在并发下产生重号。方案要求应用层用原子自增语句取号(`update ... set b_currentvalue = b_currentvalue + 1 output inserted.b_currentvalue`),并把取号与业务保存放同一事务,确保单号不重不漏。 @@ -599,13 +553,11 @@ 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_module_id` | 绑定的数据/查询模块 `s_module.b_id` | +| `s_field_list` | `b_group_id` | 分组必须与 `b_field_id` 所属字段位于同一数据模块 | +| `s_field_edit` | `b_group_id` | 分组必须与 `b_field_id` 所属字段位于同一数据模块 | | `s_menu` | `b_power_code` | `s_power.b_code` | -| `s_entity` | `b_viewtable` / `b_savetable` | Schema Compiler 生成的物理表/视图 | +| `s_module` | `b_viewtable` / `b_savetable` | Schema Compiler 生成的物理表/视图 | | `s_log_technical` | `b_user_id` | 当前身份系统的用户 `b_id` | | `s_log_login` | `b_user_id` | 当前身份系统的用户 `b_id` | | `s_log_audit` | `b_operator_id` | 当前身份系统的用户 `b_id` | @@ -615,7 +567,7 @@ create index ix_s_log_audit_field_customer on s_log_audit_field (b_entity_code, ```text 引用是否已启用 -> 目标资源是否存在且类型匹配 --> 跨模块引用是否合法(依赖图中可达) +-> 字段、分组和模块关系是否属于正确的数据模块 -> 删除或停用前是否仍被其他资源引用 ``` @@ -623,16 +575,17 @@ create index ix_s_log_audit_field_customer on s_log_audit_field (b_entity_code, | 旧系统 | 本方案 | 变化 | | --- | --- | --- | -| `s_module`(含表/路由/菜单/权限) | `s_module` + `s_entity` + `s_menu` | 职责拆分 | -| `s_module_field` | `s_field` | 字段挂实体 | -| `s_module_field_edit` / `_list` / `_query` | 并入 `s_field` 标志列 | 四表合一 | -| `s_module_field_group` | `s_field_group` | 分组挂实体 | -| `s_module_auto_code` | `s_autocode` | 挂实体+字段,原子取号 | +| `s_module`(含表/路由/菜单/权限) | `s_module` + `s_menu` | 保留模块数据主体,拆出导航与角色权限 | +| `s_module_field` | `s_field` | 字段挂模块 | +| `s_module_field_list` | `s_field_list` | 改为通过字段关联模块 | +| `s_module_field_edit` | `s_field_edit` | 改为通过字段关联模块 | +| `s_module_field_query` | `s_field_query` | 改为通过字段关联模块 | +| `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 | -| (无) | `s_relation` | 新增实体关系 | +| (无) | `s_relation` | 新增模块关系 | | (无) | `s_log_technical` | 新增技术日志(旧系统无) | | (无) | `s_log_audit` + `s_log_audit_field` | 新增业务审计与字段变更(旧系统无) | + diff --git a/code/fms/fms-vue/src/components/fms-file/FileCard.vue b/code/fms/fms-vue/src/components/fms-file/FileCard.vue new file mode 100644 index 00000000..ffae522a --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FileCard.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FileCategoryPanel.vue b/code/fms/fms-vue/src/components/fms-file/FileCategoryPanel.vue new file mode 100644 index 00000000..5c52df1c --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FileCategoryPanel.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue b/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue new file mode 100644 index 00000000..a39fe810 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue @@ -0,0 +1,951 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue b/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue new file mode 100644 index 00000000..d74e9d24 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FmsFile.vue b/code/fms/fms-vue/src/components/fms-file/FmsFile.vue new file mode 100644 index 00000000..841528b2 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FmsFile.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-file/FmsFileModal.vue b/code/fms/fms-vue/src/components/fms-file/FmsFileModal.vue new file mode 100644 index 00000000..88fd0c19 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/FmsFileModal.vue @@ -0,0 +1,85 @@ + + + + + \ No newline at end of file diff --git a/code/fms/fms-vue/src/components/fms-file/composables/useFileCategory.js b/code/fms/fms-vue/src/components/fms-file/composables/useFileCategory.js new file mode 100644 index 00000000..5f9ab359 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/composables/useFileCategory.js @@ -0,0 +1,76 @@ +import { ref, computed } from 'vue' +import { loadDataApi } from '@/services/api' + +/** + * 文件分类逻辑 —— 加载 bf_files_cateid 扁平表并通过 Tree 展示。 + * 返回的 treeData 已包含「全部分类」根节点(key 固定为 __fms_tree_root__), + * 供 FileCategoryPanel 直接渲染。 + */ +export function useFileCategory() { + const categories = ref([]) + const loading = ref(false) + const error = ref(null) + const selectedKey = ref('') + + /** 加载所有启用的分类 */ + async function loadCategories() { + loading.value = true + error.value = null + try { + const res = await loadDataApi( + 'bf_files_cateid', + 'b_canuse = 1', + 'b_xh ASC, b_id ASC', + ) + categories.value = (res.data || []).map((item) => ({ + ...item, + b_id: String(item.b_id), + })) + } catch (e) { + error.value = e?.message || String(e) + categories.value = [] + } finally { + loading.value = false + } + } + + /** 扁平分类转 Tree 所需 treeData(b_id → key, b_name → title), + 并前置「全部分类」根节点(与旧版 FmsTree 行为一致) */ + const treeData = computed(() => { + const children = categories.value.map((item) => ({ + key: String(item.b_id), + title: item.b_name || item.b_code || '', + b_id: String(item.b_id), + isLeaf: true, + })) + return [ + { + key: '__fms_tree_root__', + title: '全部分类', + isFmsRoot: true, + children, + }, + ] + }) + + /** 按 key 查找分类原始数据 */ + function findCategory(key) { + const id = String(key || '') + return categories.value.find((item) => String(item.b_id) === id) || null + } + + function selectCategory(key) { + selectedKey.value = String(key || '') + } + + return { + categories, + loading, + error, + selectedKey, + treeData, + loadCategories, + findCategory, + selectCategory, + } +} diff --git a/code/fms/fms-vue/src/components/fms-file/fileVisual.js b/code/fms/fms-vue/src/components/fms-file/fileVisual.js new file mode 100644 index 00000000..25ee1ad4 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/fileVisual.js @@ -0,0 +1,62 @@ +// 文件视觉共享工具:扩展名语义配色、图片判断、大小格式化 +// 集中此处,避免 FileCard / FileListPanel / FilePreviewPanel 三处重复定义 + +// 扩展名 → 语义配色(ERP 附件管理可一眼辨类) +const EXT_COLOR = { + // 文档 + pdf: '#f5222d', + doc: '#1677ff', + docx: '#1677ff', + txt: '#8c8c8c', + md: '#8c8c8c', + // 表格 + xls: '#52c41a', + xlsx: '#52c41a', + csv: '#52c41a', + // 演示 + ppt: '#fa541c', + pptx: '#fa541c', + // 图片 + jpg: '#722ed1', + jpeg: '#722ed1', + png: '#722ed1', + gif: '#722ed1', + bmp: '#722ed1', + webp: '#722ed1', + svg: '#722ed1', + // 压缩 + zip: '#fa8c16', + rar: '#fa8c16', + '7z': '#fa8c16', + // 音视频 + mp3: '#13c2c2', + wav: '#13c2c2', + mp4: '#13c2c2', + mov: '#13c2c2', +} + +const FALLBACK_COLOR = '#8c8c8c' + +export function extFromName(name) { + const idx = (name || '').lastIndexOf('.') + return idx >= 0 ? name.substring(idx + 1).toLowerCase() : '' +} + +export function extColor(name) { + const ext = extFromName(name) + return EXT_COLOR[ext] || FALLBACK_COLOR +} + +export function isImageType(name) { + return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes( + extFromName(name), + ) +} + +export function formatSize(bytes) { + const n = Number(bytes || 0) + if (n < 1024) return n + ' B' + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB' + if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB' + return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB' +} diff --git a/code/fms/fms-vue/src/components/fms-file/useFileModal.js b/code/fms/fms-vue/src/components/fms-file/useFileModal.js new file mode 100644 index 00000000..61dd1d80 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-file/useFileModal.js @@ -0,0 +1,46 @@ +import { reactive } from 'vue' + +const state = reactive({ + open: false, + moduleCode: '', + father: '', + showTree: true, + readonly: false, + initialCateId: '', + key: 0, + // 事件回调 + onUploaded: null, + onDeleted: null, + onSelected: null, +}) + +let keyCounter = 0 + +/** + * 文件 Modal 命令式调用 composable。 + * + * 用法: + * const fileModal = useFileModal(); + * fileModal.open({ moduleCode: 'b_port', father: '123' }); + */ +export function useFileModal() { + function open(opts) { + keyCounter++ + state.key = keyCounter + state.moduleCode = opts.moduleCode || '' + state.father = String(opts.father ?? '') + state.showTree = opts.showTree !== false + state.readonly = opts.readonly || false + state.initialCateId = opts.initialCateId || '' + state.onUploaded = opts.onUploaded || null + state.onDeleted = opts.onDeleted || null + state.onSelected = opts.onSelected || null + state.open = true + } + + function close() { + state.open = false + } + + return { open, close, state } +} diff --git a/code/fms/fms-vue/src/components/ui/splitter/splitter.vue b/code/fms/fms-vue/src/components/ui/splitter/splitter.vue index 1b91d609..81af2f88 100644 --- a/code/fms/fms-vue/src/components/ui/splitter/splitter.vue +++ b/code/fms/fms-vue/src/components/ui/splitter/splitter.vue @@ -36,11 +36,22 @@ const isVertical = computed(() => props.direction === 'vertical') // 收集默认插槽中的 SplitterPanel 节点,过滤掉注释/空节点; // 统一为 { node, props } 结构:Vue 中未传 props 的 VNode 其 props 为 null, -// 规范化后后续访问 panel.props.xxx 安全 +// 规范化后后续访问 panel.props.xxx 安全。 +// 注意:父模板上写的 kebab 属性(如 default-size / bar-on-hover)在 VNode.props +// 中保留为 kebab 键,此处统一转成 camelCase,保证 displaySize()/applyOffset() 等 +// 读取 panel.props.defaultSize / panel.props.min / panel.props.max 时能命中。 +function camelize(str) { + return str.replace(/-(\w)/g, (_, c) => c.toUpperCase()) +} const panelItems = computed(() => (slots.default?.() ?? []) .filter((node) => node.type === SplitterPanel) - .map((node) => ({ node, props: node.props ?? {} })), + .map((node) => { + const raw = node.props ?? {} + const props = {} + for (const key of Object.keys(raw)) props[camelize(key)] = raw[key] + return { node, props } + }), ) /* ---------- 尺寸工具 ---------- */ diff --git a/code/fms/fms-vue/src/layouts/DefaultLayout.vue b/code/fms/fms-vue/src/layouts/DefaultLayout.vue index e1a61200..a00d3a0a 100644 --- a/code/fms/fms-vue/src/layouts/DefaultLayout.vue +++ b/code/fms/fms-vue/src/layouts/DefaultLayout.vue @@ -161,15 +161,22 @@ function onSplitterResize(sizes) { min-width: 0; } -/* 折叠态:隐藏分隔条(此时侧栏固定 48px 且不可调整) */ -.fms-layout.is-collapsed .fms-layout-splitter :deep(.fms-splitter-bar) { +/* 折叠态:隐藏分隔条(此时侧栏固定 48px 且不可调整)。 + * 必须用子代选择器 > 限定为布局自身的直接子级分隔条: + * 若写成后代选择器(如 .fms-splitter-bar),会命中嵌套在 + * 主面板里的其他 Splitter(如 FmsFile 内部的分隔条), + * 导致折叠时内层分隔条也被隐藏 */ +.fms-layout.is-collapsed .fms-layout-splitter :deep(> .fms-splitter-bar) { display: none; } /* 非折叠态:分隔条命中区改为悬浮(width: 0 + 负 margin),不占 flex 宽度。 * 否则侧栏内容右 padding(8px) + 分隔条占位(6px) 叠加成 14px 空隙, - * 而右侧仅 8px,两侧不对称(折叠态因 display:none 掩盖了此问题) */ -.fms-layout .fms-layout-splitter :deep(.fms-splitter-horizontal .fms-splitter-bar) { + * 而右侧仅 8px,两侧不对称(折叠态因 display:none 掩盖了此问题)。 + * 注意同样只能用 > 限定为布局自身的直接子级分隔条: + * 用后代选择器时 scoped :deep 会把主面板内嵌套 Splitter 的分隔条 + * 一并压成 0 宽,导致内层(如 FmsFile)分隔条不可见、无法拖拽 */ +.fms-layout .fms-layout-splitter :deep(> .fms-splitter-bar) { width: 0; margin: 0 calc(var(--fms-splitter-trigger-size) / -2); position: relative; diff --git a/code/fms/fms-vue/src/router/index.js b/code/fms/fms-vue/src/router/index.js index ee555f93..48a1ee1f 100644 --- a/code/fms/fms-vue/src/router/index.js +++ b/code/fms/fms-vue/src/router/index.js @@ -72,6 +72,13 @@ const router = createRouter({ component: () => import('@/views/demo/index.vue'), meta: { title: '组件库', componentName: 'Demo' }, }, + // 附件组件演示 + { + path: '/file-demo', + name: 'file-demo', + component: () => import('@/views/file-demo/index.vue'), + meta: { title: '附件组件演示', componentName: 'FileDemo' }, + }, // 详情页:同一组件按参数复用,但每个 fullPath 独立缓存、关闭即销毁 // (per-param 的命名组件由 DefaultLayout 的 getPageWrapper 按路由动态生成) { diff --git a/code/fms/fms-vue/src/services/fileService.js b/code/fms/fms-vue/src/services/fileService.js new file mode 100644 index 00000000..edf8033b --- /dev/null +++ b/code/fms/fms-vue/src/services/fileService.js @@ -0,0 +1,148 @@ +import { post } from './http' +import { nextIdApi } from './api' + +let storageType = null +let ossCredCache = null +const CRED_BUFFER_MS = 5 * 60 * 1000 // 到期前 5 分钟刷新 + +/** 初始化存储模式,返回 "local" | "aliyun-oss" */ +export async function initFileConfig() { + if (storageType) return storageType + const res = await post('/file/config') + storageType = res.data.storageType + return storageType +} + +export function getStorageType() { + return storageType +} + +/** + * 批量上传文件(自动匹配模式,含 DB 写入) + * @param {{ files: File[], father: string, moduleId: string, cateId?: string }} opts + * @returns {Promise} 文件记录列表 + */ +export async function upload({ files, father, moduleId, cateId }) { + if (!storageType) await initFileConfig() + + if (storageType === 'aliyun-oss') { + return uploadToOss({ files, father, moduleId, cateId }) + } + return uploadToLocal({ files, father, moduleId, cateId }) +} + +/** Local: 一次请求打包所有文件,后端逐个写磁盘 + INSERT */ +async function uploadToLocal({ files, father, moduleId, cateId }) { + const fd = new FormData() + for (const f of files) { + fd.append('files', f) + } + fd.append('father', father) + fd.append('moduleId', moduleId) + if (cateId) fd.append('cateId', cateId) + + const res = await post('/file/upload', fd, { + headers: { 'Content-Type': undefined }, + }) + return res.data // List +} + +/** OSS: 共享凭证 → 逐个 XHR 直传 → 逐个回调 save-record */ +async function uploadToOss({ files, father, moduleId, cateId }) { + const cred = await getOssCredential() + const { accessid, policy, signature, dir, host } = cred + + const results = [] + for (const file of files) { + const idRes = await nextIdApi() + const subid = String(idRes.data) + + const ext = extname(file.name) + const objectName = crypto.randomUUID().replace(/-/g, '') + (ext ? '.' + ext : '') + const url = await xhrUpload(host, { + key: dir + objectName, + OSSAccessKeyId: accessid, + policy, + Signature: signature, + file, + filename: objectName, + }) + + const record = { + subid, + father, + mx_moduleid: moduleId, + mx_cate_id: cateId || '', + mx_filename: file.name, + mx_filesize: String(file.size), + mx_fileext: ext, + mx_mapfilename: url, + } + await post('/file/save-record', record) + results.push(record) + } + return results +} + +/* ───── OSS 凭证缓存 ───── */ + +async function getOssCredential() { + if (ossCredCache && !credExpiring(ossCredCache)) { + return ossCredCache + } + const res = await post('/file/upload-credential') + ossCredCache = { ...res.data, fetchedAt: Date.now() } + return ossCredCache +} + +function credExpiring(cache) { + // 如果 g3oss 返回了 UNIX 时间戳过期时间,优先用 + if (cache.expire) { + return Date.now() + CRED_BUFFER_MS > Number(cache.expire) * 1000 + } + if (cache.Expiration) { + return Date.now() + CRED_BUFFER_MS > new Date(cache.Expiration).getTime() + } + // 兜底:默认有效期 55 分钟 + return Date.now() - cache.fetchedAt > 55 * 60 * 1000 +} + +/* ───── 底层工具 ───── */ + +function xhrUpload(host, fields) { + const fd = new FormData() + fd.append('key', fields.key) + fd.append('OSSAccessKeyId', fields.OSSAccessKeyId) + fd.append('policy', fields.policy) + fd.append('Signature', fields.Signature) + fd.append('file', fields.file, fields.filename) + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', host) + xhr.addEventListener('load', () => { + if (xhr.status === 200 || xhr.status === 204) { + resolve(host + '/' + fields.key) + } else { + reject(new Error('OSS 上传失败: ' + xhr.status)) + } + }) + xhr.addEventListener('error', () => reject(new Error('OSS 上传异常'))) + xhr.send(fd) + }) +} + +/** 获取预览/下载地址 */ +export function getPreviewUrl(fileRow) { + return fileRow.mx_mapfilename +} + +/** 删除文件 */ +export async function removeFile(subid) { + return post('/file/delete', { subid }) +} + +function extname(name) { + if (!name || !name.includes('.')) return '' + return name.substring(name.lastIndexOf('.') + 1).toLowerCase() +} diff --git a/code/fms/fms-vue/src/views/file-demo/index.vue b/code/fms/fms-vue/src/views/file-demo/index.vue new file mode 100644 index 00000000..9cd064f3 --- /dev/null +++ b/code/fms/fms-vue/src/views/file-demo/index.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/code/test-demo/AI_CONTEXT.md b/code/test-demo/AI_CONTEXT.md index b8e9e013..1a3a678d 100644 --- a/code/test-demo/AI_CONTEXT.md +++ b/code/test-demo/AI_CONTEXT.md @@ -1,8 +1,8 @@ -# AI Context — FMS 新系统元数据配置 Demo +# AI Context — FMS 新系统配置中心 Demo ## 项目目标 -这是一个 **FMS 新系统元数据配置演示**,用 React 实现,用于直观展示如何配置模块体系,对应《FMS新系统核心表结构设计.md》中的 17 张核心表。 +这是一个 **FMS 新系统配置中心演示**,用 React 实现,用于直观展示如何配置业务域、实体、菜单与权限,对应《FMS新系统核心表结构设计.md》中的核心元数据结构。 命名风格参考旧系统:表名 `s_` 前缀、字段 `b_` 前缀 + 驼峰(如 `b_viewtable`、`b_inputdatetime`)。 @@ -19,36 +19,27 @@ ``` src/ -├── types/index.ts # 元数据模型(对应 17 张表)+ 工具函数 -├── data/mock.ts # 演示数据:海运订舱 ocean 模块全链路示例 -├── components/ -│ ├── ui.tsx # 共享 UI 小组件(Card/Badge/Field/Input/Toggle/表格) -│ ├── ModulePanel.tsx # Tab1 模块:s_module 树 + 基本信息 + s_module_dependency DAG -│ ├── EntityPanel.tsx # Tab2 实体与字段:s_entity + s_field_group + s_field 合并单表 -│ ├── MenuPanel.tsx # Tab3 菜单页面:s_menu 导航树(挂模块 + 引用实体) -│ ├── AutoCodePanel.tsx # Tab4 编码与多语言:s_autocode + s_i18n -│ └── PermissionPanel.tsx # Tab5 权限:s_role + s_user_role + s_power + s_role_power -├── App.tsx # 主布局 + Tab + 全局状态 + 统计 -├── main.tsx # 入口 -└── index.css # Tailwind 入口 +└── src/ + ├── App.tsx # 单页配置中心:总览 / 业务域与实体 / 菜单与权限 / 角色与用户 + ├── index.css # 整体样式 + └── main.tsx # 入口 ``` ## 设计理念 -1. **四 Tab 对应四组表**:模块(模块+依赖)、实体与字段(实体+分组+字段)、菜单页面、编码与多语言 -2. **所有数据为前端内存状态**:`App.tsx` 统一持有 state,面板组件通过 onChange 回调修改,演示"配置后立即生效" -3. **字段配置合并单表**:旧系统 4 张配置表(field/edit/list/query)合并为 `s_field` 单表,行内展开编辑 -4. **模块树 vs 菜单树分离**:`s_module.b_parent_id` 只做业务分组,`s_menu.b_parent_id` 才是导航层级 -5. **开关直接可拨**:必填/列表显示/可查询/启用等布尔字段用 Toggle 拨动 +1. **四个工作区**:总览、业务域与实体、菜单与权限、角色与用户 +2. **业务域只管归属**:`s_module` 仅表示业务分组,不承载菜单和权限 +3. **实体维护数据边界**:`s_entity` 负责数据源、保存表、主键,字段属于实体 +4. **菜单承载访问入口**:`s_menu` 直接绑定实体或固定页面,动作权限跟着菜单生成 +5. **权限跟随菜单**:角色只做授权主体,权限点来源于菜单动作 ## 覆盖的表 -| Tab | 表 | +| 工作区 | 表 | | --- | --- | -| 模块 | `s_module`、`s_module_dependency` | -| 实体与字段 | `s_entity`、`s_field_group`、`s_field` | -| 菜单页面 | `s_menu` | -| 编码与多语言 | `s_autocode`、`s_i18n` | +| 业务域 | `s_module` | +| 实体与字段 | `s_entity`、`s_field_group`、`s_field`、`s_field_list`、`s_field_edit`、`s_field_query` | +| 菜单与页面 | `s_menu` | | 权限 | `s_role`、`s_user_role`、`s_power`、`s_role_power` | ## 启动 @@ -60,7 +51,8 @@ pnpm dev # http://localhost:3000 ## 注意事项 -- 演示数据在 `src/data/mock.ts`,`resetAll` 会重置所有 state 到初始值 -- 菜单 `s_menu` 通过 `b_module_id` 挂模块(组织维度)、`b_entity_id` 引用实体,两者可独立演进 -- 权限演示:角色(左)→ 角色-权限授权矩阵(授权/拒绝/未授权三态)→ 角色-用户 → 权限点字典,覆盖 `s_role`/`s_user_role`/`s_power`/`s_role_power` 四表 -- 新表结构中的日志表(`s_log_technical`/`s_log_login`/`s_log_audit`/`s_log_audit_field`)未在 Demo 中展示,如需可扩展 Tab +- 这版 Demo 按维护关联度合并页面:业务域、实体和实体关系在同一棵配置树及详情工作区维护,菜单和权限一起维护 +- 业务模型树同时展示 `s_module` 和 `s_entity`:业务域节点表达归属,实体节点表达数据对象;只有 `owned` 主从实体继续嵌套,`reference` 引用不改变树位置 +- 权限是跟着菜单走的:一个数据菜单或页面菜单会生成一组动作权限,再由角色去勾选 +- `s_relation` 在实体详情中维护关系类型、来源字段、目标字段、所有权和删除策略 +- `src/index.css` 里有完整布局样式;如果要继续精修,可以在这份文件上直接改 diff --git a/code/test-demo/README.md b/code/test-demo/README.md index d6af7e39..b275eda2 100644 --- a/code/test-demo/README.md +++ b/code/test-demo/README.md @@ -1,32 +1,30 @@ -# React + TypeScript + Vite +# FMS 配置中心 Demo -This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. +这是一个用于验证 FMS 新系统元数据维护方式的前端演示。业务域和实体在同一棵配置树中展示:业务域表达归属,实体表达数据边界,实体的主从关系可以继续嵌套,普通引用关系在实体详情中配置。 -Currently, two official plugins are available: +## 启动 -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the Oxlint configuration - -If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: - -```json -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["react", "typescript", "oxc"], - "options": { - "typeAware": true - }, - "rules": { - "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] - } -} +```bash +pnpm install +pnpm dev ``` -See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. +浏览器访问 `http://localhost:3000/`。 + +## 使用流程 + +1. 在“业务域与实体”中选择或创建业务域,并直接维护该域下的实体、数据源、主键和字段。 +2. 在“菜单与权限”中维护菜单树。选中菜单后,通过页签同时维护页面绑定、动作权限、角色授权和数据范围。 +3. 在“角色与用户”中维护角色本身及其成员,具体菜单授权仍在菜单工作区完成。 +4. 顶部“保存”用于模拟保存配置;菜单权限工作区的“发布”用于模拟发布。 + +## 对应核心表 + +| 工作区 | 主要表 | +| --- | --- | +| 业务模型树 | `s_module`、`s_entity`、`s_relation` | +| 实体与字段 | `s_entity`、`s_field_group`、`s_field`、`s_field_list`、`s_field_edit`、`s_field_query` | +| 菜单与页面 | `s_menu` | +| 权限 | `s_role`、`s_user_role`、`s_power`、`s_role_power` | + +Demo 当前使用内存状态模拟维护,不连接后端接口;刷新页面会恢复示例数据。 diff --git a/code/test-demo/src/App.tsx b/code/test-demo/src/App.tsx index 7e2f7e8f..e0dddf51 100644 --- a/code/test-demo/src/App.tsx +++ b/code/test-demo/src/App.tsx @@ -1,263 +1,1940 @@ -import { useMemo, useState, type ComponentType, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { - Braces, + Activity, ChevronDown, + ChevronRight, CircleHelp, Database, - FileCode2, + FilePlus2, + FolderPlus, FolderTree, - Languages, - List, + KeyRound, + LayoutGrid, + Menu as MenuIcon, MoreHorizontal, Plus, + Save, Search, - Settings2, ShieldCheck, - Table2, - Workflow, - X, + Trash2, + Users, } from "lucide-react"; import "./index.css"; -type NodeType = "directory" | "page" | "data"; -type ModuleNode = { +type Workspace = "overview" | "domainEntities" | "menuPermissions" | "roles"; +type Domain = { id: number; - name: string; code: string; - type: NodeType; - parent: number | null; - route?: string; - component?: string; - view?: string; - save?: string; - key?: string; - dataNodeId?: number; + name: string; + parentId: number | null; + enabled: boolean; + description: string; +}; +type Entity = { + id: number; + domainId: number; + code: string; + name: string; + view: string; + save: string; + key: string; + type: "table" | "view" | "query"; enabled: boolean; - showInMenu: boolean; }; - type Field = { id: number; + entityId: number; code: string; name: string; type: string; - defaultValue: string; + nullable: boolean; + enabled: boolean; +}; +type Relation = { + id: number; + entityId: number; + targetEntityId: number; + relationType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"; + foreignField: string; + linkField: string; + ownership: "reference" | "owned"; + deletePolicy: "restrict" | "set-null" | "cascade" | "archive"; + enabled: boolean; +}; +type Menu = { + id: number; + parentId: number | null; + code: string; + name: string; + type: "directory" | "page" | "data"; + route: string; + entityId: number | null; + domainId: number | null; + enabled: boolean; + visible: boolean; +}; +type Role = { id: number; code: string; name: string; members: number }; +type Power = { + id: number; + menuId: number; + code: string; + name: string; enabled: boolean; }; -const 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 initialDomains: Domain[] = [ + { + id: 1, + code: "ocean", + name: "海运业务", + parentId: null, + enabled: true, + description: "订舱、运输节点与费用相关业务能力", + }, + { + id: 2, + code: "booking", + name: "订舱管理", + parentId: 1, + enabled: true, + description: "订舱单据及审核流程", + }, + { + id: 3, + code: "master-data", + name: "基础资料", + parentId: null, + enabled: true, + description: "客户、港口、航线等公共主数据", + }, +]; +const initialEntities: Entity[] = [ + { + id: 1, + domainId: 2, + code: "ocean_booking", + name: "订舱单", + view: "v_ocean_booking_list", + save: "ocean_booking", + key: "b_id", + type: "view", + enabled: true, + }, + { + id: 2, + domainId: 2, + code: "ocean_booking_container", + name: "集装箱明细", + view: "ocean_booking_container", + save: "ocean_booking_container", + key: "b_id", + type: "table", + enabled: true, + }, + { + id: 3, + domainId: 3, + code: "base_customer", + name: "客户档案", + view: "base_customer", + save: "base_customer", + key: "b_id", + type: "table", + enabled: true, + }, ]; - const initialFields: Field[] = [ - { id: 1, 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 }, + { + id: 1, + entityId: 1, + code: "b_ywno", + name: "业务单号", + type: "input", + nullable: false, + enabled: true, + }, + { + id: 2, + entityId: 1, + code: "b_customer_id", + name: "委托客户", + type: "select", + nullable: false, + enabled: true, + }, + { + id: 3, + entityId: 1, + code: "b_ywtype", + name: "业务类型", + type: "select", + nullable: false, + enabled: true, + }, + { + id: 4, + entityId: 1, + code: "b_etd", + name: "预计开船日", + type: "date", + nullable: true, + enabled: true, + }, + { + id: 5, + entityId: 1, + code: "b_state", + name: "单据状态", + type: "select", + nullable: false, + enabled: true, + }, + { + id: 6, + entityId: 3, + code: "b_code", + name: "客户编码", + type: "input", + nullable: false, + enabled: true, + }, + { + id: 7, + entityId: 3, + code: "b_name", + name: "客户名称", + type: "input", + nullable: false, + enabled: true, + }, + { + id: 8, + entityId: 2, + code: "b_booking_id", + name: "订舱单", + type: "number", + nullable: false, + enabled: true, + }, ]; - -const labels: Record = { - basic: "基本信息", - fields: "字段定义", - list: "列表配置", - edit: "编辑配置", - query: "查询配置", - autocode: "自动编码", - power: "权限配置", - i18n: "多语言", +const initialRelations: Relation[] = [ + { + id: 1, + entityId: 1, + targetEntityId: 2, + relationType: "one-to-many", + foreignField: "b_booking_id", + linkField: "b_id", + ownership: "owned", + deletePolicy: "cascade", + enabled: true, + }, + { + id: 2, + entityId: 1, + targetEntityId: 3, + relationType: "many-to-one", + foreignField: "b_customer_id", + linkField: "b_id", + ownership: "reference", + deletePolicy: "restrict", + enabled: true, + }, +]; +const initialMenus: Menu[] = [ + { + id: 1, + parentId: null, + code: "ocean", + name: "海运业务", + type: "directory", + route: "", + entityId: null, + domainId: 1, + enabled: true, + visible: true, + }, + { + id: 2, + parentId: 1, + code: "booking", + name: "订舱管理", + type: "directory", + route: "", + entityId: null, + domainId: 2, + enabled: true, + visible: true, + }, + { + id: 3, + parentId: 2, + code: "booking-list", + name: "订舱列表", + type: "data", + route: "/ocean/booking", + entityId: 1, + domainId: 2, + enabled: true, + visible: true, + }, + { + id: 4, + parentId: 2, + code: "booking-audit", + name: "订舱审核", + type: "page", + route: "/ocean/booking/audit", + entityId: null, + domainId: 2, + enabled: true, + visible: true, + }, + { + id: 5, + parentId: null, + code: "master-data", + name: "基础资料", + type: "directory", + route: "", + entityId: null, + domainId: 3, + enabled: true, + visible: true, + }, + { + id: 6, + parentId: 5, + code: "customer", + name: "客户档案", + type: "data", + route: "/base/customer", + entityId: 3, + domainId: 3, + enabled: true, + visible: true, + }, +]; +const initialRoles: Role[] = [ + { id: 1, code: "ops", name: "运营专员", members: 18 }, + { id: 2, code: "manager", name: "业务经理", members: 6 }, + { id: 3, code: "admin", name: "系统管理员", members: 2 }, +]; +const actionNames = ["view", "create", "edit", "delete", "export"]; +const actionLabels: Record = { + view: "查看", + create: "新增", + edit: "编辑", + delete: "删除", + export: "导出", }; - -const typeLabels: Record = { directory: "目录", page: "页面", data: "数据" }; const fieldTypes = [ - ["input", "输入框"], ["textarea", "文本域"], ["number", "数字"], ["money", "金额"], - ["date", "日期"], ["datetime", "日期时间"], ["select", "下拉选择"], + "input", + "select", + "date", + "datetime", + "number", + "textarea", ]; -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 [nodes, setNodes] = useState(initialNodes); + const [workspace, setWorkspace] = useState("overview"); + const [domains, setDomains] = useState(initialDomains); + const [entities, setEntities] = useState(initialEntities); const [fields, setFields] = useState(initialFields); - const [selectedId, setSelectedId] = useState(3); - const [section, setSection] = useState("basic"); - const [treeSearch, setTreeSearch] = useState(""); + const [relations, setRelations] = useState(initialRelations); + const [menus, setMenus] = useState(initialMenus); + const [roles] = useState(initialRoles); + const [powers, setPowers] = useState(() => + initialMenus + .filter((menu) => menu.type !== "directory") + .flatMap((menu) => + actionNames.map((action, index) => ({ + id: menu.id * 10 + index, + menuId: menu.id, + code: `${menu.code}.${action}`, + name: actionLabels[action], + enabled: action !== "delete", + })), + ), + ); + const [selectedDomainId, setSelectedDomainId] = useState(2); + const [selectedEntityId, setSelectedEntityId] = useState(1); + const [selectedMenuId, setSelectedMenuId] = useState(3); + const [selectedRoleId, setSelectedRoleId] = useState(1); + const [menuTab, setMenuTab] = useState<"basic" | "powers" | "scope">("basic"); const [dirty, setDirty] = useState(false); - const [addOpen, setAddOpen] = useState(false); - - 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 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 [toast, setToast] = useState(""); + const selectedDomain = + domains.find((item) => item.id === selectedDomainId) ?? domains[0]; + const selectedEntity = + entities.find( + (item) => + item.id === selectedEntityId && item.domainId === selectedDomain?.id, + ) ?? entities.find((item) => item.domainId === selectedDomain?.id); + const selectedMenu = + menus.find((item) => item.id === selectedMenuId) ?? menus[0]; + const selectedRole = + roles.find((item) => item.id === selectedRoleId) ?? roles[0]; + const selectDomain = (id: number) => { + setSelectedDomainId(id); + setSelectedEntityId(0); }; - const updateField = (id: number, patch: Partial) => { - setFields((items) => items.map((field) => field.id === id ? { ...field, ...patch } : field)); - setDirty(true); + const selectEntity = (id: number) => { + const entity = entities.find((item) => item.id === id); + if (!entity) return; + setSelectedDomainId(entity.domainId); + setSelectedEntityId(id); }; - - return
-
-
FMS 模块管理菜单、页面与数据配置
-
当前系统国际货运管理系统
-
{dirty ? "有未保存修改" : "所有修改已保存"}林
-
- -
- - -
-
-
{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" && } -
+
+ + + {dirty ? "有未保存修改" : "配置已同步"} + + + 林
-
+ +
+ +
+ {workspace === "overview" && ( + + )} + {workspace === "domainEntities" && ( + { + setDomains((items) => + items.map((item) => + item.id === selectedDomain.id + ? { ...item, ...patch } + : item, + ), + ); + markDirty(); + }} + onEntityChange={(patch) => { + if (!selectedEntity) return; + setEntities((items) => + items.map((item) => + item.id === selectedEntity.id + ? { ...item, ...patch } + : item, + ), + ); + markDirty(); + }} + onFieldChange={(id, patch) => { + setFields((items) => + items.map((item) => + item.id === id ? { ...item, ...patch } : item, + ), + ); + markDirty(); + }} + onRelationChange={(id, patch) => { + setRelations((items) => + items.map((item) => + item.id === id ? { ...item, ...patch } : item, + ), + ); + markDirty(); + }} + onAddField={() => { + if (!selectedEntity) return; + const id = Math.max(...fields.map((item) => item.id), 0) + 1; + setFields((items) => [ + ...items, + { + id, + entityId: selectedEntity.id, + code: `b_field_${id}`, + name: "新字段", + type: "input", + nullable: true, + enabled: true, + }, + ]); + markDirty(); + }} + /> + )} + {workspace === "menuPermissions" && ( + { + setMenus((items) => + items.map((item) => + item.id === selectedMenu.id ? { ...item, ...patch } : item, + ), + ); + markDirty(); + }} + onToggle={(id) => { + setPowers((items) => + items.map((item) => + item.id === id ? { ...item, enabled: !item.enabled } : item, + ), + ); + markDirty(); + }} + onPublish={() => { + setDirty(false); + setToast("菜单权限已发布"); + window.setTimeout(() => setToast(""), 2600); + }} + /> + )} + {workspace === "roles" && } +
+
+ {toast && ( +
+ ✓ + {toast} +
+ )}
-
; + ); } -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 SideNav({ + icon, + label, + caption, + active, + onClick, +}: { + icon: ReactNode; + label: string; + caption?: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); } - -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) => [ - + } + /> +
+ } + label="业务域" + value={String(domains.length)} + note="业务归属分组" + tone="blue" + /> + } + label="实体" + value={String(entities.length)} + note="可查询 / 可保存数据对象" + tone="teal" + /> + } + label="菜单入口" + value={String( + menus.filter((item) => item.type !== "directory").length, + )} + note="页面与数据菜单" + tone="amber" + /> + } + label="角色" + value={String(roles.length)} + note="授权主体" + tone="violet" + /> +
+
+
+
+
+

推荐维护方式

+

每个工作区都把高关联对象放在同一屏幕。

+
+ 2 WORKSPACES +
+ } + title="业务域与实体" + description="先选业务域,再在右侧维护实体、字段和页面配置。" + onClick={() => onNavigate("domainEntities")} + /> + } + title="菜单与权限" + description="选中菜单后,在同一页面配置入口、动作、角色和数据范围。" + onClick={() => onNavigate("menuPermissions")} + /> + } + title="角色与用户" + description="维护角色和人员归属,授权动作在菜单工作区完成。" + onClick={() => onNavigate("roles")} + /> +
+
+
+
+

底层职责仍然分离

+

页面聚合维护,数据模型保持清晰。

+
+
+
+ } + label="业务域" + detail="s_module" + tone="blue" + /> + + } + label="实体" + detail="s_entity" + tone="teal" + /> + + } + label="菜单" + detail="s_menu" + tone="amber" + /> + + } + label="权限" + detail="s_power" + tone="violet" + /> +
+
+ + + 管理界面按操作关联度合并,底层仍由实体、菜单、权限表分别持久化。 + +
+
+
+ + ); +} +function WorkflowStep({ + number, + icon, + title, + description, + onClick, +}: { + number: string; + icon: ReactNode; + title: string; + description: string; + onClick: () => void; +}) { + return ( + + ); +} +function ModelNode({ + icon, + label, + detail, + tone, +}: { + icon: ReactNode; + label: string; + detail: string; + tone: string; +}) { + return ( +
+ {icon} + {label} + {detail} +
+ ); +} +function DetailTitle({ + icon, + eyebrow, + title, + badge, +}: { + icon: ReactNode; + eyebrow: string; + title: string; + badge: string; +}) { + return ( +
+ {icon} +
+
{eyebrow}
+

{title}

+
+ {badge} +
+ ); +} +function LabeledInput({ + label, + value, + onChange, + readOnly = false, +}: { + label: string; + value: string; + onChange?: (value: string) => void; + readOnly?: boolean; +}) { + return ( + + ); +} +function Toggle({ value, onChange }: { value: boolean; onChange: () => void }) { + return ( + + ); +} +function DomainEntityWorkspace({ + domains, + entities, + fields, + relations, + selectedDomain, + selectedEntity, + onSelectDomain, + onSelectEntity, + onAddDomain, + onAddEntity, + onDomainChange, + onEntityChange, + onFieldChange, + onRelationChange, + onAddField, +}: { + domains: Domain[]; + entities: Entity[]; + fields: Field[]; + relations: Relation[]; + selectedDomain: Domain; + selectedEntity?: Entity; + onSelectDomain: (id: number) => void; + onSelectEntity: (id: number) => void; + onAddDomain: (parentId: number | null) => void; + onAddEntity: () => void; + onDomainChange: (patch: Partial) => void; + onEntityChange: (patch: Partial) => void; + onFieldChange: (id: number, patch: Partial) => void; + onRelationChange: (id: number, patch: Partial) => void; + onAddField: () => void; +}) { + const visibleEntities = entities.filter( + (item) => item.domainId === selectedDomain.id, + ); + const entityFields = selectedEntity + ? fields.filter((item) => item.entityId === selectedEntity.id) + : []; + const entityRelations = selectedEntity + ? relations.filter((item) => item.entityId === selectedEntity.id) + : []; + return ( +
+ + + +
+ } + /> +
+
+
+
+

业务模型树

+

+ {domains.length} 个业务域 · {entities.length} 个实体 +

+
+ +
+
+ + +
+
+ {renderBusinessTree( + domains, + entities, + relations, + null, + 0, + selectedDomain.id, + selectedEntity?.id ?? 0, + onSelectDomain, + onSelectEntity, + )} +
+
+ {selectedDomain.name} + {selectedDomain.description} + +
+
+
+
+
+

+ {selectedEntity ? selectedEntity.name : selectedDomain.name} +

+

+ {selectedEntity + ? "实体数据边界、字段与关系配置" + : `${visibleEntities.length} 个直接归属实体`} +

+
+ + {selectedEntity ? "S_ENTITY" : "S_MODULE"} + +
+ {!selectedEntity && ( + <> + } + eyebrow="S_MODULE" + title={selectedDomain.name} + badge={selectedDomain.enabled ? "启用中" : "已停用"} + /> +
+ onDomainChange({ code: value })} + /> + onDomainChange({ name: value })} + /> +