refactor platform admin from backend contract
This commit is contained in:
@@ -1,119 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)]) : [];
|
||||
const sourceFiles = (dir, extensions = ['.ts', '.vue']) => new Map(files(dir).filter((file) => extensions.includes(path.extname(file))).map((file) => [file.replaceAll('\\', '/'), fs.readFileSync(file, 'utf8')]));
|
||||
const mutationActions = (source) => [...source.matchAll(/resourceApi\.(create|update|updateStatus|archive)\b/g)].map((match) => match[1]);
|
||||
|
||||
function requiredBackendRoutes(contract) {
|
||||
if (contract.mode === 'append_only') return [{ method: 'GET', path: contract.path }, { method: 'POST', path: contract.path }];
|
||||
const resource = contract.path;
|
||||
const detail = `${resource}/:identity`;
|
||||
if (contract.mode === 'readonly') return [{ method: 'GET', path: resource }, { method: 'GET', path: detail }];
|
||||
return [
|
||||
{ method: 'GET', path: resource }, { method: 'POST', path: resource }, { method: 'GET', path: detail },
|
||||
{ method: 'PUT', path: detail }, { method: 'PATCH', path: `${detail}/status` }, { method: 'DELETE', path: detail },
|
||||
];
|
||||
}
|
||||
|
||||
function routeCoverage(contract, routeSources, viewSources) {
|
||||
const expectedView = new RegExp(`getResource\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]\\s*\\)`);
|
||||
let hasPage = false;
|
||||
let hasMenu = false;
|
||||
for (const [, source] of sourceEntries(routeSources, 'src/router')) {
|
||||
for (const match of source.matchAll(/component:\s*\(\)\s*=>\s*import\(['\"]@\/views\/([^'\"]+)['\"]\)([\s\S]{0,260}?meta:\s*\{[^}]*\})?/g)) {
|
||||
const view = viewSources.get(`src/views/${match[1]}`);
|
||||
if (!view || !expectedView.test(view)) continue;
|
||||
hasPage = true;
|
||||
if (/locale:\s*['\"]menu\.platform\./.test(match[2] ?? '')) hasMenu = true;
|
||||
}
|
||||
}
|
||||
return { hasPage, hasMenu };
|
||||
}
|
||||
|
||||
function sourceEntries(sources, fallbackDirectory) {
|
||||
if (sources instanceof Map) return [...sources];
|
||||
return sources.map((source, index) => [`${fallbackDirectory}/${index}`, source]);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Scans user-facing API and view sources for auto-increment primary or relation IDs. */
|
||||
export function scanInternalIdLeaks(sources) {
|
||||
const failures = [];
|
||||
for (const [file, source] of sources) {
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
// Remove only the defensive predicates, then keep scanning the rest of the line.
|
||||
const scanned = line.replace(/key\s*!==\s*['\"]id['\"]/g, '').replace(/key\.endsWith\(\s*['\"]_id['\"]\s*\)/g, '');
|
||||
const relation = scanned.match(/\b([A-Za-z][A-Za-z0-9_]*_id)\b/);
|
||||
if (relation) failures.push(`${file}: internal identifier ${relation[1]}`);
|
||||
else if (/\bdata-index\s*=\s*['\"]id['\"]|\.id\b|[,{]\s*id\s*:/.test(scanned)) failures.push(`${file}: internal identifier id`);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
/** Evaluates every platform contract against its backend route, UI definition, page and menu route. */
|
||||
export function auditPlatform({ manifest, resources, readOnlyPage, routeSources, viewSources, apiSources, responseShapeVerified = true }) {
|
||||
const failures = [];
|
||||
if (!responseShapeVerified) failures.push('backend list response shape is not identity-only');
|
||||
if (resources.length !== manifest.resources.length) failures.push(`catalogue count: ${resources.length}/${manifest.resources.length}`);
|
||||
for (const contract of manifest.resources) {
|
||||
const resource = resources.find((item) => item.name === contract.name);
|
||||
const pageKind = contract.name === 'ec_category' ? 'tree' : contract.pageKind;
|
||||
const label = `${contract.domain}/${contract.name}`;
|
||||
if (!resource || resource.resource !== contract.path || resource.mode !== contract.mode || resource.pageKind !== pageKind) {
|
||||
failures.push(`${label}: missing frontend resource`);
|
||||
continue;
|
||||
}
|
||||
if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id')) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${label}: invalid frontend allowlist`);
|
||||
for (const expected of requiredBackendRoutes(contract)) if (!manifest.routes.some((route) => route.method === expected.method && route.path === expected.path)) failures.push(`${label}: missing backend ${expected.method}`);
|
||||
const coverage = routeCoverage(contract, routeSources, viewSources);
|
||||
if (!coverage.hasPage) failures.push(`${label}: missing page`);
|
||||
if (!coverage.hasMenu) failures.push(`${label}: missing menu route`);
|
||||
if (contract.mode === 'readonly') {
|
||||
const statusCall = new RegExp(`resourceApi\\.updateStatus\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]`);
|
||||
for (const [file, source] of [...sourceEntries(routeSources, 'src/router'), ...sourceEntries(apiSources, 'src/api'), ...sourceEntries(viewSources, 'src/views')]) {
|
||||
if (statusCall.test(source)) failures.push(`${label}: readonly status mutation in ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(manifest.resources.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources');
|
||||
const readonlyMutations = mutationActions(readOnlyPage);
|
||||
if (readonlyMutations.length) failures.push(`readonly: mutation action exposed (${readonlyMutations.join(', ')})`);
|
||||
failures.push(...scanInternalIdLeaks(new Map([...apiSources, ...viewSources])));
|
||||
return failures;
|
||||
}
|
||||
|
||||
function loadResources() {
|
||||
const compiled = ts.transpileModule(fs.readFileSync('src/api/resources.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const resourceModule = { exports: {} };
|
||||
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
|
||||
return resourceModule.exports.resources;
|
||||
}
|
||||
|
||||
function runAudit() {
|
||||
const backendDirectory = path.resolve('../..', 'backend/api');
|
||||
const manifest = JSON.parse(execFileSync('go', ['run', './cmd/resource-contract'], { cwd: backendDirectory, encoding: 'utf8' }));
|
||||
let responseShapeVerified = true;
|
||||
try { execFileSync('go', ['test', '-count=1', './internal/logic/platform', '-run', '^TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID$'], { cwd: backendDirectory, stdio: 'pipe' }); } catch { responseShapeVerified = false; }
|
||||
const viewSources = sourceFiles('src/views', ['.vue']);
|
||||
const failures = auditPlatform({
|
||||
manifest,
|
||||
resources: loadResources(),
|
||||
readOnlyPage: fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'),
|
||||
routeSources: sourceFiles('src/router', ['.ts']),
|
||||
viewSources,
|
||||
apiSources: sourceFiles('src/api', ['.ts']),
|
||||
responseShapeVerified,
|
||||
});
|
||||
for (const failure of failures) console.log(failure);
|
||||
if (failures.length) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]?.replaceAll('\\', '/')}`) runAudit();
|
||||
@@ -1,89 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { auditPlatform, scanInternalIdLeaks } from './audit-check.mjs';
|
||||
|
||||
const resourcesSource = readFileSync('src/api/resources.ts', 'utf8');
|
||||
const legacySafetyPrefix = ['sa', 'f_'].join('');
|
||||
const legacyAuditPrefix = ['au', 'd_'].join('');
|
||||
|
||||
test('安全模块资源定义已完全删除', () => {
|
||||
assert.doesNotMatch(resourcesSource, /safe_(?:rule|event|inspection|event_disposal)|\/safety\//);
|
||||
|
||||
assert.doesNotMatch(resourcesSource, new RegExp(`define\\('${legacySafetyPrefix}(?:rule|event|inspection|event_disposal)',`));
|
||||
assert.doesNotMatch(resourcesSource, new RegExp(`action\\('${legacySafetyPrefix}event_disposal',`));
|
||||
assert.doesNotMatch(resourcesSource, new RegExp(`define\\('${legacyAuditPrefix}(?:operation_log|export_log|approval)',`));
|
||||
assert.doesNotMatch(resourcesSource, /audit_(?:operation_log|export_log|approval)/);
|
||||
});
|
||||
|
||||
test('只读页面将状态变更视为违规写操作', () => {
|
||||
const failures = auditPlatform({
|
||||
manifest: { resources: [], routes: [] },
|
||||
resources: [],
|
||||
readOnlyPage: '<script setup>resourceApi.updateStatus(resource, identity, status)</script>',
|
||||
routeSources: [],
|
||||
viewSources: new Map(),
|
||||
apiSources: new Map(),
|
||||
});
|
||||
|
||||
assert.deepEqual(failures, ['readonly: mutation action exposed (updateStatus)']);
|
||||
});
|
||||
|
||||
test('扫描 API 和页面中用于展示或请求的内部 ID', () => {
|
||||
const failures = scanInternalIdLeaks(new Map([
|
||||
['src/api/leak.ts', "resourceApi.create('/gas/gas_basic', { gas_basic_id: 7 })"],
|
||||
['src/views/leak.vue', '<a-table-column data-index="id" />'],
|
||||
]));
|
||||
|
||||
assert.deepEqual(failures, [
|
||||
'src/api/leak.ts: internal identifier gas_basic_id',
|
||||
'src/views/leak.vue: internal identifier id',
|
||||
]);
|
||||
});
|
||||
|
||||
test('防护表达式不能掩盖同一行的内部 ID 泄漏', () => {
|
||||
const failures = scanInternalIdLeaks(new Map([
|
||||
['src/views/leak.vue', "const visible = row.id; const safe = key !== 'id';"],
|
||||
['src/api/leak.ts', "send({ gas_basic_id: 7 }); const safe = key.endsWith('_id');"],
|
||||
]));
|
||||
|
||||
assert.deepEqual(failures, [
|
||||
'src/views/leak.vue: internal identifier id',
|
||||
'src/api/leak.ts: internal identifier gas_basic_id',
|
||||
]);
|
||||
});
|
||||
|
||||
test('每个资源必须由带菜单元数据的路由实际加载对应页面', () => {
|
||||
const failures = auditPlatform({
|
||||
manifest: { resources: [{ domain: 'gas', name: 'gas_basic', path: '/gas/gas_basic', mode: 'writable', pageKind: 'list' }], routes: [
|
||||
{ method: 'GET', path: '/gas/gas_basic' },
|
||||
{ method: 'POST', path: '/gas/gas_basic' },
|
||||
{ method: 'GET', path: '/gas/gas_basic/:identity' },
|
||||
{ method: 'PUT', path: '/gas/gas_basic/:identity' },
|
||||
{ method: 'PATCH', path: '/gas/gas_basic/:identity/status' },
|
||||
{ method: 'DELETE', path: '/gas/gas_basic/:identity' },
|
||||
] },
|
||||
resources: [{ name: 'gas_basic', resource: '/gas/gas_basic', mode: 'writable', pageKind: 'list', title: '气站管理', fields: [{ key: 'name', label: '名称' }] }],
|
||||
readOnlyPage: '',
|
||||
routeSources: ["{ component: () => import('@/views/gas/gas_basic/ListPage.vue') }"],
|
||||
viewSources: new Map([['src/views/gas/gas_basic/ListPage.vue', "getResource('/gas/gas_basic')"]]),
|
||||
apiSources: new Map(),
|
||||
});
|
||||
|
||||
assert.deepEqual(failures, ['gas/gas_basic: missing menu route']);
|
||||
});
|
||||
|
||||
test('只读资源拒绝 API 或路由中的状态写入', () => {
|
||||
const failures = auditPlatform({
|
||||
manifest: { resources: [{ domain: 'wallet', name: 'wallet', path: '/wallet/wallet', mode: 'readonly', pageKind: 'list' }], routes: [
|
||||
{ method: 'GET', path: '/wallet/wallet' }, { method: 'GET', path: '/wallet/wallet/:identity' },
|
||||
] },
|
||||
resources: [{ name: 'wallet', resource: '/wallet/wallet', mode: 'readonly', pageKind: 'list', title: '钱包', fields: [{ key: 'balance_amount', label: '余额' }] }],
|
||||
readOnlyPage: '',
|
||||
routeSources: ["{ component: () => import('@/views/wallet/wallet/ListPage.vue'), meta: { locale: 'menu.platform.wallet' } }"],
|
||||
viewSources: new Map([['src/views/wallet/wallet/ListPage.vue', "getResource('/wallet/wallet')"]]),
|
||||
apiSources: new Map([['src/api/wallet.ts', "resourceApi.updateStatus('/wallet/wallet', identity, 'disabled')"]]),
|
||||
});
|
||||
|
||||
assert.deepEqual(failures, ['wallet/wallet: readonly status mutation in src/api/wallet.ts']);
|
||||
});
|
||||
35
frontend/platform_admin/scripts/check-backend-contract.mjs
Normal file
35
frontend/platform_admin/scripts/check-backend-contract.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const source = readFileSync(resolve(root, 'src/api/resources.ts'), 'utf8');
|
||||
const routes = readFileSync(resolve(root, 'src/router/routes/modules/platform.ts'), 'utf8');
|
||||
const contract = JSON.parse(
|
||||
readFileSync(resolve(root, 'src/contracts/platform-resources.json'), 'utf8'),
|
||||
);
|
||||
const backend = new Map(contract.resources.map((item) => [item.name, item]));
|
||||
const frontendNames = [...source.matchAll(/define\('([^']+)'/g)].map((match) => match[1]);
|
||||
|
||||
for (const name of frontendNames) {
|
||||
const item = backend.get(name);
|
||||
if (!item) throw new Error(`前端配置了后端不存在的资源:${name}`);
|
||||
if (item.path !== `/${name}`) throw new Error(`资源路径不一致:${name}`);
|
||||
}
|
||||
for (const item of contract.resources) {
|
||||
if (!frontendNames.includes(item.name))
|
||||
throw new Error(`缺少后端资源配置:${item.name}`);
|
||||
}
|
||||
|
||||
const forbidden = [
|
||||
'/platform/platform_', '/gas/gas_', '/delivery/delivery_', '/staff/',
|
||||
'/user/', '/ec/ec_', '/finance/fin_', '/wallet/wallet',
|
||||
'delivery_task', 'delivery_track', 'delivery_track_point',
|
||||
'dev_device_binding', 'dev_smart_cylinder_valve', 'dev_telemetry',
|
||||
'wallet_ledger', 'wallet_recharge', 'wallet_withdrawal',
|
||||
];
|
||||
for (const value of forbidden) {
|
||||
if (source.includes(value) || routes.includes(value))
|
||||
throw new Error(`仍存在旧资源或旧路径:${value}`);
|
||||
}
|
||||
console.log(`契约检查通过:${frontendNames.length} 个资源`);
|
||||
@@ -1,178 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const fromProjectRoot = (...segments) => path.join(projectRoot, ...segments);
|
||||
|
||||
function loadResources() {
|
||||
const compiled = ts.transpileModule(fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const resourceModule = { exports: {} };
|
||||
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
|
||||
return resourceModule.exports.resources;
|
||||
}
|
||||
|
||||
function loadResourceForm() {
|
||||
const source = fs.readFileSync(fromProjectRoot('src/api/resource-form.ts'), 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const resourceModule = { exports: {} };
|
||||
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
|
||||
return resourceModule.exports;
|
||||
}
|
||||
|
||||
test('资源字段声明保留数字、布尔、时间与文本类型', () => {
|
||||
const resources = loadResources();
|
||||
const field = (resource, key) => resources.find((item) => item.name === resource).fields.find((item) => item.key === key);
|
||||
assert.equal(field('ec_product', 'price_amount').type, 'number');
|
||||
assert.equal(field('ec_product_image', 'is_cover').type, 'boolean');
|
||||
assert.equal(field('delivery_track', 'started_at').type, 'datetime');
|
||||
assert.equal(field('dev_telemetry', 'payload').type, 'textarea');
|
||||
});
|
||||
|
||||
test('表单载荷构造器省略空的可选关系并转换字段类型', async () => {
|
||||
const resourceFormPath = fromProjectRoot('src/api/resource-form.ts');
|
||||
assert.ok(fs.existsSync(resourceFormPath), 'resource-form.ts should define the payload boundary');
|
||||
const resourceForm = loadResourceForm();
|
||||
const fields = [
|
||||
{ key: 'gas_basic_identity', label: '气站', type: 'identity' },
|
||||
{ key: 'quantity', label: '数量', type: 'number' },
|
||||
{ key: 'selected', label: '选中', type: 'boolean' },
|
||||
];
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(resourceForm.buildResourcePayload(fields, {
|
||||
gas_basic_identity: '',
|
||||
quantity: '2',
|
||||
selected: false,
|
||||
}))),
|
||||
{ quantity: 2, selected: false },
|
||||
);
|
||||
});
|
||||
|
||||
test('订单明细文本字段允许任意文本提交', () => {
|
||||
const resources = loadResources();
|
||||
const { buildResourcePayload } = loadResourceForm();
|
||||
const fields = (name) => resources.find((item) => item.name === name).fields;
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields('ec_order_item'), {
|
||||
ec_order_identity: 'order-a',
|
||||
ec_product_identity: 'product-a',
|
||||
product_snapshot: 'arbitrary product snapshot text',
|
||||
quantity: 2,
|
||||
sale_amount: 500,
|
||||
}))),
|
||||
{
|
||||
ec_order_identity: 'order-a',
|
||||
ec_product_identity: 'product-a',
|
||||
product_snapshot: 'arbitrary product snapshot text',
|
||||
quantity: 2,
|
||||
sale_amount: 500,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('日期按 RFC3339 提交,密码只在创建时必填并提交', () => {
|
||||
const { buildResourcePayload, isResourceFieldRequired } = loadResourceForm();
|
||||
const fields = [
|
||||
{ key: 'bill_date', label: '账单日期', type: 'date', required: true },
|
||||
{ key: 'started_at', label: '开始时间', type: 'datetime' },
|
||||
{ key: 'username', label: '用户名', type: 'text', required: true },
|
||||
{ key: 'password', label: '密码', type: 'password', required: true },
|
||||
];
|
||||
|
||||
assert.equal(isResourceFieldRequired(fields[3], 'create'), true);
|
||||
assert.equal(isResourceFieldRequired(fields[3], 'edit'), false);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields, {
|
||||
bill_date: '2026-07-27',
|
||||
started_at: '2026-07-27T10:30:00Z',
|
||||
username: 'operator',
|
||||
password: 'secret',
|
||||
}, 'create'))),
|
||||
{
|
||||
bill_date: '2026-07-27T00:00:00.000Z',
|
||||
started_at: '2026-07-27T10:30:00.000Z',
|
||||
username: 'operator',
|
||||
password: 'secret',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields, {
|
||||
bill_date: '2026-07-27',
|
||||
username: 'operator',
|
||||
password: 'replacement-must-not-be-sent',
|
||||
}, 'edit'))),
|
||||
{
|
||||
bill_date: '2026-07-27T00:00:00.000Z',
|
||||
username: 'operator',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('已删除的审批模块不再保留前端资源或页面', () => {
|
||||
const source = fs.readFileSync(fromProjectRoot('src/views/shared/ReadOnlyListPage.vue'), 'utf8');
|
||||
const resources = fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8');
|
||||
assert.doesNotMatch(resources, /audit_approval|audit_operation_log|audit_export_log/);
|
||||
assert.equal(fs.existsSync(fromProjectRoot('src/views/audit')), false);
|
||||
});
|
||||
|
||||
test('树页面通过资源归档接口归档节点', () => {
|
||||
const source = fs.readFileSync(fromProjectRoot('src/views/shared/TreePage.vue'), 'utf8');
|
||||
assert.match(source, /resourceApi\.archive/);
|
||||
assert.match(source, /Modal\.(warning|confirm)/);
|
||||
});
|
||||
|
||||
test('route access uses authenticated role and assigned menu codes', () => {
|
||||
const routeDir = fromProjectRoot('src/router/routes/modules');
|
||||
const routeSource = fs.readdirSync(routeDir)
|
||||
.filter((name) => name.endsWith('.ts'))
|
||||
.map((name) => fs.readFileSync(path.join(routeDir, name), 'utf8'))
|
||||
.join('\n');
|
||||
const userStore = fs.readFileSync(fromProjectRoot('src/store/modules/user/index.ts'), 'utf8');
|
||||
const permission = fs.readFileSync(fromProjectRoot('src/hooks/permission.ts'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(routeSource, /roles:\s*\[\s*['"]\*['"]\s*\]/);
|
||||
assert.match(routeSource, /menuCode:\s*['"]finance['"]/);
|
||||
assert.match(userStore, /profile\.role_code/);
|
||||
assert.match(userStore, /menuCodes/);
|
||||
assert.match(permission, /menuCode/);
|
||||
});
|
||||
|
||||
test('platform role UI replaces assigned menu identities through the singular contract URL', () => {
|
||||
const resources = fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8');
|
||||
const crudPage = fs.readFileSync(fromProjectRoot('src/views/shared/CrudListPage.vue'), 'utf8');
|
||||
const platformApi = fs.readFileSync(fromProjectRoot('src/api/platform.ts'), 'utf8');
|
||||
|
||||
assert.match(resources, /platform_role[\s\S]*\/platform\/platform_role\/:identity\/menu/);
|
||||
assert.match(resources, /menu-identities/);
|
||||
assert.match(crudPage, /multiple/);
|
||||
assert.match(crudPage, /menu_identities/);
|
||||
assert.match(platformApi, /\/platform\/platform_role\/\$\{identity\}\/menu/);
|
||||
assert.match(platformApi, /method:\s*['"]PUT['"]/);
|
||||
});
|
||||
|
||||
test('platform role UI permits an empty menu selection to revoke every assignment', () => {
|
||||
const resources = loadResources();
|
||||
const { buildResourcePayload, isMissingField } = loadResourceForm();
|
||||
const role = resources.find((item) => item.name === 'platform_role');
|
||||
const menuField = role.detailActions
|
||||
.flatMap((action) => action.fields)
|
||||
.find((field) => field.key === 'menu_identities');
|
||||
|
||||
assert.equal(menuField.required, undefined);
|
||||
assert.equal(isMissingField([]), true);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload([menuField], {
|
||||
menu_identities: [],
|
||||
}))),
|
||||
{ menu_identities: [] },
|
||||
);
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BASE =
|
||||
'https://raw.githubusercontent.com/arco-design/arco-design-pro-vue/main/arco-design-pro-vite/src';
|
||||
|
||||
const vueFiles = [
|
||||
'views/visualization/multi-dimension-data-analysis/components/content-publishing-source.vue',
|
||||
'views/user/info/components/my-project.vue',
|
||||
'views/user/info/components/my-team.vue',
|
||||
'views/user/setting/components/enterprise-certification.vue',
|
||||
];
|
||||
|
||||
async function download(relPath) {
|
||||
const res = await fetch(`${BASE}/${relPath}`);
|
||||
if (!res.ok) throw new Error(`${relPath}: HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function patchForProject(content, relPath) {
|
||||
let text = content;
|
||||
|
||||
if (relPath.includes('my-project.vue')) {
|
||||
text = text.replace(
|
||||
"import { queryMyProjectList, MyProjectRecord } from '@/api/user-center';",
|
||||
"import { type MyProjectRecord, queryMyProjectList } from '@/api/user';",
|
||||
);
|
||||
text = text.replace(/\{\{ project\.contributors \}\}\s*/g, '');
|
||||
}
|
||||
|
||||
if (relPath.includes('my-team.vue')) {
|
||||
text = text.replace(
|
||||
"import { queryMyTeamList, MyTeamRecord } from '@/api/user-center';",
|
||||
"import { type MyTeamRecord, queryMyTeamList } from '@/api/user';",
|
||||
);
|
||||
}
|
||||
|
||||
if (relPath.includes('enterprise-certification.vue')) {
|
||||
text = text.replace(
|
||||
"import { EnterpriseCertificationModel } from '@/api/user-center';",
|
||||
"import type { EnterpriseCertificationModel } from '@/api/user';",
|
||||
);
|
||||
text = text.replace(
|
||||
/type: Object as PropType<EnterpriseCertificationModel>/,
|
||||
'type: Object as PropType<EnterpriseCertificationModel>,',
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const relPath of vueFiles) {
|
||||
let content = await download(relPath);
|
||||
content = patchForProject(content, relPath);
|
||||
const fullPath = path.join('src', relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
const hasCn = /[\u4e00-\u9fff]/.test(content);
|
||||
console.log(`OK ${relPath} (cn=${hasCn})`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BASE =
|
||||
'https://raw.githubusercontent.com/arco-design/arco-design-pro-vue/main/arco-design-pro-vite/src';
|
||||
|
||||
const localeFiles = [
|
||||
'locale/zh-CN/settings.ts',
|
||||
'views/login/locale/zh-CN.ts',
|
||||
'views/form/group/locale/zh-CN.ts',
|
||||
'views/form/step/locale/zh-CN.ts',
|
||||
'views/dashboard/workplace/locale/zh-CN.ts',
|
||||
'views/dashboard/monitor/locale/zh-CN.ts',
|
||||
'views/list/card/locale/zh-CN.ts',
|
||||
'views/list/search-table/locale/zh-CN.ts',
|
||||
'views/profile/basic/locale/zh-CN.ts',
|
||||
'views/result/success/locale/zh-CN.ts',
|
||||
'views/result/error/locale/zh-CN.ts',
|
||||
'views/exception/403/locale/zh-CN.ts',
|
||||
'views/exception/404/locale/zh-CN.ts',
|
||||
'views/user/info/locale/zh-CN.ts',
|
||||
'views/user/setting/locale/zh-CN.ts',
|
||||
'views/visualization/data-analysis/locale/zh-CN.ts',
|
||||
'views/visualization/multi-dimension-data-analysis/locale/zh-CN.ts',
|
||||
];
|
||||
|
||||
const rootZhCN = `import { mergeLocaleModules } from './merge-locales';
|
||||
import localeSettings from './zh-CN/settings';
|
||||
|
||||
const componentLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/components/**/locale/zh-CN.ts', { eager: true }),
|
||||
);
|
||||
const viewLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/views/**/locale/zh-CN.ts', { eager: true }),
|
||||
);
|
||||
|
||||
export default {
|
||||
'menu.dashboard': '仪表盘',
|
||||
'menu.server.dashboard': '仪表盘-服务端',
|
||||
'menu.server.workplace': '工作台-服务端',
|
||||
'menu.server.monitor': '实时监控-服务端',
|
||||
'menu.list': '列表页',
|
||||
'menu.result': '结果页',
|
||||
'menu.exception': '异常页',
|
||||
'menu.form': '表单页',
|
||||
'menu.profile': '详情页',
|
||||
'menu.visualization': '数据可视化',
|
||||
'menu.user': '个人中心',
|
||||
'menu.arcoWebsite': 'Arco Design',
|
||||
'menu.faq': '常见问题',
|
||||
'navbar.docs': '文档中心',
|
||||
'navbar.action.locale': '切换为中文',
|
||||
...localeSettings,
|
||||
...componentLocales,
|
||||
...viewLocales,
|
||||
};
|
||||
`;
|
||||
|
||||
async function download(relPath) {
|
||||
const url = `${BASE}/${relPath}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`${relPath}: HTTP ${res.status}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const relPath of localeFiles) {
|
||||
const content = await download(relPath);
|
||||
const dest = path.join('src', relPath.replace(/^locale\//, 'locale/'));
|
||||
const fullPath = path.join('src', relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
const hasCn = /[\u4e00-\u9fff]/.test(content);
|
||||
console.log(`OK ${relPath} (cn=${hasCn})`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join('src', 'locale/zh-CN.ts'), rootZhCN, 'utf8');
|
||||
console.log('OK locale/zh-CN.ts (cn=true)');
|
||||
|
||||
// search-table column setting label
|
||||
const stPath = path.join('src', 'views/list/search-table/index.vue');
|
||||
let st = fs.readFileSync(stPath, 'utf8');
|
||||
st = st.replace(
|
||||
"{{ item.title === '#' ? '???' : item.title }}",
|
||||
"{{ item.title === '#' ? '序列号' : item.title }}",
|
||||
);
|
||||
fs.writeFileSync(stPath, st, 'utf8');
|
||||
console.log('OK search-table/index.vue');
|
||||
|
||||
// verify
|
||||
let bad = 0;
|
||||
for (const relPath of ['locale/zh-CN.ts', ...localeFiles]) {
|
||||
const fullPath = path.join('src', relPath);
|
||||
const text = fs.readFileSync(fullPath, 'utf8');
|
||||
if (text.includes("'???'") || text.includes("'??'")) {
|
||||
console.error('STILL BAD:', relPath);
|
||||
bad += 1;
|
||||
}
|
||||
}
|
||||
if (bad) process.exit(1);
|
||||
console.log('All locale files verified');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
frontend/platform_admin/scripts/sync-backend-contract.mjs
Normal file
16
frontend/platform_admin/scripts/sync-backend-contract.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const backend = resolve(root, '../../backend/api');
|
||||
const output = resolve(root, 'src/contracts/platform-resources.json');
|
||||
const contract = execFileSync('go', ['run', './cmd/cli', 'resource-contract'], {
|
||||
cwd: backend,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
JSON.parse(contract);
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, `${contract.trim()}\n`);
|
||||
console.log(`已同步后端资源契约:${output}`);
|
||||
Reference in New Issue
Block a user