Bläddra i källkod

feat(timecard): publish main page with guarded dual-file updates

malk 3 dagar sedan
förälder
incheckning
29dd9f00c8

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 21 - 3
pages/README.md


+ 156 - 0
pages/scripts/publish-timecard-main.cjs

@@ -0,0 +1,156 @@
+'use strict';
+const fs = require('node:fs');
+const path = require('node:path');
+const cp = require('node:child_process');
+const assert = require('node:assert/strict');
+const vm = require('node:vm');
+const { isDeepStrictEqual } = require('node:util');
+
+const APP = 'APP_ZQ3I7XO2RSHDJ4QDEVNB';
+const FORM = 'FORM-1741B1D56FE04480B0E4F5412FF9AE6ATYGR';
+
+/** @returns {object} 使用已安装OpenYida的编译、认证、校验和发布传输。 */
+function toolchain() {
+  const executable = cp.execFileSync('which', ['openyida'], { encoding: 'utf8' }).trim();
+  const root = path.resolve(path.dirname(fs.realpathSync(executable)), '..');
+  return {
+    transform: require(root + '/lib/core/babel-transform').default,
+    guard: require(root + '/lib/core/no-emoji-guard'),
+    client: require(root + '/lib/core/yida-client'),
+    utils: require(root + '/lib/core/utils'),
+    transport: require(root + '/lib/app/publish').sendSaveRequestWithAuth
+  };
+}
+
+/** @param {object} value Schema响应或Schema @returns {object} 完整Schema */
+function schemaContent(value) {
+  assert.ok(value && typeof value === 'object' && value.success !== false, 'Schema响应无效');
+  const content = value.content === undefined ? value : value.content;
+  const schema = typeof content === 'string' ? JSON.parse(content) : content;
+  assert.ok(schema && Array.isArray(schema.pages) && schema.actions, '缺少页面或动作配置');
+  return schema;
+}
+
+/** @param {object} schema 完整Schema @returns {{node:object,path:string}} 唯一原生Jsx节点 */
+function jsxNode(schema) {
+  const found = [];
+  /** @param {object} value 节点 @param {string} pointer JSON路径 @returns {void} */
+  function visit(value, pointer) {
+    if (!value || typeof value !== 'object') return;
+    if (value.componentName === 'Jsx' && value.props && value.props.render) found.push({ node: value, path: pointer });
+    for (const [key, child] of Object.entries(value)) visit(child, pointer + '/' + key);
+  }
+  visit(schema.pages, '/pages');
+  assert.equal(found.length, 1, '正式页必须包含唯一的既有Jsx渲染节点');
+  return found[0];
+}
+
+/** @param {string} source 源码 @param {boolean} render 是否为独立渲染 @param {object} tools 工具链 @returns {string} 平台编译码 */
+function compile(source, render, tools) {
+  const result = tools.transform(source, {}, render, { RE_VERSION: '7.4.0' });
+  if (result.error) throw result.error;
+  assert.ok(result.compiled, '平台编译码不能为空');
+  new vm.Script(render ? '(' + result.compiled + ')' : result.compiled);
+  return result.compiled;
+}
+
+/** @param {object} baseline 最新基线 @param {string} actions 合并后的JS @param {string} render 合并后的JSX @param {object} tools 工具链 @returns {{schema:object,paths:string[],functions:number}} 限定范围候选 */
+function buildCandidate(baseline, actions, render, tools = toolchain()) {
+  const schema = structuredClone(baseline);
+  const target = jsxNode(schema);
+  const module = { source: actions, compiled: compile(actions, false, tools) };
+  const names = [...actions.matchAll(/export\s+(?:async\s+)?function\s+(\w+)\s*\(/g)].map(match => match[1]);
+  assert.ok(names.length > 0, '动作模块没有导出函数');
+  assert.equal(names.length, new Set(names).size, '动作函数不能重名');
+  for (const item of schema.actions.list) assert.ok(names.includes(item.id), '不能移除原动作:' + item.id);
+  schema.actions.module = { ...schema.actions.module, ...module };
+  const oldNames = new Set(schema.actions.list.map(item => item.id));
+  schema.actions.list.push(...names.filter(id => !oldNames.has(id)).map(id => ({ id, title: id })));
+  target.node.props.render = { ...target.node.props.render, source: render, compiled: compile(render, true, tools) };
+  const paths = ['/actions/module/source', '/actions/module/compiled', '/actions/list',
+    target.path + '/props/render/source', target.path + '/props/render/compiled'];
+  // prd 只清理当前页面内的连接器描述装饰,不触及URL、参数、鉴权或连接器主资源。
+  const root = schema.pages[0].componentsTree[0];
+  (root.dataSource.online || []).forEach((dataSource, index) => {
+    const connector = dataSource.options && dataSource.options.connector;
+    if (!connector || typeof connector.description !== 'string') return;
+    const cleaned = connector.description.replace(tools.guard.EMOJI_PATTERN, '');
+    if (cleaned === connector.description) return;
+    connector.description = cleaned;
+    paths.push('/pages/0/componentsTree/0/dataSource/online/' + index + '/options/connector/description');
+  });
+  tools.guard.assertNoEmojiInValue(schema, { artifact: 'TimeCard双文件候选', code: 'OPENYIDA_FORM_SCHEMA_EMOJI_FORBIDDEN' });
+  verifyUnchanged(baseline, schema, paths);
+  return { schema, paths, functions: names.length };
+}
+
+/** @param {object} target 待规范化副本 @param {string} pointer 已验证JSON路径 @returns {void} */
+function omit(target, pointer) {
+  const parts = pointer.slice(1).split('/');
+  const parent = parts.slice(0, -1).reduce((node, key) => node[key], target);
+  delete parent[parts.at(-1)];
+}
+
+/** @param {object} before 基线 @param {object} after 候选或回读 @param {string[]} paths 允许变化路径 @returns {void} */
+function verifyUnchanged(before, after, paths) {
+  const a = structuredClone(before), b = structuredClone(after);
+  for (const pointer of [...paths, '/gmtModified']) { omit(a, pointer); omit(b, pointer); }
+  assert.deepEqual(b, a, '检测到发布范围外的Schema变化');
+}
+
+/** @param {object} baseline 候选绑定基线 @param {object} current 保存前线上基线 @returns {void} */
+function assertCurrentBaseline(baseline, current) {
+  assert.ok(Number.isFinite(baseline.gmtModified), '缺少服务端版本标识');
+  assert.ok(isDeepStrictEqual(baseline, current), '线上页面已变化,必须重新拉取并合并;停止发布');
+}
+
+/** @param {object} authRef 平台登录引用 @param {object} tools 工具链 @returns {Promise<object>} 只读最新Schema */
+async function fetchSchema(authRef, tools) {
+  const result = await tools.utils.requestWithAutoLogin(auth => tools.utils.httpGet(auth.baseUrl,
+    '/alibaba/web/' + APP + '/_view/query/formdesign/getFormSchema.json',
+    { formUuid: FORM, schemaVersion: 'V5' }), authRef);
+  assert.ok(result && result.success !== false && !result.__needLogin && !result.__csrfExpired, '读取线上Schema失败');
+  return schemaContent(result);
+}
+
+/** @param {object} baseline 已备份最新Schema @param {object} candidate 已验证候选 @param {string} output 证据目录 @param {object} tools 工具链 @returns {Promise<void>} 绑定原版本保存并独立回读 */
+async function publish(baseline, candidate, output, tools) {
+  const auth = tools.client.createAuthRef();
+  const current = await fetchSchema(auth, tools);
+  assertCurrentBaseline(baseline, current);
+  // fixme 保存始终传候选绑定的原revision,写前及写入时任意并发变化均不能被覆盖。
+  fs.writeFileSync(path.join(output, '发布尝试.json'), JSON.stringify({ formUuid: FORM, revision: baseline.gmtModified, attemptedAt: new Date().toISOString() }, null, 2));
+  const result = await tools.transport(auth, JSON.stringify(candidate.schema), APP, FORM, baseline.gmtModified);
+  fs.writeFileSync(path.join(output, '发布回执.json'), JSON.stringify(result, null, 2));
+  assert.ok(result && result.success, '发布未成功;先只读核对回执及线上状态,不自动重试');
+  const readback = await fetchSchema(auth, tools);
+  fs.writeFileSync(path.join(output, '发布后Schema.json'), JSON.stringify(readback, null, 2));
+  verifyUnchanged(candidate.schema, readback, []);
+  const receipt = { success: true, formUuid: FORM, version: result.content && result.content.version,
+    gmtModified: readback.gmtModified, originalRevision: baseline.gmtModified,
+    codeAndConfigurationVerified: true, retainedDataSources: readback.pages[0].componentsTree[0].dataSource.online.length,
+    runtimeSmokeVerified: false, url: auth.baseUrl + '/' + APP + '/workbench/' + FORM };
+  fs.writeFileSync(path.join(output, '独立回读验收.json'), JSON.stringify(receipt, null, 2));
+  console.log(JSON.stringify(receipt));
+}
+
+/** @returns {Promise<void>} 默认仅生成候选,显式--apply才实际发布指定正式页 */
+async function main() {
+  const [baselineFile, outputDir, ...flags] = process.argv.slice(2);
+  assert.ok(baselineFile && outputDir && flags.every(flag => flag === '--apply'),
+    '用法:node pages/scripts/publish-timecard-main.cjs <最新Schema文件> <证据目录> [--apply]');
+  const baseline = schemaContent(JSON.parse(fs.readFileSync(baselineFile, 'utf8')));
+  const tools = toolchain();
+  const actions = fs.readFileSync(path.join(__dirname, '../src/timecard-main.actions.js'), 'utf8');
+  const render = fs.readFileSync(path.join(__dirname, '../src/timecard-main.render.jsx'), 'utf8');
+  const candidate = buildCandidate(baseline, actions, render, tools);
+  fs.mkdirSync(outputDir, { recursive: true });
+  assert.ok(!fs.existsSync(path.join(outputDir, '发布尝试.json')), '证据目录已存在发布尝试,请先核对,不能盲目重试');
+  fs.writeFileSync(path.join(outputDir, '候选Schema.json'), JSON.stringify(candidate.schema, null, 2));
+  fs.writeFileSync(path.join(outputDir, '候选校验.json'), JSON.stringify({ functions: candidate.functions, allowedPaths: candidate.paths, baselineRevision: baseline.gmtModified }, null, 2));
+  if (flags.includes('--apply')) await publish(baseline, candidate, outputDir, tools);
+  else console.log(JSON.stringify({ prepared: true, functions: candidate.functions, changedPaths: candidate.paths.length, published: false }));
+}
+
+module.exports = { buildCandidate, assertCurrentBaseline, verifyUnchanged, schemaContent };
+if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; });

