迭代 ONE-OS 多原型:租赁/自营台账增强、租赁合同与条线说明,统一台账壳层与侧栏导航

- 新增业务部台账、收款记录、业务条线说明及 ledger-shared / vm-shared 公共壳层
- 租赁业务台账:字段计算、宽表交互、标注 PRD 与表头说明修复
- 租赁合同:列表/表单增强、客户资质与审批规则
- 侧栏调整:工作台移至 OneOS 根目录,台账与数据分析分组更新
- 补充 S3 批量发布脚本与弹窗明细表规范

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-06 17:18:23 +08:00
parent f5e31268dc
commit d0d7458989
96 changed files with 14434 additions and 2101 deletions

View File

@@ -0,0 +1,289 @@
#!/usr/bin/env node
/**
* 将项目原型页发布到 S3阿里云 OSS
* 流程Make Admin export-html 构建静态包 → 按页面独立前缀上传到 OSS。
*
* 用法:
* node scripts/publish-all-to-s3.mjs
* node scripts/publish-all-to-s3.mjs --dry-run
* node scripts/publish-all-to-s3.mjs --group prototypes
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import extract from 'extract-zip';
import { readServerInfo } from './utils/serverInfo.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const CONFIG_PATH = path.join(projectRoot, '.axhub/make/axhub.config.json');
function parseArgs(argv) {
const args = {
projectId: 'oneos1.2',
adminOrigin: '',
dryRun: false,
group: 'all',
includeSource: true,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--project-id' && argv[i + 1]) args.projectId = argv[++i].trim();
else if (arg === '--admin-origin' && argv[i + 1]) args.adminOrigin = argv[++i].trim();
else if (arg === '--group' && argv[i + 1]) args.group = argv[++i].trim();
else if (arg === '--dry-run') args.dryRun = true;
else if (arg === '--no-source') args.includeSource = false;
}
return args;
}
function resolveAdminOrigin(explicit) {
if (explicit) return explicit.replace(/\/+$/u, '');
const info = readServerInfo(projectRoot, 'admin');
if (info?.origin) return info.origin.replace(/\/+$/u, '');
return 'http://localhost:53817';
}
function readS3Config() {
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
const s3 = raw?.cloudPublishing?.s3;
if (!s3?.accessKeyId || !s3?.secretAccessKey || !s3?.bucket) {
throw new Error('请在 .axhub/make/axhub.config.json 中配置 cloudPublishing.s3');
}
return {
...s3,
includeSource: raw?.cloudPublishing?.publishSettings?.includeSource !== false,
};
}
function toPublishPath(resource) {
const raw = resource.filePath || resource.sourcePath || '';
return String(raw).replace(/\\/gu, '/').replace(/\/index\.tsx?$/iu, '');
}
function resolveObjectPrefix(entry) {
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u);
if (prototypeMatch?.[1]) return prototypeMatch[1];
const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
return entry.id;
}
function resolvePublicUrl(baseUrl, prefix) {
const base = String(baseUrl || '').replace(/\/?$/u, '/');
const key = `${prefix.replace(/^\/+|\/+$/gu, '')}/index.html`;
return `${base}${key.split('/').map(encodeURIComponent).join('/')}`;
}
function guessContentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const map = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.webp': 'image/webp',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.txt': 'text/plain; charset=utf-8',
};
return map[ext] || 'application/octet-stream';
}
async function collectPublishEntries(adminOrigin, projectId, groupFilter) {
const res = await fetch(`${adminOrigin}/api/projects/${encodeURIComponent(projectId)}/resources`);
if (!res.ok) {
throw new Error(`GET /api/projects/${projectId}/resources failed: ${res.status}`);
}
const data = await res.json();
const groups = groupFilter === 'all' ? ['prototypes', 'themes'] : [groupFilter];
const entries = [];
for (const group of groups) {
for (const item of data.resources?.[group] || []) {
const publishPath = toPublishPath(item);
if (!publishPath) continue;
entries.push({
group,
id: item.id,
title: item.title || item.id,
path: publishPath,
prefix: resolveObjectPrefix({ path: publishPath, id: item.id }),
});
}
}
return entries;
}
async function exportHtmlZip(adminOrigin, projectId, entry, includeSource) {
const params = new URLSearchParams({
path: entry.path,
projectId,
});
if (includeSource) params.set('includeSource', 'true');
const res = await fetch(`${adminOrigin}/api/export-html?${params.toString()}`);
if (!res.ok) {
const text = await res.text().catch(() => '');
let message = text;
try {
message = JSON.parse(text).error || text;
} catch {
// keep raw text
}
throw new Error(message || `export-html failed (${res.status})`);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4b) {
throw new Error('export-html 未返回有效 ZIP');
}
return buffer;
}
function resolveS3Endpoint(s3Config) {
const bucket = String(s3Config.bucket || '').trim();
const endpoint = String(s3Config.endpoint || '').trim().replace(/\/+$/u, '');
const region = String(s3Config.region || 'cn-hangzhou').trim();
if (endpoint) {
// 配置里可能是 bucket 级域名model-lnoneos.oss-...AWS SDK 只需区域 endpoint。
const bucketHostPrefix = bucket ? `${bucket}.` : '';
if (bucketHostPrefix && endpoint.includes(bucketHostPrefix)) {
return endpoint.replace(bucketHostPrefix, '');
}
return endpoint;
}
return `https://oss-${region}.aliyuncs.com`;
}
function createS3Client(s3Config) {
return new S3Client({
region: s3Config.region || 'cn-hangzhou',
endpoint: resolveS3Endpoint(s3Config),
forcePathStyle: false,
credentials: {
accessKeyId: s3Config.accessKeyId,
secretAccessKey: s3Config.secretAccessKey,
},
});
}
async function uploadDirectory(client, bucket, prefix, dir) {
const uploaded = [];
const walk = async (currentDir, relative = '') => {
for (const name of fs.readdirSync(currentDir)) {
const abs = path.join(currentDir, name);
const rel = relative ? `${relative}/${name}` : name;
if (fs.statSync(abs).isDirectory()) {
await walk(abs, rel);
continue;
}
const key = [prefix.replace(/^\/+|\/+$/gu, ''), rel].filter(Boolean).join('/');
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fs.readFileSync(abs),
ContentType: guessContentType(abs),
}));
uploaded.push(key);
}
};
await walk(dir);
return uploaded;
}
async function publishEntry(client, s3Config, adminOrigin, projectId, entry, includeSource) {
const zipBuffer = await exportHtmlZip(adminOrigin, projectId, entry, includeSource);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'axhub-publish-'));
const zipPath = path.join(tempDir, 'bundle.zip');
const extractDir = path.join(tempDir, 'site');
fs.writeFileSync(zipPath, zipBuffer);
fs.mkdirSync(extractDir, { recursive: true });
await extract(zipPath, { dir: extractDir });
const objectPrefix = s3Config.prefix
? `${String(s3Config.prefix).replace(/^\/+|\/+$/gu, '')}/${entry.prefix}`
: entry.prefix;
const keys = await uploadDirectory(client, s3Config.bucket, objectPrefix, extractDir);
fs.rmSync(tempDir, { recursive: true, force: true });
return {
url: resolvePublicUrl(s3Config.baseUrl, objectPrefix),
fileCount: keys.length,
keys,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const adminOrigin = resolveAdminOrigin(args.adminOrigin);
const s3Config = readS3Config();
const includeSource = args.includeSource && s3Config.includeSource;
const entries = await collectPublishEntries(adminOrigin, args.projectId, args.group);
console.log(`Admin: ${adminOrigin}`);
console.log(`Bucket: ${s3Config.bucket}`);
console.log(`Base URL: ${s3Config.baseUrl}`);
console.log(`Pages to publish: ${entries.length}`);
if (args.dryRun) {
for (const entry of entries) {
console.log(`[dry-run] ${entry.group}/${entry.id} -> ${entry.prefix}/index.html`);
}
return;
}
const client = createS3Client(s3Config);
const results = [];
let failed = 0;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
const label = `[${i + 1}/${entries.length}] ${entry.group}/${entry.id}`;
process.stdout.write(`${label} ... `);
try {
const result = await publishEntry(client, s3Config, adminOrigin, args.projectId, entry, includeSource);
console.log(`OK ${result.url} (${result.fileCount} files)`);
results.push({ ...entry, status: 'success', ...result });
} catch (error) {
failed += 1;
const message = error instanceof Error ? error.message : String(error);
console.log(`FAIL ${message}`);
results.push({ ...entry, status: 'failed', error: message });
}
}
const reportPath = path.join(
projectRoot,
'.axhub/make/exports',
`cloud.publish.s3-all-${new Date().toISOString().replace(/[:.]/gu, '-')}.json`,
);
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify({
schemaVersion: 2,
projectId: args.projectId,
target: 's3',
method: 'export-html+s3-upload',
total: entries.length,
success: entries.length - failed,
failed,
completedAt: new Date().toISOString(),
results: results.map(({ keys, ...rest }) => ({
...rest,
fileCount: rest.fileCount ?? keys?.length ?? 0,
})),
}, null, 2)}\n`, 'utf8');
console.log(`\nDone: ${entries.length - failed}/${entries.length} succeeded`);
if (failed > 0) process.exitCode = 1;
console.log(`Report: ${reportPath}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});