/** * 原型导航注册表:检测原型变更、递增版本、写入变更日志,并同步 nav-menu。 * * 用法: * node scripts/sync-prototype-nav-registry.mjs * node scripts/sync-prototype-nav-registry.mjs --prototype vehicle-h2-fee-ledger * node scripts/sync-prototype-nav-registry.mjs --prototype vehicle-h2-fee-ledger --note "成本单价校验" * CHANGELOG_NOTE="修复筛选" node scripts/sync-prototype-nav-registry.mjs --prototype customer-management */ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const NAV_PROTOTYPE_ID = 'oneos-prototype-nav'; const REGISTRY_RELATIVE = 'src/prototypes/oneos-prototype-nav/prototype-registry.json'; const TRACKED_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.json', '.md', '.html']); const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', 'dist', 'coverage']); const IGNORED_FILE_NAMES = new Set(['prototype-registry.json', 'nav-menu.json']); const MAX_CHANGELOG_PER_PROTOTYPE = 30; const MAX_RECENT_UPDATES = 40; function parseArgs(argv) { const args = { prototype: '', projectRoot: '', note: process.env.CHANGELOG_NOTE?.trim() || '', quiet: false, skipDirectorySync: false, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--prototype' && argv[i + 1]) { args.prototype = argv[++i].trim(); } else if (arg === '--project-root' && argv[i + 1]) { args.projectRoot = path.resolve(argv[++i].trim()); } else if (arg === '--note' && argv[i + 1]) { args.note = argv[++i].trim(); } else if (arg === '--quiet') { args.quiet = true; } else if (arg === '--skip-directory-sync') { args.skipDirectorySync = true; } } return args; } function readJson(filePath, fallback = null) { if (!fs.existsSync(filePath)) return fallback; return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } function log(message, quiet) { if (!quiet) console.log(message); } function prototypeIdFromItemKey(itemKey) { const normalized = String(itemKey || '').trim().replace(/^\/+/u, ''); const match = normalized.match(/^prototypes\/(.+)$/u); return match ? match[1] : ''; } function loadSidebarPrototypes(projectRoot) { const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json'); if (!fs.existsSync(sidebarPath)) { throw new Error(`缺少侧边栏配置:${sidebarPath}`); } const sidebar = readJson(sidebarPath, {}); return Array.isArray(sidebar.prototypes) ? sidebar.prototypes : []; } function collectNavPrototypeTitles(sidebarItems) { const titles = new Map(); const walk = (items) => { for (const item of items || []) { if (item?.kind === 'item') { const prototypeId = prototypeIdFromItemKey(item.itemKey); if (prototypeId && prototypeId !== NAV_PROTOTYPE_ID) { titles.set(prototypeId, item.title || prototypeId); } } if (Array.isArray(item?.children)) walk(item.children); } }; walk(sidebarItems); return titles; } function shouldTrackFile(relativePath) { const base = path.basename(relativePath); if (IGNORED_FILE_NAMES.has(base)) return false; if (relativePath === 'annotation-source.json') return false; const ext = path.extname(relativePath).toLowerCase(); return TRACKED_EXTENSIONS.has(ext); } function listTrackedFiles(prototypeDir) { const files = []; const walk = (dir, prefix = '') => { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (IGNORED_DIR_NAMES.has(entry.name)) continue; const rel = prefix ? `${prefix}/${entry.name}` : entry.name; const abs = path.join(dir, entry.name); if (entry.isDirectory()) { walk(abs, rel); continue; } if (shouldTrackFile(rel)) files.push(rel); } }; walk(prototypeDir); return files.sort((a, b) => a.localeCompare(b)); } function hashPrototypeDir(prototypeDir) { const files = listTrackedFiles(prototypeDir); const hash = crypto.createHash('sha256'); hash.update(`files:${files.length}\n`); for (const rel of files) { const abs = path.join(prototypeDir, rel); const stat = fs.statSync(abs); hash.update(`${rel}\n${stat.mtimeMs}\n${stat.size}\n`); hash.update(fs.readFileSync(abs)); hash.update('\n'); } return { hash: hash.digest('hex'), files }; } function formatNowParts(date = new Date()) { const pad = (n) => String(n).padStart(2, '0'); return { iso: date.toISOString(), date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, time: `${pad(date.getHours())}:${pad(date.getMinutes())}`, label: `${date.getMonth() + 1}月${date.getDate()}日`, }; } function summarizeChange(changedFiles, note) { if (note) return note; const names = changedFiles.map((f) => path.basename(f)).join(' '); if (/PRD|requirements|需求/i.test(names) || changedFiles.some((f) => f.includes('.spec/') && f.endsWith('.md'))) { return '更新需求说明与标注'; } if (changedFiles.some((f) => f.endsWith('.css'))) { return '更新页面样式'; } if (changedFiles.some((f) => /\.(tsx|jsx)$/.test(f) || f.includes('/pages/'))) { return '更新页面逻辑与交互'; } if (changedFiles.length === 1) { return `更新 ${path.basename(changedFiles[0])}`; } return `更新 ${changedFiles.length} 个文件`; } function diffChangedFiles(previousFiles, currentFiles) { const prevSet = new Set(previousFiles || []); return currentFiles.filter((file) => !prevSet.has(file)); } function bumpVersion(revision) { return `v1.${revision}`; } function createRegistrySkeleton() { return { version: 1, updatedAt: new Date().toISOString(), prototypes: {}, recentUpdates: [], }; } function upsertPrototypeRecord(registry, prototypeId, title, contentHash, files, options) { const existing = registry.prototypes[prototypeId] || { title, version: 'v1.0', revision: 0, lastUpdated: null, contentHash: '', trackedFiles: [], changelog: [], }; const changed = existing.contentHash && existing.contentHash !== contentHash; const now = formatNowParts(); if (!existing.contentHash) { existing.title = title; existing.contentHash = contentHash; existing.trackedFiles = files; existing.lastUpdated = now.iso; registry.prototypes[prototypeId] = existing; return { changed: false, entry: null }; } if (!changed) { existing.title = title; existing.trackedFiles = files; registry.prototypes[prototypeId] = existing; return { changed: false, entry: null }; } const changedFiles = diffChangedFiles(existing.trackedFiles, files); const summary = summarizeChange(changedFiles.length ? changedFiles : files, options.note); const nextRevision = (existing.revision || 0) + 1; const version = bumpVersion(nextRevision); const entry = { version, date: now.date, time: now.time, summary, files: (changedFiles.length ? changedFiles : files).slice(0, 12), }; existing.title = title; existing.version = version; existing.revision = nextRevision; existing.lastUpdated = now.iso; existing.contentHash = contentHash; existing.trackedFiles = files; existing.changelog = [entry, ...(existing.changelog || [])].slice(0, MAX_CHANGELOG_PER_PROTOTYPE); registry.prototypes[prototypeId] = existing; return { changed: true, entry: { prototypeId, title, ...entry } }; } function pushRecentUpdate(registry, update) { if (!update) return; registry.recentUpdates = [ update, ...(registry.recentUpdates || []).filter((item) => !( item.prototypeId === update.prototypeId && item.version === update.version )), ].slice(0, MAX_RECENT_UPDATES); } function syncNavMenuAndDirectories(projectRoot, quiet) { const script = path.join(projectRoot, 'scripts/sync-project-prototype-directory.mjs'); const result = spawnSync(process.execPath, [script, `--project-root=${projectRoot}`], { cwd: projectRoot, encoding: 'utf8', }); if (result.status !== 0) { const err = result.stderr || result.stdout || 'unknown error'; throw new Error(`同步原型目录失败:${err}`); } if (!quiet && result.stdout?.trim()) { console.log(result.stdout.trim()); } } function syncXllNavMenu(projectRoot, quiet) { const script = path.join(projectRoot, 'scripts/sync-xll-nav-menu.mjs'); if (!fs.existsSync(script)) return false; const result = spawnSync(process.execPath, [script, `--project-root=${projectRoot}`], { cwd: projectRoot, encoding: 'utf8', }); if (result.status !== 0) { const err = result.stderr || result.stdout || 'unknown error'; throw new Error(`同步小羚羚导航失败:${err}`); } if (!quiet && result.stdout?.trim()) { console.log(result.stdout.trim()); } return true; } export function syncPrototypeNavRegistry(options = {}) { const projectRoot = options.projectRoot || path.resolve(__dirname, '..'); const registryPath = path.join(projectRoot, REGISTRY_RELATIVE); const sidebarItems = loadSidebarPrototypes(projectRoot); const titleMap = collectNavPrototypeTitles(sidebarItems); const registry = readJson(registryPath, createRegistrySkeleton()) || createRegistrySkeleton(); if (!options.skipDirectorySync) { syncNavMenuAndDirectories(projectRoot, options.quiet); syncXllNavMenu(projectRoot, options.quiet); } const targetIds = options.prototype ? [options.prototype] : [...titleMap.keys()]; let bumped = 0; for (const prototypeId of targetIds) { if (prototypeId === NAV_PROTOTYPE_ID) continue; const title = titleMap.get(prototypeId) || prototypeId; const prototypeDir = path.join(projectRoot, 'src/prototypes', prototypeId); if (!fs.existsSync(prototypeDir)) { log(`[skip] ${prototypeId}: 目录不存在`, options.quiet); continue; } const { hash, files } = hashPrototypeDir(prototypeDir); const result = upsertPrototypeRecord(registry, prototypeId, title, hash, files, { note: options.note && options.prototype === prototypeId ? options.note : '', }); if (result.changed) { bumped += 1; pushRecentUpdate(registry, result.entry); log(`[version] ${prototypeId}: ${result.entry.version} — ${result.entry.summary}`, options.quiet); } } registry.updatedAt = new Date().toISOString(); writeJson(registryPath, registry); return { bumped, registryPath, prototypeCount: targetIds.length }; } function main() { const args = parseArgs(process.argv.slice(2)); const projectRoot = args.projectRoot || path.resolve(__dirname, '..'); const result = syncPrototypeNavRegistry({ projectRoot, prototype: args.prototype, note: args.note, quiet: args.quiet, skipDirectorySync: args.skipDirectorySync, }); log(`完成:检查 ${result.prototypeCount} 个原型,新增版本 ${result.bumped} 个`, args.quiet); } const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isMain) { main(); }