迭代 ONE-OS 多原型:统一标注壳与操作规范,租赁明细/合同/提车应收款增强,新增任务工单,同步导航与合包页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-12 22:44:21 +08:00
parent 824512a224
commit 131b963e4a
317 changed files with 38569 additions and 12009 deletions

View File

@@ -0,0 +1,339 @@
/**
* 从 Git 提交历史生成/补全原型导航注册表中的详细变更日志。
*
* 用法:
* node scripts/enrich-prototype-registry-from-git.mjs
* node scripts/enrich-prototype-registry-from-git.mjs --prototype vehicle-h2-fee-ledger
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const REGISTRY_PATH = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/prototype-registry.json');
const TRACKED_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.json', '.md', '.html']);
const IGNORED_FILE_NAMES = new Set(['prototype-registry.json', 'nav-menu.json']);
const MAX_CHANGELOG = 30;
const MAX_RECENT = 40;
function parseArgs(argv) {
const args = { prototype: '' };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--prototype' && argv[i + 1]) args.prototype = argv[++i].trim();
}
return args;
}
function shouldTrackFile(relativePath) {
const base = path.basename(relativePath);
if (IGNORED_FILE_NAMES.has(base)) return false;
if (relativePath === 'annotation-source.json') return false;
return TRACKED_EXTENSIONS.has(path.extname(relativePath).toLowerCase());
}
function normalizePrototypePath(filePath, prototypeId) {
const normalized = String(filePath || '').replace(/\\/gu, '/').trim();
const prefix = `src/prototypes/${prototypeId}/`;
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
return null;
}
function parseCommitBlocks(raw) {
const blocks = raw.split(/\n?----\n/u).map((block) => block.trim()).filter(Boolean);
const commits = [];
for (const block of blocks) {
const lines = block.split('\n');
const hash = lines[0]?.trim();
if (!/^[0-9a-f]{7,40}$/iu.test(hash || '')) continue;
const dateLine = lines[1]?.trim();
const subject = lines[2]?.trim() || '';
if (!dateLine) continue;
const fileStart = lines.findIndex((line, index) => index > 2 && line.trim() === '');
const bodyEnd = fileStart >= 0 ? fileStart : lines.length;
const body = lines.slice(3, bodyEnd).join('\n').trim();
const files = (fileStart >= 0 ? lines.slice(fileStart + 1) : [])
.map((line) => line.trim())
.filter(Boolean);
commits.push({ hash, dateLine, subject, body, files });
}
return commits;
}
function getPrototypeCommits(prototypeId) {
const relDir = `src/prototypes/${prototypeId}`;
if (!fs.existsSync(path.join(projectRoot, relDir))) return [];
let raw = '';
try {
raw = execSync(
`git log --pretty=format:----%n%H%n%ci%n%s%n%b --name-only -- ${relDir}`,
{ cwd: projectRoot, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 },
);
} catch {
return [];
}
return parseCommitBlocks(raw);
}
function formatDateParts(dateLine) {
const match = dateLine.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/u);
if (!match) {
const date = new Date(dateLine);
if (Number.isNaN(date.getTime())) return { date: '1970-01-01', time: '00:00', iso: null };
return {
date: date.toISOString().slice(0, 10),
time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`,
iso: date.toISOString(),
};
}
const [, y, m, d, hh, mm] = match;
return {
date: `${y}-${m}-${d}`,
time: `${hh}:${mm}`,
iso: new Date(`${y}-${m}-${d}T${hh}:${mm}:00+08:00`).toISOString(),
};
}
function extractBodyLines(body) {
return body
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !/^Co-authored-by:/iu.test(line))
.map((line) => line.replace(/^[-*•]\s*/u, '').trim())
.filter(Boolean);
}
function strictKeywords(title, prototypeId) {
const keywords = new Set([title, prototypeId]);
for (const part of String(title || '').split(/[·、/()\s「」]+/u)) {
const trimmed = part.trim();
if (trimmed.length >= 3) keywords.add(trimmed);
}
if (title.includes('氢费明细')) keywords.add('氢费明细');
else if (title.includes('氢费')) keywords.add('氢费');
if (title.includes('原型导航')) keywords.add('原型导航');
if (title.includes('合同模板')) keywords.add('合同模板');
if (title.includes('租赁合同')) keywords.add('租赁合同');
if (title.includes('租赁业务')) keywords.add('租赁业务');
if (title.includes('收款记录')) keywords.add('收款记录');
if (title.includes('维修保养')) keywords.add('维修保养');
if (title.includes('还车应结')) keywords.add('还车应结');
return [...keywords];
}
function lineMatchesPrototype(line, title, prototypeId) {
if (!line) return false;
if (line.includes(title)) return true;
if (line.toLowerCase().includes(prototypeId.toLowerCase())) return true;
return strictKeywords(title, prototypeId).some((keyword) => (
keyword.length >= 3 && line.includes(keyword)
));
}
function commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines) {
if (changedFiles.length > 0) return true;
const onlyAnnotation = allChangedFiles.length > 0
&& allChangedFiles.every((file) => file === 'annotation-source.json');
const textMatches = [commit.subject, ...bodyLines].some((line) => (
lineMatchesPrototype(line, title, prototypeId)
));
if (textMatches && onlyAnnotation) return true;
if (textMatches) return true;
return false;
}
function summarizeFileChanges(files) {
const notes = [];
const prd = files.filter((f) => f.includes('.spec/') && f.endsWith('.md'));
const comments = files.filter((f) => f.includes('prototype-comments') || f.includes('annotation-source'));
const styles = files.filter((f) => f.endsWith('.css'));
const logic = files.filter((f) => /\.(tsx|jsx|ts|js)$/u.test(f) && !f.endsWith('.css'));
const data = files.filter((f) => f.includes('/data/') || f.endsWith('.json'));
if (logic.length) {
const names = [...new Set(logic.map((f) => path.basename(f)))].slice(0, 4);
notes.push(`页面逻辑与交互:${names.join('、')}${logic.length > names.length ? ' 等' : ''}`);
}
if (styles.length) notes.push(`样式与布局:${[...new Set(styles.map((f) => path.basename(f)))].join('、')}`);
if (prd.length) notes.push(`需求说明与 PRD${prd.map((f) => path.basename(f)).join('、')}`);
if (comments.length) notes.push('原型标注目录与批注说明同步');
if (data.length) notes.push(`演示数据与配置:${data.map((f) => path.basename(f)).join('、')}`);
return notes;
}
function buildSummary(subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation) {
const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId));
if (matched.length === 1) return matched[0];
if (matched.length > 1) return matched[0];
const fragments = splitSubjectFragments(subject, prototypeId, title);
if (fragments.length === 1) return fragments[0];
if (fragments.length > 1) return fragments[0];
if (onlyAnnotation && changedFiles.length === 0) return '原型标注目录同步';
if (changedFiles.length) return summarizeFileChanges(changedFiles)[0] || subject;
if (lineMatchesPrototype(subject, title, prototypeId)) return subject;
return subject;
}
function dedupeDetails(summary, details) {
return [...new Set(details)].filter((line) => (
line
&& line !== summary
&& !summary.includes(line)
&& line.length > 2
));
}
function splitSubjectFragments(subject, prototypeId, title) {
const segments = subject
.split(/[;]/u)
.map((part) => part.trim())
.filter(Boolean);
const matched = segments.filter((segment) => lineMatchesPrototype(segment, title, prototypeId));
if (!matched.length) return [];
const fragments = [];
for (const segment of matched) {
const colonIndex = segment.indexOf('');
const tail = colonIndex >= 0 ? segment.slice(colonIndex + 1) : segment;
tail.split(/[、,]/u)
.map((part) => part.trim())
.filter((part) => part.length >= 4)
.forEach((part) => fragments.push(part));
}
return [...new Set(fragments)];
}
function buildDetails(subject, bodyLines, prototypeId, title, changedFiles) {
const details = [];
const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId));
details.push(...matched);
for (const fragment of splitSubjectFragments(subject, prototypeId, title)) {
if (!details.includes(fragment) && fragment !== subject) details.push(fragment);
}
for (const note of summarizeFileChanges(changedFiles)) {
if (!details.includes(note)) details.push(note);
}
if (!details.length && changedFiles.length) {
details.push(`变更 ${changedFiles.length} 个文件`);
}
return details.filter((line) => line !== subject);
}
function bumpVersion(indexFromOldest) {
return `v1.${indexFromOldest}`;
}
function buildChangelogFromGit(prototypeId, title) {
const commits = getPrototypeCommits(prototypeId);
if (!commits.length) return [];
const chronological = [...commits].reverse();
const entries = [];
for (const commit of chronological) {
const { date, time } = formatDateParts(commit.dateLine);
const bodyLines = extractBodyLines(commit.body);
const changedFiles = commit.files
.map((file) => normalizePrototypePath(file, prototypeId))
.filter((file) => file && shouldTrackFile(file));
const allChangedFiles = commit.files
.map((file) => normalizePrototypePath(file, prototypeId))
.filter(Boolean);
const onlyAnnotation = allChangedFiles.length > 0
&& allChangedFiles.every((file) => file === 'annotation-source.json');
if (!commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines)) {
continue;
}
const summary = buildSummary(commit.subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation);
const details = dedupeDetails(
summary,
buildDetails(commit.subject, bodyLines, prototypeId, title, changedFiles),
);
entries.push({
version: '',
date,
time,
summary,
details,
commit: commit.hash.slice(0, 7),
files: [...new Set(changedFiles)].slice(0, 12),
});
}
return entries.reverse().map((entry, index, list) => ({
...entry,
version: bumpVersion(list.length - index),
}));
}
function rebuildRecentUpdates(registry) {
const rows = [];
for (const [prototypeId, record] of Object.entries(registry.prototypes || {})) {
const latest = record.changelog?.[0];
if (!latest) continue;
rows.push({
prototypeId,
title: record.title || prototypeId,
version: latest.version,
date: latest.date,
time: latest.time,
summary: latest.summary,
details: latest.details || [],
files: latest.files || [],
commit: latest.commit,
_sort: `${latest.date}T${latest.time}`,
});
}
rows.sort((a, b) => b._sort.localeCompare(a._sort));
return rows.slice(0, MAX_RECENT).map(({ _sort, ...row }) => row);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
const prototypeIds = args.prototype
? [args.prototype]
: Object.keys(registry.prototypes || {});
let updated = 0;
for (const prototypeId of prototypeIds) {
const record = registry.prototypes[prototypeId];
if (!record) continue;
const changelog = buildChangelogFromGit(prototypeId, record.title || prototypeId);
if (!changelog.length) continue;
record.changelog = changelog.slice(0, MAX_CHANGELOG);
record.revision = changelog.length;
record.version = changelog[0].version;
const latestIso = getPrototypeCommits(prototypeId)[0];
if (latestIso) {
const { iso } = formatDateParts(latestIso.dateLine);
if (iso) record.lastUpdated = iso;
}
registry.prototypes[prototypeId] = record;
updated += 1;
console.log(`[git] ${prototypeId}: ${changelog.length} entries → ${changelog[0].version}`);
}
registry.recentUpdates = rebuildRecentUpdates(registry);
registry.updatedAt = new Date().toISOString();
fs.writeFileSync(REGISTRY_PATH, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
console.log(`完成:补全 ${updated} 个原型的 Git 变更日志`);
}
main();

View File

@@ -0,0 +1,62 @@
/**
* 将原型 index.tsx 中的 AnnotationViewer 迁移为 PrototypeAnnotationHost。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
const hostImport = "import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';";
function listIndexFiles() {
return fs.readdirSync(prototypesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(prototypesRoot, entry.name, 'index.tsx'))
.filter((filePath) => fs.existsSync(filePath))
.filter((filePath) => fs.readFileSync(filePath, 'utf8').includes('AnnotationViewer'));
}
function migrateFile(filePath) {
let content = fs.readFileSync(filePath, 'utf8');
if (!content.includes('AnnotationViewer')) return false;
if (content.includes('PrototypeAnnotationHost')) {
console.log(`[skip] ${path.relative(projectRoot, filePath)}`);
return false;
}
content = content.replace(
/import\s*\{([^}]*)\}\s*from\s*'@axhub\/annotation';/u,
(match, importsBlock) => {
const cleaned = importsBlock
.split(',')
.map((item) => item.trim())
.filter((item) => item && item !== 'AnnotationViewer')
.join(',\n ');
return `import {\n ${cleaned}\n} from '@axhub/annotation';`;
},
);
if (!content.includes(hostImport)) {
content = content.replace(
/\} from '@axhub\/annotation';/u,
`} from '@axhub/annotation';\n${hostImport}`,
);
}
content = content
.replace(/<AnnotationViewer\b/gu, '<PrototypeAnnotationHost')
.replace(/<\/AnnotationViewer>/gu, '</PrototypeAnnotationHost>')
.replace(/React\.createElement\(AnnotationViewer,/gu, 'React.createElement(PrototypeAnnotationHost,');
fs.writeFileSync(filePath, content, 'utf8');
console.log(`[migrate] ${path.relative(projectRoot, filePath)}`);
return true;
}
let changed = 0;
for (const filePath of listIndexFiles()) {
if (migrateFile(filePath)) changed += 1;
}
console.log(`完成:迁移 ${changed} 个 index.tsx`);

View File

@@ -66,7 +66,7 @@ function toPublishPath(resource) {
function resolveObjectPrefix(entry) {
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u);
if (prototypeMatch?.[1]) return prototypeMatch[1];
if (prototypeMatch?.[1]) return `prototypes/${prototypeMatch[1]}`;
const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
return entry.id;

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve('src/prototypes');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) {
if (name === 'node_modules' || name === '.spec') continue;
walk(full, out);
} else if (/\.(jsx|tsx)$/.test(name)) out.push(full);
}
return out;
}
function cleanup(source) {
let s = source;
// Fix corrupted fragment from partial breadcrumb removal (37-新增后装设备)
s = s.replace(
/\{ style: styles\.page \},\s*\{ e\.preventDefault\(\); \} \}, '运维管理'\),[\s\S]*?React\.createElement\('span', \{ style: styles\.breadcrumbCurrent \}, '[^']+'\)\s*\),/g,
'{ style: styles.page },'
);
// Top bar: breadcrumb spans + req button -> req button only
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\),\s*(React\.createElement\(Button, \{ type: 'link'[\s\S]*?'查看需求说明'\)\s*)\),/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1),`
);
// Lease contract rows with broken partial breadcrumb
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\),\s*(React\.createElement\('(?:button|span)'[\s\S]*?'查看需求说明'\)[^)]*\))/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1)`
);
// Lease contract row - only breadcrumb spans ending with )), no req
s = s.replace(
/React\.createElement\('div', \{ style: \{ marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\)\),/g,
''
);
// 后装设备 list page partial breadcrumb block inside createElement
s = s.replace(
/React\.createElement\('div', \{ style: styles\.breadcrumbLeft \},[\s\S]*?React\.createElement\('span', \{ style: styles\.breadcrumbCurrent \}, '[^']+'\)\s*\),/g,
''
);
// Orphan breadcrumb link lines after broken removal
s = s.replace(
/\n\t\t\tReact\.createElement\('a', \{ href: '#', style: styles\.breadcrumbLink[\s\S]*?breadcrumbCurrent \}, '[^']+'\)\n\t\t\),/g,
''
);
// Remove breadcrumbItems variable blocks
s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\t\tbreadcrumbItems\.push\([^)]+\);\n/g, '');
// 备车/交车任务 inline breadcrumb rows (partial)
s = s.replace(
/React\.createElement\('div', \{ style: styles\.breadcrumb[^}]*\},[\s\S]*?\),?\n?/g,
''
);
s = s.replace(
/React\.createElement\('span', \{ key: '[^']+', style: styles\.breadcrumbSep \}, ' \/ '\),?\n?\s*/g,
''
);
// Arco/fault pages: remove Breadcrumb from Layout header if any remain
s = s.replace(/,\s*breadcrumb:\s*React\.createElement\(Breadcrumb[\s\S]*?\)/g, '');
// business pages partial breadcrumb in flex div
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', alignItems: 'center', marginBottom: 16 \} \},[\s\S]*?styles\.breadcrumbSep[\s\S]*?\),/g,
''
);
// Fix double spaces in lease contract lines
s = s.replace(/marginBottom: 16 \} \},\s{2}React/g, 'marginBottom: 16 } }, React');
// 27-交车管理: broken topbar after breadcrumb removal
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\(Button,/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, React.createElement(Button,`
);
return s;
}
function fixCollectPageIndent(source) {
return source.replace(
/(\}, '返回'\),\n)\s+React\.createElement\(Button,/g,
`$1\t\t\tReact.createElement(Button,`
);
}
const files = walk(ROOT);
let changed = 0;
for (const file of files) {
let source = fs.readFileSync(file, 'utf8');
if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) continue;
const next = fixCollectPageIndent(cleanup(source));
if (next !== source) {
fs.writeFileSync(file, next);
changed++;
console.log('fixed:', path.relative(process.cwd(), file));
}
}
console.log(`Pass 2 done. ${changed} file(s).`);

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve('src/prototypes');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) {
if (name === 'node_modules' || name === '.spec') continue;
walk(full, out);
} else if (/\.(jsx|tsx)$/.test(name)) out.push(full);
}
return out;
}
function cleanup(source) {
let s = source;
// Lease contract topbar: remove span trail, keep req action
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 \} \}, React\.createElement\('span'[\s\S]*?\)\),\s*(React\.createElement\('(?:button|span)'[\s\S]*?'查看需求说明'\)[^)]*\)\),/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1),`
);
// Topbar only breadcrumb spans (no req button)
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 \} \}, React\.createElement\('span'[\s\S]*?\)\),/g,
''
);
// Unused breadcrumb vars
s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\t\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\tvar breadcrumbNodes = \[[\s\S]*?\];\n/g, '\n');
// Broken 后装设备 page wrapper
s = s.replace(
/(\{ style: styles\.page \},\n)\s*React\.createElement\('a', \{ href: '#', style: styles\.requirementLink[\s\S]*?'查看需求说明'\)\n\t\t\),/g,
`$1\t\tReact.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } },\n\t\t\tReact.createElement('a', { href: '#', style: styles.requirementLink, onClick: function (e) { e.preventDefault(); setShowRequirementModal(true); } }, '查看需求说明')\n\t\t),\n`
);
// Empty if blocks left from breadcrumb removal in 业务台账
s = s.replace(/\n\tif \(view === 'sales' \|\| view === 'project'\) \{\s*\}\n\tif \(view === 'project'\) \{\s*\}\n/g, '\n');
return s;
}
const files = walk(ROOT);
let changed = 0;
for (const file of files) {
let source = fs.readFileSync(file, 'utf8');
if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) continue;
const next = cleanup(source);
if (next !== source) {
fs.writeFileSync(file, next);
changed++;
console.log('fixed:', path.relative(process.cwd(), file));
}
}
console.log(`Pass 3 done. ${changed} file(s).`);

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* Remove breadcrumb UI from prototype pages under src/prototypes.
* Keeps sibling actions such as「查看需求说明」when present in breadcrumbRight.
*/
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve('src/prototypes');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
if (name === 'node_modules' || name === '.spec') continue;
walk(full, out);
} else if (/\.(jsx|tsx)$/.test(name)) {
out.push(full);
}
}
return out;
}
function removeBalancedFrom(source, openIndex) {
let i = openIndex;
if (source[i] !== '(') return null;
let depth = 0;
let inStr = null;
let escape = false;
for (; i < source.length; i++) {
const ch = source[i];
if (inStr) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inStr) inStr = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
inStr = ch;
continue;
}
if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return source.slice(openIndex, i + 1);
}
}
return null;
}
function removeReactBreadcrumbCalls(source) {
const token = 'React.createElement(Breadcrumb,';
let result = source;
let idx = 0;
while ((idx = result.indexOf(token, idx)) !== -1) {
const open = result.indexOf('(', idx);
const chunk = removeBalancedFrom(result, open);
if (!chunk) break;
let start = idx;
let end = open + chunk.length;
while (start > 0 && /[\t ]/.test(result[start - 1])) start--;
if (result[start - 1] === ',') start--;
if (result[start - 1] === '\n' && result[start - 2] === ',') start--;
while (end < result.length && /[\t ,]/.test(result[end])) end++;
if (result[end] === '\n') end++;
result = result.slice(0, start) + result.slice(end);
idx = start;
}
return result;
}
function removeBreadcrumbImports(source) {
return source
.replace(/^\s*var Breadcrumb = antd\.Breadcrumb;\n/gm, '')
.replace(/^\s*const Breadcrumb = antd\.Breadcrumb;\n/gm, '')
.replace(/^\s*import\s+\{[^}]*\bBreadcrumb\b[^}]*\}\s+from\s+['"][^'"]+['"];\n/gm, (line) =>
line.replace(/\bBreadcrumb,?\s*/g, '').replace(/,\s*,/g, ',').replace(/\{\s*,/g, '{').replace(/,\s*\}/g, '}').replace(/import\s+\{\s*\}\s+from[^;]+;\n/g, '')
);
}
function removeNavBreadcrumbs(source) {
return source.replace(
/\n?\s*<nav[^>]*breadcrumb[^>]*>[\s\S]*?<\/nav>\n?/gi,
'\n'
);
}
function replaceJsxBreadcrumbBar(source) {
// JSX: breadcrumb row with optional right actions
return source.replace(
/\{\/\*[\s*]*面包屑[\s*]*\*\/\}\s*\n?\s*<div style=\{styles\.breadcrumb\}>[\s\S]*?<div style=\{styles\.breadcrumbRight\}>([\s\S]*?)<\/div>\s*<\/div>/g,
(_, right) => `\n\t\t\t<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>${right.trim()}</div>`
);
}
function replaceCreateElementBreadcrumbBar(source) {
// createElement breadcrumb with Left + Right — keep Right only
const pattern = /React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumb \},[\s\S]*?React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumbRight \},([\s\S]*?)\)\s*\)/g;
return source.replace(
pattern,
(_, right) =>
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } }, ${right.trim()})`
);
}
function replaceSimpleCreateElementBreadcrumb(source) {
// div with only styles.breadcrumb path segments (no Right sibling)
return source.replace(
/React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumb[^}]*\},[\s\S]*?\),?\n?/g,
''
);
}
function replaceLeaseContractBreadcrumbRow(source) {
// Long inline breadcrumb + req button row
return source.replace(
/React\.createElement\('div',\s*\{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('div',\s*\{ style: styles\.breadcrumb[^}]+\}[\s\S]*?'查看需求说明'\)\),/g,
(match) => {
const req = match.match(/React\.createElement\('button'[\s\S]*?'查看需求说明'\)/);
if (!req) return '';
return `React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, ${req[0]}),`;
}
);
}
function removeEmptyBreadcrumbWrappers(source) {
// div wrapper that only wrapped breadcrumb (now empty)
return source
.replace(
/React\.createElement\('div',\s*\{ style: \{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 \} \},\s*\),?\n?/g,
''
)
.replace(
/React\.createElement\('div',\s*\{ style: \{ marginBottom: 16 \} \},\s*\),?\n?/g,
''
)
.replace(
/React\.createElement\('div',\s*\{ className: 'vpr-collect-topbar__main' \},\s*\),?\n?/g,
''
);
}
function transformFile(filePath) {
let source = fs.readFileSync(filePath, 'utf8');
if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) return false;
const original = source;
source = removeReactBreadcrumbCalls(source);
source = removeBreadcrumbImports(source);
source = removeNavBreadcrumbs(source);
source = replaceJsxBreadcrumbBar(source);
source = replaceCreateElementBreadcrumbBar(source);
source = replaceSimpleCreateElementBreadcrumb(source);
source = replaceLeaseContractBreadcrumbRow(source);
source = removeEmptyBreadcrumbWrappers(source);
// header with only req link after breadcrumb removed
source = source.replace(
/React\.createElement\(\s*'header',\s*\{ className: 'vr-page-header' \},\s*React\.createElement\(\s*Button,/g,
`React.createElement('header', { className: 'vr-page-header', style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button,`
);
// flex topbar: space-between -> flex-end when breadcrumb was first child
source = source.replace(
/justifyContent: 'space-between', marginBottom: 16 \} \},\s*React\.createElement\(Button, \{ type: 'link'/g,
`justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button, { type: 'link'`
);
if (source !== original) {
fs.writeFileSync(filePath, source);
return true;
}
return false;
}
const files = walk(ROOT);
let changed = 0;
for (const file of files) {
if (transformFile(file)) {
changed++;
console.log('updated:', path.relative(process.cwd(), file));
}
}
// CSS tweak for collect topbar
const vprCss = path.join(ROOT, 'vehicle-pickup-receivable/styles/index.css');
if (fs.existsSync(vprCss)) {
let css = fs.readFileSync(vprCss, 'utf8');
const next = css
.replace('/* 办理页顶栏:返回 + 面包屑 */', '/* 办理页顶栏:返回 + 操作 */')
.replace(
/\.vm-page\.vpr-collect-page \.vpr-collect-topbar \{\n display: flex;\n align-items: center;\n gap: 16px;/,
`.vm-page.vpr-collect-page .vpr-collect-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;`
)
.replace(/\n\.vm-page\.vpr-collect-page \.vpr-collect-topbar__main \{[^}]+\}\n?/g, '\n');
if (next !== css) {
fs.writeFileSync(vprCss, next);
console.log('updated:', path.relative(process.cwd(), vprCss));
}
}
console.log(`\nDone. ${changed} file(s) updated.`);

View File

@@ -0,0 +1,92 @@
/**
* 从各原型 annotation-source.json 移除 ONE-OS 原型导航目录节点。
*
* 用法:
* node scripts/strip-prototype-nav-from-directory.mjs
* node scripts/strip-prototype-nav-from-directory.mjs --prototype lease-contract-management
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NAV_FOLDER_ID = 'oneos-project-nav';
function parseArgs(argv) {
const args = { prototype: '', projectRoot: '' };
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());
}
}
return args;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function stripNavNodes(nodes) {
const list = Array.isArray(nodes) ? nodes : [];
return list
.filter((node) => node?.id !== NAV_FOLDER_ID)
.map((node) => {
if (node?.type === 'folder' && Array.isArray(node.children)) {
return { ...node, children: stripNavNodes(node.children) };
}
return node;
});
}
function listPrototypeIds(projectRoot) {
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
if (!fs.existsSync(prototypesRoot)) return [];
return fs.readdirSync(prototypesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory()
&& fs.existsSync(path.join(prototypesRoot, entry.name, 'annotation-source.json')))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
function stripPrototype(prototypeId, projectRoot) {
const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json');
if (!fs.existsSync(annotationPath)) return false;
const annotation = readJson(annotationPath);
const before = JSON.stringify(annotation.directory?.nodes || []);
annotation.directory = {
nodes: stripNavNodes(annotation.directory?.nodes),
};
const after = JSON.stringify(annotation.directory.nodes || []);
if (before === after) {
console.log(`[skip] ${prototypeId}: 无导航节点`);
return false;
}
annotation.data = annotation.data || {};
annotation.data.updatedAt = Date.now();
writeJson(annotationPath, annotation);
console.log(`[strip] ${prototypeId}: 已移除原型导航目录`);
return true;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const projectRoot = args.projectRoot || path.resolve(__dirname, '..');
const targets = args.prototype ? [args.prototype] : listPrototypeIds(projectRoot);
let changed = 0;
for (const prototypeId of targets) {
if (stripPrototype(prototypeId, projectRoot)) changed += 1;
}
console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`);
}
main();

View File

@@ -26,6 +26,7 @@ const STANDALONE_MODULES = [
{ slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true },
{ slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true },
{ slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true },
{ slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true },
];
const MODULES = [
@@ -39,7 +40,17 @@ const MODULES = [
title: '财务管理',
dir: '财务管理',
excludeDirs: ['文档'],
excludeFiles: ['还车应结款.jsx', '还车应结款-查看.jsx', '还车应结款-费用明细.jsx'],
excludeFiles: [
'还车应结款.jsx',
'还车应结款-查看.jsx',
'还车应结款-费用明细.jsx',
'提车应收款.jsx',
'提车应收款-查看.jsx',
'提车应收款-开票信息.jsx',
'提车应收款-审核.jsx',
'提车应收款-提车收款单.jsx',
'提车收款单-编辑.jsx',
],
},
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
{ slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' },

View File

@@ -1,5 +1,6 @@
/**
* 将 .axhub/make/sidebar-tree.json 左侧菜单同步到原型 annotation-source.json 的「原型目录」
* 将 .axhub/make/sidebar-tree.json 同步到原型导航页 nav-menu.json
* 不再向各原型 annotation-source.json 注入「ONE-OS 原型导航」目录。
*
* 用法:
* node scripts/sync-project-prototype-directory.mjs
@@ -33,56 +34,6 @@ function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function prototypeIdFromItemKey(itemKey) {
const normalized = String(itemKey || '').trim().replace(/^\/+/u, '');
const match = normalized.match(/^prototypes\/(.+)$/u);
return match ? match[1] : '';
}
function navLinkId(prototypeId) {
return `nav-link-${prototypeId.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+|-+$/gu, '').toLowerCase()}`;
}
function createLinkNode(sidebarItem, currentPrototypeId) {
const prototypeId = prototypeIdFromItemKey(sidebarItem.itemKey);
if (!prototypeId) return null;
const isCurrent = prototypeId === currentPrototypeId;
return {
type: 'link',
id: navLinkId(prototypeId),
title: isCurrent ? `${sidebarItem.title}(当前)` : sidebarItem.title,
href: `/prototypes/${prototypeId}`,
target: 'self',
};
}
function sidebarItemsToDirectoryChildren(sidebarItems, currentPrototypeId) {
const children = [];
for (const item of sidebarItems || []) {
if (item?.kind === 'item') {
const link = createLinkNode(item, currentPrototypeId);
if (link) children.push(link);
continue;
}
if (item?.kind !== 'folder' || !Array.isArray(item.children)) {
continue;
}
const folderChildren = item.children
.filter((child) => child?.kind === 'item')
.map((child) => createLinkNode(child, currentPrototypeId))
.filter(Boolean);
if (!folderChildren.length) continue;
children.push({
type: 'folder',
id: item.id ? `nav-${item.id}` : `nav-folder-${children.length + 1}`,
title: item.title || '分组',
defaultExpanded: item.defaultExpanded !== false,
children: folderChildren,
});
}
return children;
}
function loadSidebarPrototypes(projectRoot) {
const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json');
if (!fs.existsSync(sidebarPath)) {
@@ -102,6 +53,18 @@ function listPrototypeIdsWithAnnotation(projectRoot) {
.sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
function stripNavNodes(nodes) {
const list = Array.isArray(nodes) ? nodes : [];
return list
.filter((node) => node?.id !== NAV_FOLDER_ID)
.map((node) => {
if (node?.type === 'folder' && Array.isArray(node.children)) {
return { ...node, children: stripNavNodes(node.children) };
}
return node;
});
}
function fixPublishedLinks(nodes, prototypeId) {
const walk = (items) => {
for (const node of items || []) {
@@ -127,7 +90,7 @@ function fixPublishedLinks(nodes, prototypeId) {
walk(nodes);
}
function syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot) {
function cleanupPrototypeDirectory(prototypeId, projectRoot) {
const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json');
if (!fs.existsSync(annotationPath)) {
console.log(`[skip] ${prototypeId}: 无 annotation-source.json`);
@@ -135,27 +98,22 @@ function syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot) {
}
const annotation = readJson(annotationPath);
const navChildren = sidebarItemsToDirectoryChildren(sidebarItems, prototypeId);
const navFolder = {
type: 'folder',
id: NAV_FOLDER_ID,
title: 'ONE-OS 原型导航',
defaultExpanded: true,
children: navChildren,
};
const existingNodes = Array.isArray(annotation.directory?.nodes) ? annotation.directory.nodes : [];
const preservedNodes = existingNodes.filter((node) => node?.id !== NAV_FOLDER_ID);
fixPublishedLinks(preservedNodes, prototypeId);
const cleanedNodes = stripNavNodes(existingNodes);
fixPublishedLinks(cleanedNodes, prototypeId);
annotation.directory = {
nodes: [navFolder, ...preservedNodes],
};
const before = JSON.stringify(existingNodes);
const after = JSON.stringify(cleanedNodes);
if (before === after) {
console.log(`[ok] ${prototypeId}: 目录已不含原型导航`);
return false;
}
annotation.directory = { nodes: cleanedNodes };
annotation.data = annotation.data || {};
annotation.data.updatedAt = Date.now();
writeJson(annotationPath, annotation);
console.log(`[sync] ${prototypeId}: 已写入 ${navChildren.length} 个顶级目录项(含分组)`);
console.log(`[cleanup] ${prototypeId}: 已移除原型导航目录节点`);
return true;
}
@@ -186,11 +144,11 @@ function main() {
let changed = 0;
for (const prototypeId of targets) {
if (syncPrototypeDirectory(prototypeId, sidebarItems, projectRoot)) {
if (cleanupPrototypeDirectory(prototypeId, projectRoot)) {
changed += 1;
}
}
console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`);
console.log(`完成:处理 ${targets.length} 个原型,清理 ${changed} 个 annotation-source.json`);
syncPrototypeNavMenu(projectRoot, sidebarItems);
}