Quellcode durchsuchen

feat(mkl): sync contract production flow

malk vor 3 Wochen
Ursprung
Commit
895dd2ba64
3 geänderte Dateien mit 488 neuen und 153 gelöschten Zeilen
  1. 49 0
      doc/development.md
  2. 286 33
      doc/mankalong-contract-page-sync.js
  3. 153 120
      src/sample/mkl.js

+ 49 - 0
doc/development.md

@@ -6,6 +6,48 @@
 
 ---
 
+### 基础编程规范(宜搭页面与 MJS)
+
+- 页面组件 ID 统一集中配置;每个 ID 必须附中文名称注释,禁止在业务方法中散落硬编码。
+- 宜搭调用 MJS 且 MJS 需要页面能力时,必须显式传入 `$this: this`;MJS 内只使用传入的 `options.$this` 访问组件、按钮、`utils`、`router` 和 toast。MJS 方法自身的 `this` 指向 MJS 模块对象,`mjs.$this` 也不得作为当前宜搭页面实例使用。
+- 组件取值、赋值前必须判断组件是否存在;可选组件缺失时走兼容分支,不得直接调用 `getValue` 或 `setValue`。
+- 新增业务通过独立方法或显式参数扩展;与原方法共用时增加条件判断,默认不改变原有调用行为。
+- 异步流程按“加载 → 校验 → 请求 → 赋值 → 异常提示 → 收尾”组织,loading 必须在 `finally` 中关闭。
+- 变量优先使用 `const`,确需重新赋值时使用 `let`;方法保持单一职责,避免在页面事件中堆叠接口细节。
+- 外部地址、环境开关和敏感配置集中管理,测试与生产地址不得混用,密钥不得写入页面代码。
+
+#### 宜搭页面上下文传递
+
+```js
+// 宜搭页面:把当前页面实例显式交给 MJS
+export async function onClickConfirm() {
+  return mjs.corp.contract.confirm({
+    $this: this,
+    componentIds: {
+      // 合同编号
+      contractNo: 'textField_xxx',
+    },
+  });
+}
+
+// MJS:只能使用传入的 $this 访问宜搭页面能力
+async function confirm(options = {}) {
+  const $this = options.$this;
+  if (!$this || typeof $this.$ !== 'function') {
+    throw new Error('宜搭页面 $this 未传入');
+  }
+  const contractNo = $this.$(options.componentIds.contractNo);
+  if (!contractNo || typeof contractNo.setValue !== 'function') {
+    throw new Error('合同编号组件不存在或不支持赋值');
+  }
+  contractNo.setValue('HT-001');
+}
+```
+
+- 禁止在 MJS 方法中用 `this.$(...)`:此处 `this` 是 MJS 模块对象。
+- 禁止依赖 `mjs.$this.$(...)`:设计器预览、弹层、iframe 或多页面并存时可能不是当前页面实例。
+- 缺少 `$this` 或组件不存在时立即抛出明确异常,禁止继续调用 `null.getValue()` / `null.setValue()`。
+
 ### 前言
 
 - 组件赋值:文本和按钮显示修改需要使用 `set("content", "")`,修改按钮图标 `set("iconName", "")`, 赋值使用 `setVal`
