迭代 ONE-OS 多原型:原型导航与版本追踪上线;车辆氢费明细成本单价校验增强;多业务台账/财务/工作台页面迭代与标注目录同步。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -159,6 +159,14 @@ function syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncPrototypeNavMenu(projectRoot, sidebarItems) {
|
||||
const navMenuPath = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/nav-menu.json');
|
||||
if (!fs.existsSync(path.dirname(navMenuPath))) return false;
|
||||
writeJson(navMenuPath, { prototypes: sidebarItems });
|
||||
console.log('[sync] oneos-prototype-nav: 已更新 nav-menu.json');
|
||||
return true;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const projectRoot = args.projectRoot || path.resolve(__dirname, '..');
|
||||
@@ -183,6 +191,7 @@ function main() {
|
||||
}
|
||||
}
|
||||
console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`);
|
||||
syncPrototypeNavMenu(projectRoot, sidebarItems);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
339
scripts/sync-prototype-nav-registry.mjs
Normal file
339
scripts/sync-prototype-nav-registry.mjs
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* 原型导航注册表:检测原型变更、递增版本、写入变更日志,并同步 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();
|
||||
}
|
||||
130
scripts/sync-xll-nav-menu.mjs
Normal file
130
scripts/sync-xll-nav-menu.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 从小羚羚「小程序」项目同步页面目录到 oneos 原型导航。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/sync-xll-nav-menu.mjs
|
||||
* node scripts/sync-xll-nav-menu.mjs --project-root /path/to/oneos1.2
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const ROUTE_TO_PAGE = {
|
||||
todo: 'todo',
|
||||
business: 'business',
|
||||
map: 'map',
|
||||
mine: 'mine',
|
||||
audit: 'audit',
|
||||
delivery: 'delivery',
|
||||
inspection: 'inspection',
|
||||
vehicle: 'vehicle',
|
||||
thirdReturn: 'third-return',
|
||||
'third-return': 'third-return',
|
||||
replace: 'replace',
|
||||
'audit-return': 'audit-return',
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { projectRoot: '' };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--project-root' && argv[i + 1]) {
|
||||
args.projectRoot = path.resolve(argv[++i].trim());
|
||||
}
|
||||
}
|
||||
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 resolvePageId(route) {
|
||||
const key = String(route || '').trim();
|
||||
return ROUTE_TO_PAGE[key] || key;
|
||||
}
|
||||
|
||||
function buildLinksFromFolder(folder) {
|
||||
const links = [];
|
||||
for (const child of folder.children || []) {
|
||||
if (child?.type !== 'route' || !child.route) continue;
|
||||
const pageId = resolvePageId(child.route);
|
||||
links.push({
|
||||
id: child.id || `xll-route-${pageId}`,
|
||||
title: child.title || pageId,
|
||||
pageId,
|
||||
route: child.route,
|
||||
});
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
export function syncXllNavMenu(projectRoot) {
|
||||
const oneosRoot = projectRoot || path.resolve(__dirname, '..');
|
||||
const sourceConfigPath = path.join(oneosRoot, 'src/prototypes/oneos-prototype-nav/xll-nav-source.json');
|
||||
const outputPath = path.join(oneosRoot, 'src/prototypes/oneos-prototype-nav/xll-nav-menu.json');
|
||||
const sourceConfig = readJson(sourceConfigPath);
|
||||
if (!sourceConfig?.projectRoot) {
|
||||
throw new Error(`缺少小羚羚同步配置:${sourceConfigPath}`);
|
||||
}
|
||||
|
||||
const xllRoot = path.resolve(sourceConfig.projectRoot);
|
||||
const annotationPath = path.join(xllRoot, sourceConfig.annotationSource);
|
||||
const annotation = readJson(annotationPath);
|
||||
if (!annotation) {
|
||||
throw new Error(`无法读取小羚羚标注目录:${annotationPath}`);
|
||||
}
|
||||
|
||||
const includeIds = new Set(sourceConfig.includeDirectoryIds || ['directory-main', 'directory-modules']);
|
||||
const groups = [];
|
||||
for (const node of annotation.directory?.nodes || []) {
|
||||
if (node?.type !== 'folder' || !includeIds.has(node.id)) continue;
|
||||
const links = buildLinksFromFolder(node);
|
||||
if (!links.length) continue;
|
||||
groups.push({
|
||||
id: node.id,
|
||||
title: node.title || '分组',
|
||||
defaultExpanded: node.defaultExpanded !== false,
|
||||
links,
|
||||
});
|
||||
}
|
||||
|
||||
const devInfoPath = path.join(xllRoot, sourceConfig.devServerInfo || '.axhub/make/.dev-server-info.json');
|
||||
const devInfo = readJson(devInfoPath, {});
|
||||
const runtimeOrigin = String(devInfo.origin || '').trim() || 'http://localhost:51721';
|
||||
const prototypeId = sourceConfig.prototypeId || 'xll-miniapp';
|
||||
|
||||
const menu = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
title: '小羚羚',
|
||||
sectionId: 'folder-prototypes-xll-miniapp',
|
||||
description: '氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。',
|
||||
prototypeId,
|
||||
runtimeOrigin,
|
||||
hrefPrefix: `${runtimeOrigin.replace(/\/$/u, '')}/prototypes/${prototypeId}`,
|
||||
groups,
|
||||
};
|
||||
|
||||
writeJson(outputPath, menu);
|
||||
return { outputPath, groupCount: groups.length, linkCount: groups.reduce((n, g) => n + g.links.length, 0) };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const result = syncXllNavMenu(args.projectRoot);
|
||||
console.log(`[sync] xll-nav-menu: ${result.groupCount} 个分组,${result.linkCount} 个页面入口`);
|
||||
console.log(`[sync] 写入 ${result.outputPath}`);
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
main();
|
||||
}
|
||||
Reference in New Issue
Block a user