+ 17 - 11
pages/src/timecard-main.actions.js

@@ -1879,15 +1879,15 @@ export function getDayApprovalLock(formData) {
   let submitted = data[TIME_FIELD_IDS.approvalStatus] === APPROVAL_STATUS.IN_APPROVAL;
   let approved = data[TIME_FIELD_IDS.approvalStatus] === APPROVAL_STATUS.APPROVED;
   const mainSubmitted = [TIME_FIELD_IDS.dailySubmittedTotal, TIME_FIELD_IDS.projectSubmittedTotal,
-    TIME_FIELD_IDS.nonProjectSubmittedTotal, TIME_FIELD_IDS.otherSubmittedTotal];
+  TIME_FIELD_IDS.nonProjectSubmittedTotal, TIME_FIELD_IDS.otherSubmittedTotal];
   const mainApproved = [TIME_FIELD_IDS.dailyApprovedTotal, TIME_FIELD_IDS.dailyChangeTotal,
-    TIME_FIELD_IDS.projectApprovedTotal, TIME_FIELD_IDS.projectChangeTotal,
-    TIME_FIELD_IDS.nonProjectApprovedTotal, TIME_FIELD_IDS.nonProjectChangeTotal,
-    TIME_FIELD_IDS.otherApprovedTotal, TIME_FIELD_IDS.otherChangeTotal];
+  TIME_FIELD_IDS.projectApprovedTotal, TIME_FIELD_IDS.projectChangeTotal,
+  TIME_FIELD_IDS.nonProjectApprovedTotal, TIME_FIELD_IDS.nonProjectChangeTotal,
+  TIME_FIELD_IDS.otherApprovedTotal, TIME_FIELD_IDS.otherChangeTotal];
   submitted = submitted || mainSubmitted.some(field => Math.abs(parseFloat(data[field]) || 0) > 1e-6);
   approved = approved || mainApproved.some(field => Math.abs(parseFloat(data[field]) || 0) > 1e-6);
   [[BILLABLE_SUB, data[BILLABLE_SUB.table]], [NON_BILLABLE_SUB, data[NON_BILLABLE_SUB.table]],
-    [OTHER_SUB, data[OTHER_SUB.table]]].forEach(([fields, rows]) => {
+  [OTHER_SUB, data[OTHER_SUB.table]]].forEach(([fields, rows]) => {
     (Array.isArray(rows) ? rows : []).forEach(row => {
       if (Math.abs(parseFloat(row[fields.submitted]) || 0) > 1e-6) submitted = true;
       if (Math.abs(parseFloat(row[fields.approved]) || 0) > 1e-6
@@ -2541,8 +2541,10 @@ export function closingNativePage(response) {
  * @returns {Object} 与旧后端一致的六字段封账状态
  */
 export function summarizeNativeMonthClosing(monthText, records) {
-  const result = { monthText: monthText, closed: false, currentStatus: '',
-    approvedClosingCount: 0, approvedReopeningCount: 0, runningClosingCount: 0 };
+  const result = {
+    monthText: monthText, closed: false, currentStatus: '',
+    approvedClosingCount: 0, approvedReopeningCount: 0, runningClosingCount: 0
+  };
   let latestTime = null;
   records.forEach(record => {
     const data = this.closingNativeRecordData(record);
@@ -2587,15 +2589,19 @@ export async function queryNativeMonthClosingStatus(year, month) {
   const nextMonth = new Date(start + 8 * 60 * 60 * 1000);
   nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1);
   const end = nextMonth.getTime() - 8 * 60 * 60 * 1000 - 1;
-  const search = [{ key: 'dateField_mt6wzb17', componentName: 'DateField', type: 'DOUBLE',
-    operator: 'between', value: [start, end] }];
+  const search = [{
+    key: 'dateField_mt6wzb17', componentName: 'DateField', type: 'DOUBLE',
+    operator: 'between', value: [start, end]
+  }];
   const records = [];
   const seen = {};
   let totalCount = null;
   for (let page = 1; page <= 1000; page += 1) {
-    const response = await api.getProcessInstances({ appType: APP_TYPE,
+    const response = await api.getProcessInstances({
+      appType: APP_TYPE,
       formUuid: 'FORM-275BDAFE67FE4226B6BCF2BC6EEFFD4B4215', currentPage: page,
-      pageSize: 100, searchFieldJson: JSON.stringify(search) });
+      pageSize: 100, searchFieldJson: JSON.stringify(search)
+    });
     const current = this.closingNativePage(response);
     if (totalCount !== null && totalCount !== current.totalCount) throw new Error('封账流程分页期间数据变化,请重试');
     totalCount = current.totalCount;

+ 2 - 2
pages/src/timecard-main.render.jsx

@@ -491,8 +491,8 @@ function render() {
                   || _this.state._savingDraft
                   || _this.state._submitting;
                 return [
-                  <button key="save" style={btnS(false, actionDisabled)} disabled={actionDisabled} onClick={() => _this.saveDraft()}>💾 {T.btnSaveDraft}</button>,
-                  <button key="submit" style={btnS(true, actionDisabled)} disabled={actionDisabled} onClick={() => _this.submitForApproval()}>📤 {T.btnSubmit}</button>,
+                  <button key="save" style={btnS(false, actionDisabled)} disabled={actionDisabled} onClick={() => _this.saveDraft()}>{T.btnSaveDraft}</button>,
+                  <button key="submit" style={btnS(true, actionDisabled)} disabled={actionDisabled} onClick={() => _this.submitForApproval()}>{T.btnSubmit}</button>,
                 ];
               })()}
             </div>

+ 36 - 0
pages/tests/verify-timecard-main-publish.cjs

@@ -0,0 +1,36 @@
+'use strict';
+const fs = require('node:fs');
+const path = require('node:path');
+const assert = require('node:assert/strict');
+const { buildCandidate, assertCurrentBaseline, verifyUnchanged, schemaContent } = require('../scripts/publish-timecard-main.cjs');
+
+const file = process.argv[2];
+assert.ok(file, '请指定只读线上基线Schema文件;测试不发起网络请求');
+const baseline = schemaContent(JSON.parse(fs.readFileSync(file, 'utf8')));
+const actions = fs.readFileSync(path.join(__dirname, '../src/timecard-main.actions.js'), 'utf8');
+const render = fs.readFileSync(path.join(__dirname, '../src/timecard-main.render.jsx'), 'utf8');
+const candidate = buildCandidate(baseline, actions, render);
+assert.equal(candidate.functions, 110);
+assert.equal(candidate.schema.pages[0].componentsTree[0].dataSource.online.length, 9);
+verifyUnchanged(baseline, candidate.schema, candidate.paths);
+assertCurrentBaseline(baseline, structuredClone(baseline));
+
+const revisionChanged = structuredClone(baseline);
+revisionChanged.gmtModified++;
+assert.throws(() => assertCurrentBaseline(baseline, revisionChanged), /线上页面已变化/);
+const configurationChanged = structuredClone(baseline);
+configurationChanged.config += ' ';
+assert.throws(() => assertCurrentBaseline(baseline, configurationChanged), /线上页面已变化/);
+const unauthorizedChange = structuredClone(candidate.schema);
+unauthorizedChange.pages[0].componentsTree[0].dataSource.online.pop();
+assert.throws(() => verifyUnchanged(baseline, unauthorizedChange, candidate.paths));
+
+const missingAction = structuredClone(baseline);
+missingAction.actions.list.push({ id: 'unexpectedExistingAction', title: '既有动作' });
+assert.throws(() => buildCandidate(missingAction, actions, render), /不能移除原动作/);
+const duplicateJsx = structuredClone(baseline);
+duplicateJsx.pages[0].componentsTree.push({ componentName: 'Jsx', props: { render: {} } });
+assert.throws(() => buildCandidate(duplicateJsx, actions, render), /唯一的既有Jsx/);
+assert.throws(() => buildCandidate(baseline, actions, render + '\n// 💾'), /EMOJI|emoji/i);
+assert.throws(() => schemaContent({ success: false, content: baseline }), /Schema响应无效/);
+console.log('通过:双文件编译、原配置与9个数据源保留、并发版本拒绝、范围外变更拒绝、动作删除拒绝及完整内容校验。');