@@ -1196,3 +1238,10 @@ export function onSupplierChange(ctx) {
 
 工作台4个任务和列表页面, 不能隐藏侧边栏 :: 免登追加corpid=xxx&dd_addcookie=true
 ```
+
+## 曼卡龙无主从合同组件页面规则
+
+- 页面没有“主从合同”组件、但关联特许经营合同时,合同确认必须按“子合同”处理。
+- 页面组件配置使用 `mode: ''`、`contractMode: '子合同'`,不得伪造一个组件 ID,也不得默认按主合同处理。
+- 合同确认仍必须读取真实的关联合同编号,并由子合同编号逻辑生成主合同号后缀。
+- 当前适用页面:加盟年度目标确认书、加盟合同主体变更协议、加盟终止协议、补充协议-加盟续签、合同主体变更协议。

+ 286 - 33
doc/mankalong-contract-page-sync.js

@@ -3,85 +3,317 @@
  *
  * 使用说明:
  * 1. 按当前页面修改 getMankalongContractFieldIds() 中的组件 ID。
- * 2. didMount 中先调用 await this._mklLoad(),再调用 initMankalongContractPage()。
- * 3. “生成合同”按钮绑定 onClickContractGenerate。
+ * 2. didMount 中先调用 await this._mklLoad(),再调用 await this.initMankalongContractPage()。
+ * 3. 保留页面原有“生成合同”按钮及 generateSubFormImages 请求逻辑。
+ *    开始、结束、成功后分别调用 this.startMankalongContractGenerate()、
+ *    this.finishMankalongContractGenerate()、this.markMankalongContractGenerated()。
  * 4. “合同确认”按钮绑定 onClickContractConfirm。
+ * 5. 唯一标识必须从当前页面组件读取,读取失败直接抛出异常;合同确认内部使用 toast loading/error。
+ * 6. 已部署旧复制区的页面按函数级合并,复制区外代码和“页面自定义初始化保留区”不得覆盖。
+ * 7. 页面没有“主从合同”组件、但业务上关联特许经营合同时,mode 设为空字符串,
+ *    contractMode 固定设为“子合同”;不得按主合同处理,也不得伪造组件 ID。
  *
  * 依赖:页面已加载 mjs.corp.mkl(https://mc.cloudpure.cn/mjs/mkl/mjs.min.js)。
  */
 
+// ============================================================
+// 曼卡龙合同初始化与按钮防抖(可复制同步区域开始)
+// ============================================================
+
 /**
- * @returns {Object} 当前合同页面固定配置
+ * 加载曼卡龙专用 MJS 公共库。
+ * @returns {Promise<void>} 加载完成
  */
-function getMankalongContractPageConfig() {
-  return {
-    formUuid: 'FORM-BBFA15D85B2E4E8D832C2F0FE49CAF782GO3',
-    subDirectory: '工程类',
-    dingOrgId: '908563201',
-    corpId: 'ding3735baea6bea68ac24f2f5cc6abecb85',
-  };
+export function _mklLoad() {
+  if (window.__mankalongMjsReady) return window.__mankalongMjsReady;
+  window.__mankalongMjsReady = new Promise((resolve, reject) => {
+    const existing = document.querySelector('script[data-mankalong-mjs="1"]');
+    if (existing) {
+      if (window.mjs && mjs.corp && mjs.corp.mkl) {
+        this._mklInit().then(resolve).catch(reject);
+      } else {
+        existing.addEventListener('load', () => this._mklInit().then(resolve).catch(reject));
+        existing.addEventListener('error', reject);
+      }
+      return;
+    }
+    const script = document.createElement('script');
+    script.type = 'text/javascript';
+    script.src = 'https://mc.cloudpure.cn/mjs/mkl/mjs.min.js?v=20260831-3';
+    script.dataset.mankalongMjs = '1';
+    script.onload = () => this._mklInit().then(resolve).catch(reject);
+    script.onerror = reject;
+    document.head.appendChild(script);
+  });
+  return window.__mankalongMjsReady;
+}
+
+/**
+ * 初始化 MJS 曼卡龙模块。
+ * @returns {Promise<void>} 初始化结果
+ */
+export async function _mklInit() {
+  await mjs.init(this, { vconsole: false });
+  mjs.corp.mkl.init({
+    environment: 'production',
+    testApi: 'https://mc.cloudpure.cn/frphz/mankalong',
+    productionApi: 'https://znht.mclon.com/api/mankalong',
+  });
 }
 
 /**
  * @returns {Object} 当前合同页面组件 ID
  */
-function getMankalongContractFieldIds() {
+export function getMankalongContractFieldIds() {
   return {
+    // 合同唯一标识(业务唯一号)
     uniqueId: 'textField_mte3gh61',
+    // 合同编号
     contractNo: 'textField_mrishceq',
+    // 主从合同
     mode: 'radioField_mryiusal',
+    // 没有主从合同组件时的固定业务值;关联特许经营合同的页面设为“子合同”
+    contractMode: '',
+    // 合同类型
     type: 'textField_mspzgras',
+    // 关联合同编号
     masterNo: 'textField_mrylbq28',
+    // 合同标题
     name: 'textField_mryko9bh',
+    // 合同正文
     attachment: 'attachmentField_msv7rz7m',
+    // 发起人
+    initiator: 'employeeField_mrishcex',
+    // 生成合同按钮
     generateButton: 'button_msiiyhct',
+    // 合同确认按钮
     confirmButton: 'button_mtfaz1tq',
+    // 仅发起人可见区块
+    initiatorOnlySection: 'pageSection_mte7a077',
+    // 合同操作区块
+    contractOperationSection: 'pageSection_mte7a078',
+    // 合同预览区块
+    previewSection: 'pageSection_msruj1an',
+    // 合同预览 iframe
+    previewFrame: 'iframe_msn38cuu',
+    // 生成预览附件地址
+    previewUrl: 'textField_msnynxia',
   };
 }
 
+/**
+ * 从当前页面实例读取合同业务唯一标识。
+ * @param {Object} page 当前宜搭页面实例
+ * @returns {string} 合同业务唯一标识
+ */
+function readMankalongContractUniqueId(page, componentId) {
+  const component = page && typeof page.$ === 'function' ? page.$(componentId) : null;
+  if (!component || typeof component.getValue !== 'function') {
+    throw new Error('合同唯一标识组件不存在: ' + componentId);
+  }
+  let value = component.getValue();
+  if (Array.isArray(value)) {
+    value = value[0] && (value[0].value || value[0].label || value[0].name);
+  } else if (value && typeof value === 'object') {
+    value = value.value || value.label || value.name;
+  }
+  const normalized = String(value || '').trim();
+  if (!normalized) throw new Error('合同唯一标识不能为空');
+  return normalized;
+}
+
+/**
+ * @param {Object} page 当前宜搭页面实例
+ * @param {string} componentId 字段组件 ID
+ * @param {string} fieldName 字段名称
+ * @returns {string} 字段值
+ */
+function readMankalongContractFieldValue(page, componentId, fieldName) {
+  const component = page && typeof page.$ === 'function' ? page.$(componentId) : null;
+  if (!component || typeof component.getValue !== 'function') {
+    throw new Error(fieldName + '组件不存在: ' + componentId);
+  }
+  let value = component.getValue();
+  if (Array.isArray(value)) {
+    value = value[0] && (value[0].value || value[0].label || value[0].name || value[0].zh_CN || value[0].en_US);
+  } else if (value && typeof value === 'object') {
+    value = value.value || value.label || value.name || value.zh_CN || value.en_US;
+  }
+  const normalized = String(value || '').trim();
+  if (!normalized) throw new Error(fieldName + '不能为空');
+  return normalized;
+}
+
 /**
  * @returns {Promise<Object>} 初始化后的合同页面状态
  */
 export async function initMankalongContractPage() {
-  const ids = getMankalongContractFieldIds();
+  const ids = this.getMankalongContractFieldIds();
   if (mjs.env == 0) {
+    const initiatorOnlySection = this.$(ids.initiatorOnlySection);
+    if (!initiatorOnlySection || typeof initiatorOnlySection.setBehavior !== 'function') {
+      throw new Error('仅发起人可见区块不存在或不支持状态设置: ' + ids.initiatorOnlySection);
+    }
+    initiatorOnlySection.setBehavior('HIDDEN');
+
     const contractNo = this.$(ids.contractNo);
-    const attachment = this.$(ids.attachment);
+    const contractValue = contractNo && typeof contractNo.getValue === 'function'
+      ? contractNo.getValue()
+      : '';
+    const contractNumber = Array.isArray(contractValue)
+      ? (contractValue[0] && (contractValue[0].value || contractValue[0].label || contractValue[0].name)) || ''
+      : contractValue && typeof contractValue === 'object'
+        ? contractValue.value || contractValue.label || contractValue.name || ''
+        : String(contractValue || '');
+    if (contractNumber) {
+      const unique = this.$(ids.uniqueId);
+      if (!unique || typeof unique.setValue !== 'function') {
+        throw new Error('合同唯一标识组件不存在或不支持赋值: ' + ids.uniqueId);
+      }
+      const userId = this.utils && typeof this.utils.getLoginUserId === 'function'
+        ? this.utils.getLoginUserId()
+        : window.loginUser && window.loginUser.userId;
+      if (!userId) throw new Error('当前用户 ID 不能为空,无法生成新的合同唯一标识');
+      unique.setValue(String(userId) + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10));
+    }
     if (contractNo && typeof contractNo.reset === 'function') contractNo.reset();
+    const attachment = this.$(ids.attachment);
     if (attachment && typeof attachment.reset === 'function') attachment.reset();
+
+    // 页面自定义初始化保留区开始
+    // 页面自定义初始化保留区结束
+  } else {
+    const initiatorUserId = readMankalongContractFieldValue(this, ids.initiator, '发起人');
+    const loginUserId = this.utils && typeof this.utils.getLoginUserId === 'function'
+      ? this.utils.getLoginUserId()
+      : window.loginUser && window.loginUser.userId;
+    if (!loginUserId) throw new Error('当前登录用户 ID 不能为空');
+    if (initiatorUserId !== String(loginUserId)) {
+      const initiatorOnlySection = this.$(ids.initiatorOnlySection);
+      if (!initiatorOnlySection || typeof initiatorOnlySection.setBehavior !== 'function') {
+        throw new Error('仅发起人可见区块不存在或不支持状态设置: ' + ids.initiatorOnlySection);
+      }
+      initiatorOnlySection.setBehavior('HIDDEN');
+    }
+    const contractOperationSection = this.$(ids.contractOperationSection);
+    if (!contractOperationSection || typeof contractOperationSection.setBehavior !== 'function') {
+      throw new Error('合同操作区块不存在或不支持状态设置: ' + ids.contractOperationSection);
+    }
+    contractOperationSection.setBehavior('HIDDEN');
   }
   return mjs.corp.mkl.initContractPage({
+    $this: this,
     componentIds: ids,
   });
 }
 
 /**
- * @returns {Promise<*>} 合同预览生成结果
+ * @returns {boolean} 是否允许开始合同生成
  */
-export async function onClickContractGenerate() {
-  const config = getMankalongContractPageConfig();
-  const ids = getMankalongContractFieldIds();
-  return mjs.corp.mkl.runContractGenerate({
-    componentIds: ids,
-    generateContract: () => mjs.corp.mkl.generateSubFormImages({
-      formUuid: config.formUuid,
-      type: 'preview',
-      componentIds: ids,
-      dingOrgId: config.dingOrgId,
-      corpId: config.corpId,
-    }),
+export function startMankalongContractGenerate() {
+  return mjs.corp.mkl.startContractGenerate({
+    $this: this,
+    componentIds: this.getMankalongContractFieldIds(),
+  });
+}
+
+/**
+ * @returns {Object} 当前页面合同状态
+ */
+export function finishMankalongContractGenerate() {
+  return mjs.corp.mkl.finishContractGenerate({
+    $this: this,
+    componentIds: this.getMankalongContractFieldIds(),
+  });
+}
+
+/**
+ * 原有合同生成请求成功后调用,仅更新合同确认按钮 UI 状态。
+ * @returns {Object} 当前页面合同状态
+ */
+export function markMankalongContractGenerated() {
+  return mjs.corp.mkl.markContractGenerated({
+    $this: this,
+    componentIds: this.getMankalongContractFieldIds(),
+  });
+}
+
+/**
+ * 通过原有合同合成接口生成正式文件,供 MJS 合同确认流程上传 OSS。
+ * @param {Object} params 合同生成参数
+ * @returns {Promise<string>} 合成文件地址
+ */
+export async function generateMankalongContractFile({ contractNumber, mode, allowMissingInstanceId }) {
+  const ids = this.getMankalongContractFieldIds();
+  const fieldsResponse = await mjs.request.xhr.doPost('/api/yida/getFormFields', {
+    formUuid: getFormUuid(),
+  }, {}, {
+    ignoreResponse: true,
+    noLoading: true,
+    noErrorTip: true,
+  });
+  const fieldIds = fieldsResponse && Array.isArray(fieldsResponse.data)
+    ? fieldsResponse.data
+    : [];
+  const formData = fieldIds
+    .map((fieldId) => {
+      try {
+        const component = fieldId && this.$(fieldId);
+        if (!component || typeof component.getValue !== 'function') {
+          console.warn('跳过当前页面不存在的组件 ID:', fieldId);
+          return null;
+        }
+        return {
+          structKey: fieldId,
+          structValue: String(component.getValue() || ''),
+        };
+      } catch (error) {
+        console.warn('读取组件 ID 失败,已跳过:', fieldId, error);
+        return null;
+      }
+    })
+    .filter((item) => item !== null);
+  const instanceId = this.utils.router.getQuery('procInsId')
+    || (mjs.com && mjs.com.getFormInstIdByUrl && mjs.com.getFormInstIdByUrl());
+  const generateMode = mode || 'generate';
+  if (generateMode === 'generate' && !instanceId && !allowMissingInstanceId) {
+    throw new Error('当前页面缺少流程实例 ID,无法生成正式合同');
+  }
+  const body = {
+    formUuid: getFormUuid(),
+    mappings: [],
+    formData,
+    type: generateMode,
+    unique: readMankalongContractUniqueId(this, ids.uniqueId),
+    instanceId: instanceId || '',
+    preSubmit: allowMissingInstanceId === true,
+    dingOrgId: '908563201',
+    dingUid: this.state && this.state.userId || this.utils.getLoginUserId(),
+    corpId: 'ding3735baea6bea68ac24f2f5cc6abecb85',
+    contractNumber,
+  };
+  const response = await mjs.request.xhr.doPost('/api/yida/subform-to-image', {}, body, {
+    noLoading: true,
+    noErrorTip: true,
   });
+  const fileUrl = response && (response.message || response.downloadUrl || response.fileUrl || response.url);
+  if (!fileUrl) throw new Error('合同合成接口未返回文件地址');
+  return fileUrl;
 }
 
 /**
  * @returns {Promise<Object|null>} 合同确认结果
  */
 export async function onClickContractConfirm() {
-  const config = getMankalongContractPageConfig();
-  const ids = getMankalongContractFieldIds();
+  const ids = this.getMankalongContractFieldIds();
+  const uniqueId = readMankalongContractUniqueId(this, ids.uniqueId);
   return mjs.corp.mkl.runContractConfirm({
+    $this: this,
     componentIds: ids,
-    subDirectory: config.subDirectory,
+    uniqueId,
+    contractType: readMankalongContractFieldValue(this, ids.type, '合同类型'),
+    contractMode: ids.contractMode || readMankalongContractFieldValue(this, ids.mode, '合同主从类型'),
+    subDirectory: '工程类',
     generateMode: 'generate',
     allowMissingInstanceId: true,
     generateContract: (params) => this.generateMankalongContractFile(params),
@@ -89,10 +321,31 @@ export async function onClickContractConfirm() {
 }
 
 /**
- * @returns {void} 清理合同页面定时器
+ * @returns {boolean} 是否允许提交
  */
-export function didUnmount() {
-  if (window.mjs && mjs.corp && mjs.corp.mkl) {
-    mjs.corp.mkl.disposeContractPage();
+export function beforeSubmitContractReady() {
+  if (!window.mjs || !mjs.corp || !mjs.corp.mkl) {
+    this.utils.dialog({
+      type: 'alert',
+      title: '提示',
+      content: '合同还未正式生成,请操作!',
+    });
+    return false;
   }
+  return mjs.corp.mkl.beforeSubmitContract({
+    $this: this,
+    componentIds: this.getMankalongContractFieldIds(),
+  });
 }
+
+/**
+ * @returns {void} 清理合同页面状态
+ */
+export function disposeMankalongContractPage() {
+  if (!window.mjs || !mjs.corp || !mjs.corp.mkl) return;
+  mjs.corp.mkl.disposeContractPage({ $this: this });
+}
+
+// ============================================================
+// 曼卡龙合同初始化与按钮防抖(可复制同步区域结束)
+// ============================================================

+ 153 - 120
src/sample/mkl.js

@@ -12,21 +12,30 @@ function _page () {
   return mjs.$this;
 }
 
-function _textValue (compId) {
-  if (!compId) return "";
-  const comp = _page().$(compId);
-  if (!comp || typeof comp.getValue !== "function") return "";
-  const value = comp.getValue();
+function _scalarValue (value) {
   if (Array.isArray(value)) {
-    const first = value[0];
-    return first && (first.value || first.label || first.name) || "";
+    return _scalarValue(value[0]);
   }
   if (value && typeof value === "object") {
-    return value.value || value.label || value.name || "";
+    return value.value || value.label || value.name || value.zh_CN || value.en_US || "";
   }
   return String(value || "");
 }
 
+function _textValue (compId, page = _page()) {
+  if (!compId) return "";
+  const comp = page && page.$(compId);
+  if (comp && typeof comp.getValue === "function") {
+    try {
+      const value = _scalarValue(comp.getValue());
+      if (_hasValue(value)) return value;
+    } catch (error) {
+      console.warn("读取页面组件值失败:", compId, error);
+    }
+  }
+  return "";
+}
+
 function _hasValue (value) {
   if (Array.isArray(value)) return value.length > 0;
   return value !== null && value !== undefined && String(value).trim() !== "";
@@ -66,44 +75,18 @@ async function _postRaw (url, params = {}, body = {}) {
   return response && response.data;
 }
 
-/**
- * 调用宜搭图片生成接口并保留 ApiResponse 外层结构。
- * 该接口返回 success/message/data,不使用合同确认接口的 code/data 结构。
- * @param {string} url 请求地址
- * @param {Object} body 请求参数
- * @returns {Promise<Object>} 宜搭图片生成响应
- */
-async function _postYidaEnvelope (url, body = {}) {
-  const response = await mjs.request.xhr.doPost(url, {}, body, {
-    ..._requestConfig(),
-    ignoreResponse: true,
-  });
-  const result = response && response.data;
-  if (!result || result.success !== true) {
-    throw new Error(result && (result.message || result.msg) || "合同生成接口调用失败");
-  }
-  return result;
-}
-
 function _componentValue (componentId) {
   if (!componentId) return "";
   const component = _page().$(componentId);
   return component && typeof component.getValue === "function" ? component.getValue() : "";
 }
 
-function _safeSetValue (componentId, value) {
-  if (!componentId) return;
-  const component = _page().$(componentId);
-  if (component && typeof component.setValue === "function") component.setValue(value);
-}
-
 function _contractState (page) {
   if (!page.__mankalongContractState) {
     page.__mankalongContractState = {
       generated: false,
       generating: false,
       confirming: false,
-      resetTimer: null,
     };
   }
   return page.__mankalongContractState;
@@ -112,7 +95,16 @@ function _contractState (page) {
 function _setButtonBehavior (page, componentId, behavior) {
   if (!componentId) return;
   const button = page.$(componentId);
-  if (button && typeof button.setBehavior === "function") button.setBehavior(behavior);
+  if (button && typeof button.set === "function") button.set("behavior", behavior);
+}
+
+function _contractPage (options = {}) {
+  // ppExt: page 及 mjs.$this 仅兼容已发布旧页面;新增宜搭调用必须显式传 options.$this。
+  const page = options.$this || options.page || _page();
+  if (!page || typeof page.$ !== "function") {
+    throw new Error("宜搭页面 $this 未传入");
+  }
+  return page;
 }
 
 function _normalizeError (error, fallback) {
@@ -161,10 +153,34 @@ export default {
    * 把已生成文件同步到 OSS。
    * @param {string} fileUrl 文件地址
    * @param {string} subDirectory contract 下的二级目录
+   * @param {string} contractName 合同名称
+   * @param {string} contractNumber 合同编号
    * @returns {Promise<Array>} 宜搭附件数组
    */
-  async syncContractToOss (fileUrl, subDirectory) {
-    return _post("/contract/confirm", { fileUrl, subDirectory });
+  async syncContractToOss (fileUrl, subDirectory, contractName, contractNumber) {
+    return _post("/contract/confirm", { fileUrl, subDirectory, contractName, contractNumber });
+  },
+
+  /**
+   * 读取合同业务唯一标识。合同确认链路必须直接从页面组件读取。
+   * @param {string} componentId 唯一标识组件 ID
+   * @returns {string} 合同业务唯一标识
+   */
+  getContractUniqueId (componentId) {
+    if (!componentId) throw new Error("合同唯一标识组件 ID 未配置");
+    const page = _page();
+    const component = page && page.$(componentId);
+    if (!component || typeof component.getValue !== "function") {
+      throw new Error(`合同唯一标识组件不存在: ${componentId}`);
+    }
+    let value;
+    try {
+      value = _scalarValue(component.getValue());
+    } catch (error) {
+      throw new Error(`读取合同唯一标识失败: ${componentId}`);
+    }
+    if (!_hasValue(value)) throw new Error("合同唯一标识不能为空");
+    return value;
   },
 
   /**
@@ -207,16 +223,16 @@ export default {
       mappings: Array.isArray(options.mappings) ? options.mappings : [],
       formData,
       type,
-      unique: _componentValue(ids.uniqueId),
+      unique: options.uniqueId || this.getContractUniqueId(ids.uniqueId),
       instanceId: options.instanceId || "",
       dingOrgId: options.dingOrgId || "",
       dingUid: options.dingUid || page.utils.getLoginUserId(),
       corpId: options.corpId || "",
     };
-    const result = await _postYidaEnvelope("/api/yida/subform-to-image", body);
+    const result = await _post("/api/yida/subform-to-image", body);
     const fileUrl = result && (result.message || result.downloadUrl || result.fileUrl || result.url);
     if (fileUrl && ids.previewFrame) _page().$(ids.previewFrame).set("src", fileUrl);
-    if (fileUrl && ids.previewSection) _page().$(ids.previewSection).setBehavior("NORMAL");
+    if (fileUrl && ids.previewSection) _setButtonBehavior(_page(), ids.previewSection, "NORMAL");
     return fileUrl || null;
   },
 
@@ -352,83 +368,70 @@ export default {
 
   /**
    * 初始化曼卡龙合同页面状态。
-   * 提交页延迟清空复制流程可能带入的合同编号和合同正文,确认按钮默认禁用
+   * 初始化合同按钮状态;提交页字段清空由页面原有初始化逻辑处理
    * @param {Object} options 页面组件 ID
    * @returns {Object} 当前页面合同状态
    */
   initContractPage (options = {}) {
-    const page = _page();
+    const page = _contractPage(options);
     const ids = options.componentIds || {};
     const state = _contractState(page);
     state.generated = false;
     state.generating = false;
     state.confirming = false;
-    if (state.resetTimer) clearTimeout(state.resetTimer);
-    state.resetTimer = null;
 
     _setButtonBehavior(page, ids.generateButton, "NORMAL");
     _setButtonBehavior(page, ids.confirmButton, "DISABLED");
+    return state;
+  },
 
-    if (typeof mjs !== "undefined" && String(mjs.env) === "0") {
-      state.resetTimer = setTimeout(() => {
-        _safeSetValue(ids.contractNo, "");
-        _safeSetValue(ids.attachment, []);
-        state.generated = false;
-        _setButtonBehavior(page, ids.confirmButton, "DISABLED");
-        state.resetTimer = null;
-      }, 800);
-    }
+  /**
+   * 标记页面原有合同生成流程已完成,仅更新确认按钮 UI 状态。
+   * @param {Object} options 页面组件 ID
+   * @returns {Object} 当前页面合同状态
+   */
+  markContractGenerated (options = {}) {
+    const page = _contractPage(options);
+    const state = _contractState(page);
+    state.generated = true;
+    _setButtonBehavior(page, options.componentIds && options.componentIds.confirmButton, "NORMAL");
     return state;
   },
 
   /**
-   * 清理合同页面初始化定时器。
-   * @returns {void}
+   * 开始页面原有合同生成流程,仅禁用生成按钮并记录进行中状态。
+   * @param {Object} options 页面组件 ID
+   * @returns {boolean} 是否允许开始生成
    */
-  disposeContractPage () {
-    const page = _page();
-    const state = page.__mankalongContractState;
-    if (state && state.resetTimer) clearTimeout(state.resetTimer);
-    if (state) state.resetTimer = null;
+  startContractGenerate (options = {}) {
+    const page = _contractPage(options);
+    const state = _contractState(page);
+    if (state.generating || state.confirming) return false;
+    state.generating = true;
+    _setButtonBehavior(page, options.componentIds && options.componentIds.generateButton, "DISABLED");
+    return true;
   },
 
   /**
-   * 合同生成按钮防抖:请求期间禁用生成和确认,成功后解锁确认
-   * @param {Object} options 生成参数
-   * @returns {Promise<*>} 生成结果
+   * 结束页面原有合同生成流程,仅恢复生成按钮状态
+   * @param {Object} options 页面组件 ID
+   * @returns {Object} 当前页面合同状态
    */
-  async runContractGenerate (options = {}) {
-    const page = _page();
-    const ids = options.componentIds || {};
+  finishContractGenerate (options = {}) {
+    const page = _contractPage(options);
     const state = _contractState(page);
-    if (state.generating || state.confirming) return null;
-    if (typeof options.generateContract !== "function") {
-      throw new Error("必须传入合同生成回调");
-    }
+    state.generating = false;
+    _setButtonBehavior(page, options.componentIds && options.componentIds.generateButton, "NORMAL");
+    return state;
+  },
 
-    state.generating = true;
-    _setButtonBehavior(page, ids.generateButton, "DISABLED");
-    _setButtonBehavior(page, ids.confirmButton, "DISABLED");
-    try {
-      const generated = await options.generateContract.call(page, options);
-      const fileUrl = typeof generated === "string"
-        ? generated
-        : generated && (generated.downloadUrl || generated.fileUrl || generated.url);
-      if (!fileUrl) throw new Error("合同生成接口未返回文件地址");
-      state.generated = true;
-      _setButtonBehavior(page, ids.confirmButton, "NORMAL");
-      page.utils.toast({ type: "success", title: "合同生成成功,可进行合同确认" });
-      return generated;
-    } catch (error) {
-      state.generated = false;
-      _setButtonBehavior(page, ids.confirmButton, "DISABLED");
-      const normalizedError = _normalizeError(error, "合同生成失败");
-      page.utils.toast({ type: "error", title: normalizedError.message });
-      throw normalizedError;
-    } finally {
-      state.generating = false;
-      _setButtonBehavior(page, ids.generateButton, "NORMAL");
-    }
+  /**
+   * 清理合同页面初始化定时器。
+   * @returns {void}
+   */
+  disposeContractPage (options = {}) {
+    const page = _contractPage(options);
+    delete page.__mankalongContractState;
   },
 
   /**
@@ -437,7 +440,7 @@ export default {
    * @returns {Promise<Object|null>} 合同确认结果
    */
   async runContractConfirm (options = {}) {
-    const page = _page();
+    const page = _contractPage(options);
     const ids = options.componentIds || {};
     const state = _contractState(page);
     if (state.generating || state.confirming) return null;
@@ -457,40 +460,64 @@ export default {
    * 合同确认完整流程:合同号 → 原合同生成回调 → OSS → 附件回写。
    * @param {Object} options 流程参数
    * @param {Object} options.componentIds 页面组件 ID
-   * @param {string} options.subDirectory OSS 二级目录
+   * OSS 二级目录使用页面传入的合同类型;未传时读取 componentIds.type(textField_mspzgras)
    * @param {boolean} options.allowMissingInstanceId 预提交正式合成时允许暂缺流程实例 ID
    * @param {Function} options.generateContract 原合同生成回调,必须返回文件 URL 或包含 downloadUrl 的对象
    * @returns {Promise<Object>} 合同号及附件
    */
   async confirmContract (options = {}) {
-    const page = _page();
+    // fixme: MJS 方法内 this 指向模块对象;页面能力必须使用宜搭显式传入的 options.$this。
+    // ppExt: options.page 仅兼容已发布的旧页面调用,新代码统一传 $this。
+    const page = _contractPage(options);
     const ids = options.componentIds || {};
-    const closeLoading = page.utils.toast({
-      type: "loading",
-      title: "合同生成中",
-      hasMask: true,
-    });
+    let closeLoading = null;
+    const stopLoading = () => {
+      if (typeof closeLoading === "function") {
+        closeLoading();
+        closeLoading = null;
+      }
+    };
     try {
-      if (!ids.uniqueId || !ids.contractNo || !ids.mode || !ids.type || !ids.attachment) {
+      closeLoading = page.utils.toast({
+        type: "loading",
+        title: "合同确认中",
+      });
+      const configuredContractMode = String(options.contractMode || "").trim();
+      if (!ids.uniqueId || !ids.contractNo || (!configuredContractMode && !ids.mode) || !ids.type || !ids.attachment) {
         throw new Error("合同确认组件 ID 配置不完整");
       }
       if (typeof options.generateContract !== "function") {
         throw new Error("必须传入 generateContract 回调");
       }
 
-      let contractNumber = _textValue(ids.contractNo);
-      if (!contractNumber) {
+      // fixme: 设计器预览时 mjs.$this 可能不是当前按钮所在页面,优先使用页面直接读取后传入的合同类型。
+      const contractType = options.contractType || _textValue(ids.type, page);
+      if (!contractType) throw new Error("合同类型不能为空");
+      const subDirectory = contractType;
+      const contractName = _textValue(ids.name, page);
+      if (!contractName) throw new Error("合同名称不能为空");
+      // prd: 无主从合同组件的页面由调用方显式传入“主合同”,不伪造组件 ID。
+      const contractMode = configuredContractMode || _textValue(ids.mode, page);
+      if (!contractMode) throw new Error("合同主从类型不能为空");
+      let contractNumber = _textValue(ids.contractNo, page);
+      const isChildContract = contractMode === "子合同" || contractMode === "从合同";
+      // 子合同必须按当前唯一标识重新走分配逻辑,避免复制流程残留旧合同号时跳过后缀递增。
+      if (!contractNumber || isChildContract) {
         const numberResult = await this.allocateContractNumber({
-          targetUniqueId: _textValue(ids.uniqueId),
-          contractType: _textValue(ids.type),
-          contractMode: _textValue(ids.mode),
-          masterContractNumber: _textValue(ids.masterNo),
-          contractName: _textValue(ids.name),
+          targetUniqueId: options.uniqueId || this.getContractUniqueId(ids.uniqueId),
+          contractType,
+          contractMode,
+          masterContractNumber: _textValue(ids.masterNo, page),
+          contractName,
           yearMonth: options.yearMonth || "",
         });
         contractNumber = numberResult && numberResult.contractNumber;
         if (!contractNumber) throw new Error("合同号接口未返回合同号");
-        page.$(ids.contractNo).setValue(contractNumber);
+        const contractNoComp = page && page.$(ids.contractNo);
+        if (!contractNoComp || typeof contractNoComp.setValue !== "function") {
+          throw new Error("合同编号组件不存在或不支持赋值: " + ids.contractNo);
+        }
+        contractNoComp.setValue(contractNumber);
       }
 
       const generated = await options.generateContract.call(page, {
@@ -503,7 +530,7 @@ export default {
         : generated && (generated.downloadUrl || generated.fileUrl || generated.url);
       if (!fileUrl) throw new Error("原合同生成接口未返回文件地址");
 
-      const ossResult = await this.syncContractToOss(fileUrl, options.subDirectory);
+      const ossResult = await this.syncContractToOss(fileUrl, subDirectory, contractName, contractNumber);
       const attachments = Array.isArray(ossResult)
         ? ossResult
         : ossResult && Array.isArray(ossResult.data) ? ossResult.data : [];
@@ -513,38 +540,44 @@ export default {
         throw new Error("合同附件组件不存在或不支持赋值");
       }
       attachmentComp.setValue(attachments);
+      stopLoading();
       page.utils.toast({
         type: "success",
-        title: "正式合同生成成功",
+        title: "合同确认成功",
+        duration: 3000,
       });
       return { contractNumber, attachments };
     } catch (error) {
       const normalizedError = error instanceof Error
         ? error
         : new Error(error && (error.message || error.msg) || String(error || "合同确认失败"));
+      stopLoading();
       page.utils.toast({
         type: "error",
-        title: normalizedError.message,
+        title: normalizedError.message || "合同确认失败",
+        duration: 5000,
       });
       throw normalizedError;
-    } finally {
-      if (typeof closeLoading === "function") closeLoading();
     }
   },
 
   /**
    * 提交前校验合同号和合同附件是否已经生成。
-   * @param {Object} componentIds 页面组件 ID
+   * @param {Object} options 页面实例与组件 ID;旧调用可直接传组件 ID
    * @returns {boolean} 是否允许提交
    */
-  beforeSubmitContract (componentIds = {}) {
-    const number = _textValue(componentIds.contractNo);
-    const attachmentComp = componentIds.attachment && _page().$(componentIds.attachment);
+  beforeSubmitContract (options = {}) {
+    const page = options.$this || options.page
+      ? _contractPage(options)
+      : _page();
+    const componentIds = options.componentIds || options;
+    const number = _textValue(componentIds.contractNo, page);
+    const attachmentComp = componentIds.attachment && page.$(componentIds.attachment);
     const attachment = attachmentComp && typeof attachmentComp.getValue === "function"
       ? attachmentComp.getValue()
       : null;
     if (_hasValue(number) && _hasValue(attachment)) return true;
-    _page().utils.dialog({
+    page.utils.dialog({
       type: "alert",
       title: "提示",
       content: "合同还未正式生成,请操作!",