恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 lines
6.5 KiB
JavaScript
212 lines
6.5 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import assert from 'node:assert/strict'
|
|
import { readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const scriptPath = fileURLToPath(import.meta.url)
|
|
const appRoot = path.resolve(path.dirname(scriptPath), '..')
|
|
|
|
async function source(relativePath) {
|
|
return readFile(path.join(appRoot, relativePath), 'utf8')
|
|
}
|
|
|
|
const [
|
|
navigationSource,
|
|
moduleCatalogSource,
|
|
inventorySource,
|
|
pageRendererSource,
|
|
adapterBlockSource,
|
|
searchFormSource,
|
|
entityFormSource
|
|
] = await Promise.all([
|
|
source('src/utils/modernNavigation.ts'),
|
|
source('src/data/moduleCatalog.ts'),
|
|
source('public/generated/ui-inventory.json'),
|
|
source('src/components/erp/ErpPageRenderer.vue'),
|
|
source('src/components/erp/ErpAdapterBlock.vue'),
|
|
source('src/components/erp/ErpSearchForm.vue'),
|
|
source('src/components/erp/ErpEntityForm.vue')
|
|
])
|
|
|
|
const inventory = JSON.parse(inventorySource)
|
|
|
|
function loadNavigationExports(sourceCode) {
|
|
const executableSource = sourceCode
|
|
.replace(/export const legacyControlPagePrefixes:\s*Record<string,\s*string>\s*=/, 'const legacyControlPagePrefixes =')
|
|
.replace(/export function /g, 'function ')
|
|
.replace(/([,(]\s*)([A-Za-z_$][\w$]*):\s*(?:string|unknown)/g, '$1$2')
|
|
|
|
return Function(`${executableSource}
|
|
return {
|
|
legacyControlPagePrefixes,
|
|
isInternalLegacyControlTarget,
|
|
pageHashForTarget,
|
|
navigateTarget,
|
|
normalizeModernNavigationTarget
|
|
}
|
|
`)()
|
|
}
|
|
|
|
function moduleBlocks(sourceCode) {
|
|
return [...sourceCode.matchAll(/\{\n\s+id: '([^']+)'[\s\S]*?\n\s+workflows:/g)]
|
|
.map((match) => {
|
|
const block = match[0]
|
|
return {
|
|
id: match[1],
|
|
prefixes: stringArrayValue(block, 'prefixes'),
|
|
legacyIncludes: stringArrayValue(block, 'legacyIncludes'),
|
|
quickPages: [...block.matchAll(/pageId: '([^']+)'/g)].map((item) => item[1])
|
|
}
|
|
})
|
|
}
|
|
|
|
function stringArrayValue(sourceCode, propertyName) {
|
|
const match = sourceCode.match(new RegExp(`${propertyName}: \\[([\\s\\S]*?)\\]`))
|
|
return match?.[1].match(/'([^']+)'/g)?.map((item) => item.slice(1, -1)) || []
|
|
}
|
|
|
|
function moduleOwnsRoute(module, route) {
|
|
const haystack = [
|
|
route.pageId,
|
|
route.title,
|
|
route.component,
|
|
route.domain,
|
|
route.legacyPath,
|
|
route.modernPath,
|
|
...(route.permissions || [])
|
|
].filter(Boolean).join(' ').toLowerCase()
|
|
|
|
return module.quickPages.includes(route.pageId)
|
|
|| module.prefixes.some((prefix) => String(route.pageId || '').startsWith(prefix) || haystack.includes(prefix.toLowerCase()))
|
|
|| module.legacyIncludes.some((include) => haystack.includes(include.toLowerCase()))
|
|
}
|
|
|
|
const navigationExports = loadNavigationExports(navigationSource)
|
|
const routeManifest = inventory.routeManifest || []
|
|
const routePageIds = new Set(routeManifest.map((route) => route.pageId))
|
|
const modules = moduleBlocks(moduleCatalogSource)
|
|
|
|
for (const exportName of [
|
|
'legacyControlPagePrefixes',
|
|
'isInternalLegacyControlTarget',
|
|
'pageHashForTarget',
|
|
'navigateTarget',
|
|
'normalizeModernNavigationTarget'
|
|
]) {
|
|
assert.match(
|
|
navigationSource,
|
|
new RegExp(`export function ${exportName}|export const ${exportName}`),
|
|
`modern navigation utility must export ${exportName}`
|
|
)
|
|
}
|
|
|
|
for (const [legacyContext, pagePrefix] of [
|
|
['ordermgr', 'order'],
|
|
['partymgr', 'party'],
|
|
['sfa', 'SalesForceAutomation'],
|
|
['projectmgr', 'projectmgr'],
|
|
['webtools', 'webtools']
|
|
]) {
|
|
assert.match(
|
|
navigationSource,
|
|
new RegExp(`${legacyContext}: '${pagePrefix}'`),
|
|
`/${legacyContext}/control routes must map to ${pagePrefix} PageDefinition ids`
|
|
)
|
|
}
|
|
|
|
const generatedContexts = new Set()
|
|
for (const route of routeManifest) {
|
|
const context = String(route.legacyPath || '').match(/^\/([^/]+)\/control\//)?.[1]
|
|
if (context) generatedContexts.add(context)
|
|
}
|
|
|
|
const missingContextPrefixes = [...generatedContexts]
|
|
.filter((context) => !navigationExports.legacyControlPagePrefixes[context])
|
|
.sort()
|
|
assert.deepEqual(
|
|
missingContextPrefixes,
|
|
[],
|
|
'every generated legacy /control context must map to a PageDefinition prefix'
|
|
)
|
|
|
|
const mismatchedLegacyTargets = routeManifest
|
|
.map((route) => ({
|
|
legacyPath: route.legacyPath,
|
|
pageId: route.pageId,
|
|
actual: navigationExports.pageHashForTarget(route.legacyPath, 'ofbiz'),
|
|
expected: `#/pages/${route.pageId}`
|
|
}))
|
|
.filter((item) => item.actual !== item.expected)
|
|
assert.deepEqual(
|
|
mismatchedLegacyTargets,
|
|
[],
|
|
'legacy /control targets must normalize to existing #/pages PageDefinition ids'
|
|
)
|
|
|
|
const missingQuickPages = modules.flatMap((module) =>
|
|
module.quickPages
|
|
.filter((pageId) => !routePageIds.has(pageId))
|
|
.map((pageId) => ({ moduleId: module.id, pageId }))
|
|
)
|
|
assert.deepEqual(
|
|
missingQuickPages,
|
|
[],
|
|
'moduleCatalog quickPages must point at generated PageDefinition ids'
|
|
)
|
|
|
|
const unownedRoutes = routeManifest
|
|
.filter((route) => !modules.some((module) => moduleOwnsRoute(module, route)))
|
|
.map((route) => ({
|
|
pageId: route.pageId,
|
|
legacyPath: route.legacyPath,
|
|
component: route.component
|
|
}))
|
|
assert.deepEqual(
|
|
unownedRoutes,
|
|
[],
|
|
'every generated route must be owned by a Modern UI ERP module'
|
|
)
|
|
|
|
assert.doesNotMatch(
|
|
navigationSource,
|
|
/startsWith\('http'\)[\s\S]*return value/,
|
|
'absolute external URLs must not be treated as internal SPA page routes'
|
|
)
|
|
|
|
assert.match(
|
|
pageRendererSource,
|
|
/import \{[^}]*navigateTarget[^}]*normalizeModernNavigationTarget[^}]*\} from '\.\.\/\.\.\/utils\/modernNavigation'/,
|
|
'page renderer must use the shared modern navigation utility'
|
|
)
|
|
assert.match(
|
|
pageRendererSource,
|
|
/isSharedInternalLegacyControlTarget|sharedLegacyControlPagePrefixes|pageHashForTarget/,
|
|
'page renderer compatibility shims must delegate to the shared modern navigation utility'
|
|
)
|
|
assert.match(
|
|
pageRendererSource,
|
|
/normalizeModernNavigationTarget\(target, props\.page\.pageId\.split\('__'\)\[0\]/,
|
|
'action navigation targets must be normalized before changing location'
|
|
)
|
|
|
|
for (const [label, componentSource] of [
|
|
['adapter block', adapterBlockSource],
|
|
['search form', searchFormSource],
|
|
['entity form', entityFormSource]
|
|
]) {
|
|
assert.match(
|
|
componentSource,
|
|
/import \{[^}]*navigateTarget[^}]*\} from '\.\.\/\.\.\/utils\/modernNavigation'/,
|
|
`${label} must use the shared modern navigation utility`
|
|
)
|
|
assert.match(
|
|
componentSource,
|
|
/navigateTarget\([^)]*field\.target|navigateTarget\(item\.target/,
|
|
`${label} links must normalize generated internal targets`
|
|
)
|
|
}
|
|
|
|
console.log('Modern navigation source policy passed.')
|