From 13630045dfa3e9c10181bc9d69fc8721b5abe3ab Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Sat, 31 Oct 2026 22:34:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(contract):=20=E6=8E=A5=E5=85=A5=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E5=90=88=E5=90=8C=E6=89=93=E5=8D=B0=E4=B8=8E?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E6=96=87=E6=A1=A3=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA=E5=88=86=E4=BA=AB?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/miniprogram-ui-spec-sanqiang.md | 4 + .../plans/2026-10-31-mobile-contract-print.md | 84 + ...2026-10-31-mobile-contract-print-design.md | 17 + miniprogram/README.md | 2 +- miniprogram/pages.json | 1 + .../pages/common/contract-document.vue | 75 + .../scripts/test-contract-document.mjs | 98 + miniprogram/utils/contract-document.js | 31 + .../scripts/mobile-contract-delivery.test.ts | 151 ++ .../scripts/mobile-contract-seal-drag.test.ts | 56 + .../scripts/test-mobile-contract-print.mjs | 536 ++++++ web-admin/src/views/m/crm/contract/detail.vue | 19 +- .../contract/print/MobileContractEditor.vue | 782 ++++++++ .../contract/print/MobileContractPrint.vue | 1654 +++++++++++++++++ .../crm/contract/print/deliverContractFile.ts | 90 + .../m/crm/contract/print/sealPointerDrag.ts | 70 + 16 files changed, 3666 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-31-mobile-contract-print.md create mode 100644 docs/superpowers/specs/2026-10-31-mobile-contract-print-design.md create mode 100644 miniprogram/pages/common/contract-document.vue create mode 100644 miniprogram/scripts/test-contract-document.mjs create mode 100644 miniprogram/utils/contract-document.js create mode 100644 web-admin/scripts/mobile-contract-delivery.test.ts create mode 100644 web-admin/scripts/mobile-contract-seal-drag.test.ts create mode 100644 web-admin/scripts/test-mobile-contract-print.mjs create mode 100644 web-admin/src/views/m/crm/contract/print/MobileContractEditor.vue create mode 100644 web-admin/src/views/m/crm/contract/print/MobileContractPrint.vue create mode 100644 web-admin/src/views/m/crm/contract/print/deliverContractFile.ts create mode 100644 web-admin/src/views/m/crm/contract/print/sealPointerDrag.ts diff --git a/docs/miniprogram-ui-spec-sanqiang.md b/docs/miniprogram-ui-spec-sanqiang.md index 18f8ca39b..f0c2ea7be 100644 --- a/docs/miniprogram-ui-spec-sanqiang.md +++ b/docs/miniprogram-ui-spec-sanqiang.md @@ -448,3 +448,7 @@ CRM 管理六个界面及运输计划、危化品证上传界面的普通操作 后续 H5 改造以客户、产品、报价单页面为准。规范正文见 OpenSpec「H5 样式改造补遗(以报价单管理落地为准)」。要点:绿头 Tab 白字均分、筛选行白字透明底、更多筛选底栏重置/确定、滚动分页、即时检索、表单绿竖条标题与固定底栏、左右只留壳层间距、明细与选择器用 Vant 底部弹层、详情 Tab 作锚点且字段与 Web 查看弹窗一致。 + +### 12.9 合同打印与富文本编辑 + +移动 H5 合同打印使用 Web 打印功能的独立副本,参考 `web-admin/src/views/crm/contract/components/ContractDetailDialog.vue`,不得更改 Web 原组件。采用全屏编辑面板,顶部标题与提示、中央 TinyMCE 编辑区、底部换行操作按钮。保留合同原纸张版式,窄屏允许正文横向滚动;工具栏允许折行,表格宽高通过属性菜单设置。沿用小程序主色,样式限于移动打印面板。Word/PDF 在微信中经原生文档页打开和转发;打印按钮生成 PDF。 diff --git a/docs/superpowers/plans/2026-10-31-mobile-contract-print.md b/docs/superpowers/plans/2026-10-31-mobile-contract-print.md new file mode 100644 index 000000000..be20ae76e --- /dev/null +++ b/docs/superpowers/plans/2026-10-31-mobile-contract-print.md @@ -0,0 +1,84 @@ +# 小程序合同打印 Implementation Plan + +> 使用 subagent-driven-development 实施,并分别检查需求符合度和代码质量。 + +**Goal:** 从 Web 复制合同打印功能,接入小程序 H5,保持 Web 调用链不变。 + +**Architecture:** 移动端独立组件和编辑器副本;现有模板 API;新增移动文件交付适配和原生文档页。 + +**Tech Stack:** Vue 3、TinyMCE、html2canvas、jsPDF、HTML→DOCX、uni-app。 + +## 任务 + +- [x] 1. 先添加文件交付、入口隔离和组件生命周期的回归测试,执行并确认缺失功能失败。 +- [x] 2. 在 `web-admin/src/views/m/crm/contract/print/` 复制打印模板、保存和导出代码,复制 TinyMCE 到 `MobileContractEditor.vue`,保留源文件不动。公开 `open(id)`,载入失败显示错误,重开重置状态,关闭防止旧请求回写。 +- [x] 3. 接入 `web-admin/src/views/m/crm/contract/detail.vue`,沿用 `captureOperation` 和操作列表,懒加载打印组件,切换合同销毁旧实例。 +- [x] 4. 实现 `deliverContractFile(blob, fileName)`:浏览器下载;微信中调用 `fetchUploadAttachment`,取得下载 URL,通过 `wx.miniProgram.navigateTo` 进入新文档页。失败抛错交由组件提示,不修改合同附件集合。 +- [x] 5. 新建 `miniprogram/pages/common/contract-document.vue` 和下载辅助函数;注册页面。校验资源地址,下载成功后 `uni.openDocument({ filePath, fileType, showMenu: true })`,支持重试和返回;不发送 Bearer 到资源域名。 +- [x] 6. 执行定向测试、Vue 编译/构建、移动浏览器验证。检查 `git diff` 保证 Web 原打印和编辑器无变更,记录真机限制和结果。 + +UI 参考:现有移动合同详情全屏面板,以及 Web P10 查看弹窗中的合同打印编辑区。功能与隔离方案已在聊天中获用户确认,不重复请求确认。 + +## 执行与验证记录 + +- 代码在当前 V3.0 工作区内完成,保留用户既有未提交文件;未提交或发布。 +- `pnpm exec tsx --test scripts/mobile-contract-seal-drag.test.ts scripts/mobile-contract-detail-review.test.ts scripts/mobile-contract-review.test.ts scripts/mobile-contract-picker.test.ts scripts/mobile-crm-detail-navigation.test.ts src/utils/crm/contract-pdf-pages.test.ts src/utils/crm/contract-table-layout.test.ts src/utils/crm/contract-product-table-widths.test.ts src/utils/crm/contract-docx-column-widths.test.ts src/utils/crm/contract-html-to-docx.test.ts src/utils/tinymce-table-edge-resize.test.ts`:80 项通过。 +- `node --test miniprogram/scripts/test-contract-document.mjs`:5 项通过,包括首页注册顺序保持不变。 +- Web 目录 `node scripts/test-mobile-contract-print.mjs`:Chrome 模拟触摸,375/414 全屏、模板编辑保存/关闭重开、默认模板、红章切换、真实 DOCX/PDF 下载、模拟微信上传失败重试和原生页跳转均通过。开发页面和 `vite preview` 构建产物均通过;补测非 SX 合同快速关闭。全部 API 为 fixture,不写真实业务数据。 +- `pnpm build`:通过;保留已有组件重名及大 chunk 警告。 +- `vue-tsc --noEmit`:全仓历史错误未消除;与改动前基线比较无新增诊断,移动打印新增文件无诊断。不能称全仓类型检查通过。 +- Web 原 `ContractDetailDialog.vue`、`TinyMceEditor/index.vue` 和共享导出工具无修改;`git diff --check` 通过。 + +## 发布与真机验收 + +新增了小程序原生页面,需重新编译/上传小程序,单独更新 H5 不足以启用文档打开。保持现有环境配置;正式环境需确认当前 MinIO 主机(配置为 `res.shanxiachem.com`)已加入小程序 downloadFile 合法域名。尚未执行 HBuilderX 编译、iOS/Android 微信真机验收或生产部署。真机重点验收键盘/触摸选区、盖章移动、长合同导出及文件转发;超过移动截图16MP上限会给出使用电脑端导出的提示并保留内容。 + +## 真机空白反馈后的初始化修复 + +用户反馈 iOS 27.2 正式微信环境编辑区空白、导出报 `serializer.serialize` 未定义。正式域名核心/主题/模型/图标/样式/语言/表格资源均返回 200,字节与本地 TinyMCE 6.8.6 一致。能确认初始化前读取内容缺少保护;尚未直接调试该设备,不能断言真实设备空白的唯一根因。 + +移动专用编辑器启用 TinyMCE 官方 `init_content_sync`,绕过 srcdoc 异步 load 依赖;仅 init 完成且 serializer 可用后允许读取、保存或导出。增加加载状态、资源失败重试和20秒超时提示;核心脚本由副本管理加载错误,普通模型/主题失败重试仅清理未注册项。持续挂起的共享加载队列无法通过重建组件释放,超时采用明确的「重新加载页面」,使用详情已有 `action=print` 恢复当前合同。原 Web 组件和公共 TinyMCE 静态资源不变。 + +新增回归模式(均使用 fixture,不写真实业务数据): + +- `CONTRACT_TEST_IOS=1 CONTRACT_TEST_WECHAT_UA=1 CONTRACT_TEST_BLOCK_SRCDOC=1 node scripts/test-mobile-contract-print.mjs`:屏蔽 iframe srcdoc 初始化,修复前初始化超时,修复后通过编辑/保存/Word/PDF 全流程。 +- `CONTRACT_TEST_SLOW_EDITOR=1 node scripts/test-mobile-contract-print.mjs`:模型未完成加载时保存/导出禁用、关闭可用;释放后可操作。修复前断言失败,修复后通过。 +- `CONTRACT_TEST_IOS=1 CONTRACT_TEST_RETRY_EDITOR=1 node scripts/test-mobile-contract-print.mjs`:模型首次503,重试后通过。 +- `CONTRACT_TEST_IOS=1 CONTRACT_TEST_RETRY_EDITOR=core node scripts/test-mobile-contract-print.mjs`:核心脚本首次503,重试后通过。 +- `CONTRACT_TEST_IOS=1 CONTRACT_TEST_SLOW_EDITOR=timeout node scripts/test-mobile-contract-print.mjs`:首次模型持续挂起,超时刷新恢复合同并通过全流程。局部重试版本失败,页面恢复版本通过。 +- 定向原有回归51项通过;类型检查与原基线相比无新增诊断。 + +本机 WebKit 18.5/iPhone微信UA模拟不等价于用户 iOS 27.2 微信真机。未发布正式站点;H5更新后仍需用户原机复测。本次无需重新打包小程序原生页面。 + +## 真机提示成功但无文件去向的交付修复 + +用户确认点击 PDF 后始终停留编辑窗口,没有原生文档页或预览。此前所给165字节文件为其他档案接口的历史500 JSON,不能用作合同PDF样本。 + +确认移动交付器存在错误分支:同步环境标记/UA判定为false时立即调用FileSaver并返回,调用方无条件提示生成成功。微信桥接尚未就绪、UA无miniProgram时可以进入此分支。回归用iPhone微信UA、无同步标记、延迟getEnv=true复现;真实设备尚未连调,不能断言所有真机表现均由同一原因造成。 + +仅移动交付器增加JSSDK加载/桥接环境确认,微信环境不能确认时明确报错,不降级浏览器下载;环境确认8秒超时、原生页面跳转10秒超时。返回交付类型,界面分别提示文档页用途或浏览器下载记录,避免将浏览器下载请求等同于文件已保存。Word/PDF共用此逻辑。Web原打印、共享环境工具和小程序原生页面均未修改。 + +验证: +- 新增交付专项9项通过,覆盖延迟环境、SDK加载/超时、关闭窗口、普通浏览器、非小程序微信环境和原生跳转失败/无响应;修复前核心用例失败。 +- 原移动合同与印章回归26项、小程序文档页5项通过。 +- Chrome触摸及WebKit/iPhone微信UA的完整编辑/保存/Word/PDF/上传失败重试/原生跳转fixture回归通过;原生宿主由mock模拟,不等价于真机。 +- `pnpm build`通过,保留已有大chunk警告。 + +本次未部署。更新H5即可交付本轮修复;前提是已发布小程序包包含之前新增的`pages/common/contract-document`页。若该页尚未发布,仍需更新小程序包;失败提示会指明文档页打开失败。 +- 本轮`vue-tsc --noEmit`仍因仓库历史诊断退出2;与上一轮基线去除行列号后比较无新增诊断。不能称全仓类型检查通过。 + +代码复核未发现新的阻塞问题;补充按官方JSSDK顺序在bridge ready时注入环境标记的测试通过。`getEnv`本身仍读取该标记,因此永久缺失标记应明确报错,不能宣称已覆盖所有真机宿主异常。 + +## 文档页未出现:原生包同步确认 + +用户后续截图显示“已进入文档页”但仍停留H5,并确认只更新了H5。检查此前本机14:08的`unpackage/dist/dev/mp-weixin/app.json`确实没有`pages/common/contract-document`。页面只注册在源码,须随小程序原生包编译/上传/切换版本;H5更新不能增加原生页面。旧回调只代表跳转请求被接受,不应断言页面已呈现,移动提示已改为“已请求打开文档;若未跳转,请更新小程序后重试”,返回值改为`mini-program-requested`。 + +用户明确要求切换生产配置、由用户同步发布。已将原工程`miniprogram/config/env.js`的`APP_ENV`改为`production`;`npm run release:check-api`通过。HBuilderX `launch mp-weixin --compile true`编译成功,检查产物的环境为production且新文档页的js/json/wxml/wxss齐全,下载和打开接口已编译进入页面。交付9项、小程序文档5项测试通过。 + +`publish mp-weixin --upload false`被HBuilderX登录要求阻止,未产生发行包;以上成功编译为运行模式验证,不是正式发行包。用户需要登录HBuilderX后用“发行 → 小程序-微信”构建压缩包并上传。本轮未上传或发布小程序,也未部署H5;仍待真实设备验收。 + +## 原生文档页下载失败的排查 + +用户更新小程序后已进入合同文档页,出现下载fail通用提示。确认现有fail回调丢弃了微信errMsg,截图无法区分域名白名单、TLS、超时等原因。下载存储域名为`res.shanxiachem.com`;本机对该域名HTTPS检查成功,但这不能证明真机下载合法域名已配置或具体签名链接有效。已请求用户核对微信公众平台downloadFile合法域名配置,尚未拿到实际errMsg,不宣称根因已证实。 + +最小修正:原生文档页保留下载错误和错误码,识别域名拦截时明确指出待配置域名;隐藏错误中的文件URL避免展示临时签名。补充README中的MinIO下载域名要求。新增两项错误呈现回归先失败后通过,总计7项通过;HBuilderX运行模式编译及production配置检查通过。未上传或发布,若域名已配置仍报错,需要用更新后的原生包取得实际错误继续排查。 diff --git a/docs/superpowers/specs/2026-10-31-mobile-contract-print-design.md b/docs/superpowers/specs/2026-10-31-mobile-contract-print-design.md new file mode 100644 index 000000000..c3a766419 --- /dev/null +++ b/docs/superpowers/specs/2026-10-31-mobile-contract-print-design.md @@ -0,0 +1,17 @@ +# 小程序合同打印独立副本 + +用户已确认:复制 Web 合同打印界面及逻辑,在小程序 H5 中适配,不修改 Web 原组件、编辑器或导出工具。保留现有模板、富文本编辑、表格操作、预览、保存模板、红章切换、Word/PDF 导出。手机打印通过导出 PDF 后打开文档处理,不承诺浏览器打印能力。 + +## 边界 + +- 源参考:`web-admin/src/views/crm/contract/components/ContractDetailDialog.vue`,仅复制打印相关部分,避免引入详情、审批、删除等无关功能。 +- 移动副本放在 `web-admin/src/views/m/crm/contract/print/`;需调整的 TinyMCE 和工具代码也复制到该目录。 +- 入口接入移动合同详情操作区,遵守当前详情加载、记录身份及操作权限保护。 +- 采用现有移动全屏面板模式,合同正文保持原版式,工具栏允许换行,操作按钮遵循小程序规范 §12.3 的 30px 紧凑例外,保留按钮间距。样式只作用于移动副本。 +- 复用既有读取合同、读取/保存模板 API。保存只调用模板接口,不提交整单子数据。 +- 生成文件复用原算法,在微信中经现有附件 API 上传 MinIO,再通过原生文档页下载并打开;普通浏览器仍下载。新增文档页不使用延迟触发的 postMessage 传输大文件。 +- 身份鉴别沿用小程序票据登录;下载不向外部资源附加用户 token。失败保留编辑内容并允许重试。 + +## 验收 + +Web 原有打印相关源文件无修改;移动入口可打开/关闭;模板载入、编辑、保存、红章、Word/PDF 流程可运行;关闭或切换合同不串数据;导出失败不清空内容;文件页拒绝非法链接。执行定向回归与构建,真机未执行须明确标记。 diff --git a/miniprogram/README.md b/miniprogram/README.md index c5523aaed..267368e6b 100644 --- a/miniprogram/README.md +++ b/miniprogram/README.md @@ -76,6 +76,6 @@ npm run release:check-api # 发版前:要求 APP_ENV=production | MinIO 附件 | https://res.shanxiachem.com/ruoyi | | 微信 AppID | `wx7db1ddb2a90c9425` | -微信公众平台合法域名请配置:`w.shanxiachem.com`(request / uploadFile / downloadFile)。 +微信公众平台服务器域名:`https://w.shanxiachem.com` 用于 request / uploadFile;合同文档还必须将 `https://res.shanxiachem.com` 加入 **downloadFile 合法域名**。只填写域名,不带 `/ruoyi` 路径;H5 的业务域名配置不替代 downloadFile 配置。 维护入口:[小程序工程规则](AGENTS.md)、[小程序 UI 规范](../docs/miniprogram-ui-spec-sanqiang.md);地磅页面与公共组件见上文「已接通业务:地磅管理」。 diff --git a/miniprogram/pages.json b/miniprogram/pages.json index 7deed5cbc..bedbdaaa6 100644 --- a/miniprogram/pages.json +++ b/miniprogram/pages.json @@ -54,6 +54,7 @@ "navigationStyle": "custom" } }, + { "path": "pages/common/contract-document", "style": { "navigationBarTitleText": "合同文档" } }, { "path": "pages/common/biz-placeholder", "style": { diff --git a/miniprogram/pages/common/contract-document.vue b/miniprogram/pages/common/contract-document.vue new file mode 100644 index 000000000..8a6dd7c52 --- /dev/null +++ b/miniprogram/pages/common/contract-document.vue @@ -0,0 +1,75 @@ + + + diff --git a/miniprogram/scripts/test-contract-document.mjs b/miniprogram/scripts/test-contract-document.mjs new file mode 100644 index 000000000..67b573974 --- /dev/null +++ b/miniprogram/scripts/test-contract-document.mjs @@ -0,0 +1,98 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +const file = new URL('../utils/contract-document.js', import.meta.url) +test('document links only accept configured storage, PDF/DOCX and safe boundaries', async () => { + assert.ok(existsSync(file), 'document validator exists') + const { validateContractDocument } = await import(file) + const base = 'https://res.example.com/ruoyi' + assert.equal(validateContractDocument({url: base+'/a.pdf?signature=x', type:'pdf'}, base).type, 'pdf') + for (const url of ['https://evil.com/a.pdf', base+'evil/a.pdf', 'https://res.example.com/ruoyi/../secret', 'javascript:alert(1)', 'https://res.example.com@evil.com/ruoyi/a.pdf']) { + assert.throws(() => validateContractDocument({url,type:'pdf'},base)) + } + assert.throws(() => validateContractDocument({url:base+'/a.exe',type:'exe'},base)) +}) + +import vm from 'node:vm' +import { readFileSync } from 'node:fs' +async function pageHarness() { + const { validateContractDocument, contractDownloadErrorMessage } = await import(file) + const source = readFileSync(new URL('../pages/common/contract-document.vue', import.meta.url),'utf8') + .split('')[0].replace(/^import .*$/gm,'') + const callbacks = {} + const calls = {downloads: [], opens: [], removed: [], aborted:0} + const context = vm.createContext({ + ref: value => ({value}), + onLoad: fn => callbacks.load = fn, + onUnload: fn => callbacks.unload = fn, + MINIO_BASE_URL: 'https://res.example.com/ruoyi', + validateContractDocument, + contractDownloadErrorMessage, + uni: { + downloadFile: options => {calls.downloads.push(options); return {abort() {calls.aborted++}}}, + openDocument: options => calls.opens.push(options), + getFileSystemManager: () => ({unlink: options => calls.removed.push(options.filePath)}), + navigateBack() {} + } + }) + vm.runInContext(source+'\nglobalThis.state = { busy, message, fileName, documentInfo, openDocument };',context) + return {callbacks,calls,state: context.state} +} +const tick = () => new Promise(resolve=>setImmediate(resolve)) +test('download, preview/share, retry and cleanup use local files without credentials', async () => { + const h = await pageHarness() + h.callbacks.load({url:'https://res.example.com/ruoyi/a.pdf',type:'pdf',name:'100%合同.pdf'}) + assert.equal(h.state.fileName.value,'100%合同.pdf') + assert.equal(h.calls.downloads[0].header,undefined) + h.calls.downloads[0].fail() + await tick() + assert.equal(h.state.busy.value,false) + void h.state.openDocument() + h.calls.downloads[1].success({statusCode:200,tempFilePath:'/tmp/contract.pdf'}) + await tick() + assert.equal(h.calls.opens[0].filePath,'/tmp/contract.pdf') + assert.equal(h.calls.opens[0].showMenu,true) + h.calls.opens[0].success() + await tick() + h.callbacks.unload() + assert.deepEqual(h.calls.removed,['/tmp/contract.pdf']) +}) +test('unload during download prevents opening and removes late temp files', async () => { + const h=await pageHarness() + h.callbacks.load({url:'https://res.example.com/ruoyi/a.pdf',type:'pdf'}) + h.callbacks.unload() + h.calls.downloads[0].success({statusCode:200,tempFilePath:'/tmp/late.pdf'}) + await tick() + assert.equal(h.calls.opens.length,0) + assert.deepEqual(h.calls.removed,['/tmp/late.pdf']) +}) +test('invalid links never start download', async () => { + const h=await pageHarness() + h.callbacks.load({url:'https://evil.com/a.pdf',type:'pdf'}) + assert.equal(h.calls.downloads.length,0) + assert.equal(h.state.documentInfo.value,null) +}) +test('registering contract documents preserves the login launch page', () => { + const pages = JSON.parse(readFileSync(new URL('../pages.json',import.meta.url),'utf8').replace(/^\s*\/\/.*$/gm, '')).pages + assert.equal(pages[0].path,'pages/login/login') + assert.equal(pages.filter(p => p.path === 'pages/common/contract-document').length,1) +}) + +test('native domain rejection identifies the required downloadFile domain', async () => { + const h = await pageHarness() + h.callbacks.load({url:'https://res.example.com/ruoyi/a.pdf',type:'pdf'}) + h.calls.downloads[0].fail({errMsg:'downloadFile:fail url not in domain list',errno:600002}) + await tick() + assert.match(h.state.message.value, /downloadFile 合法域名/) + assert.match(h.state.message.value, /https:\/\/res\.example\.com/) + assert.equal(h.calls.opens.length, 0) +}) +test('native download failures retain diagnostic details without signed URLs', async () => { + const h = await pageHarness() + h.callbacks.load({url:'https://res.example.com/ruoyi/a.pdf',type:'pdf'}) + h.calls.downloads[0].fail({errMsg:'downloadFile:fail connection reset https://res.example.com/a.pdf?X-Amz-Signature=secret',errno:600001}) + await tick() + assert.match(h.state.message.value, /connection reset/) + assert.match(h.state.message.value, /600001/) + assert.doesNotMatch(h.state.message.value, /secret|X-Amz/) +}) diff --git a/miniprogram/utils/contract-document.js b/miniprogram/utils/contract-document.js new file mode 100644 index 000000000..416a9ef3d --- /dev/null +++ b/miniprogram/utils/contract-document.js @@ -0,0 +1,31 @@ +/** 不依赖小程序中可能不存在的 URL 构造器,不接受任意下载主机。 */ +export function validateContractDocument(query, storageBase) { + const url = String(query?.url || '') + const type = String(query?.type || '').toLowerCase() + const base = String(storageBase || '').replace(/\/$/, '') + if (!['pdf', 'docx'].includes(type)) throw new Error('不支持的文档格式') + if (!/^https?:\/\/[^/@?#]+(?:\/[^?#]*)?$/.test(base) || !url.startsWith(base + '/')) { + throw new Error('无效的文档地址') + } + const path = url.split(/[?#]/)[0] + let decoded + try { decoded = decodeURIComponent(path) } catch { throw new Error('无效的文档地址') } + if (/[\\\s\x00-\x1f]/.test(url) || decoded.split('/').some((part) => part === '..' || part === '.')) { + throw new Error('无效的文档地址') + } + return { url, type, name: String(query?.name || `合同.${type}`) } +} + +/** 保留微信下载错误用于排查,但不展示含临时签名的文件地址。 */ +export function contractDownloadErrorMessage(error, url) { + const raw = String(error?.errMsg || '') + const origin = String(url || '').match(/^https?:\/\/[^/?#]+/)?.[0] || '文件存储域名' + if (/url not in domain list|domain.*not.*allowed|域名.*不.*合法/i.test(raw)) { + return `下载被微信拦截:请管理员将 ${origin} 加入小程序的 downloadFile 合法域名,更新配置后重新进入小程序。` + } + const detail = raw.replace(/https?:\/\/[^\s]+/gi, '[下载地址]') + .replace(/[\r\n\t]/g, ' ').replace(/^downloadFile:fail\s*/i, '').trim().slice(0, 180) + const code = error?.errno ?? error?.errCode + const codeText = Number.isFinite(Number(code)) && code != null ? `(错误码 ${Number(code)})` : '' + return detail ? `下载失败:${detail}${codeText}` : `下载失败${codeText},请检查网络或稍后重试` +} diff --git a/web-admin/scripts/mobile-contract-delivery.test.ts b/web-admin/scripts/mobile-contract-delivery.test.ts new file mode 100644 index 000000000..ab24a7876 --- /dev/null +++ b/web-admin/scripts/mobile-contract-delivery.test.ts @@ -0,0 +1,151 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' +import ts from 'typescript' + +// 执行真实交付函数,仅替换浏览器宿主与 HTTP 边界。 +const source = readFileSync( + new URL('../src/views/m/crm/contract/print/deliverContractFile.ts', import.meta.url), + 'utf8' +) +const code = ts.transpileModule( + source.replace(/^import .*$/gm, '').replace('export async function', 'async function'), + { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } } +).outputText +function harness({ + wechat = true, + marked = false, + env = true, + navigation = 'success', + delayedSdk = false, + pendingBridge = false +} = {}) { + const calls = { saved: 0, uploaded: 0, navigated: [] as string[] } + const events = new EventTarget() + const script = new EventTarget() + const mini = { + getEnv(cb: (value: { miniprogram: boolean }) => void) { + if (pendingBridge) { + // JSSDK 1.6.0:桥接就绪后才读取宿主注入的环境标记。 + events.addEventListener('WeixinJSBridgeReady', () => cb({ miniprogram: marked }), { + once: true + }) + return + } + setTimeout(() => cb({ miniprogram: env }), 1) + }, + navigateTo(options: any) { + calls.navigated.push(options.url) + if (navigation === 'success') options.success() + if (navigation === 'fail') options.fail({ errMsg: 'page not found' }) + } + } + const window: any = { wx: delayedSdk ? undefined : { miniProgram: mini } } + const context = vm.createContext({ + window, + navigator: { userAgent: wechat ? 'iPhone MicroMessenger/8.0' : 'Safari' }, + document: { + querySelector: () => (delayedSdk ? script : null), + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events) + }, + setTimeout: (fn: () => void, ms: number) => setTimeout(fn, Math.min(ms, 30)), + clearTimeout, + File, + encodeURIComponent, + FileSaver: { + saveAs() { + calls.saved++ + } + }, + isInMiniProgramWebView: () => marked, + CRM_UPLOAD_BIZ_TYPE: { CONTRACT_ATTACHMENT: 'contract' }, + buildAttachmentUploadFormData: (file: File) => file, + fetchUploadAttachment: async () => { + calls.uploaded++ + return { data: { fileUrl: 'contracts/a.pdf' } } + }, + fetchAttachmentDownloadUrl: async () => ({ + data: { downloadUrl: 'https://res.example.com/a.pdf?signature=x' } + }) + }) + vm.runInContext(code + '\nglobalThis.deliver = deliverContractFile', context) + return { + calls, + deliver: (isActive?: () => boolean) => + context.deliver(new Blob(['%PDF-1.3']), '测试合同.pdf', isActive), + ready() { + window.wx = { miniProgram: mini } + script.dispatchEvent(new Event('load')) + }, + bridgeReady() { + marked = true + events.dispatchEvent(new Event('WeixinJSBridgeReady')) + } + } +} +test('iPhone WeChat without synchronous markers waits for getEnv and opens native document page', async () => { + const h = harness() + assert.equal(await h.deliver(), 'mini-program-requested') + assert.equal(h.calls.saved, 0) + assert.equal(h.calls.uploaded, 1) + assert.match(h.calls.navigated[0], /^\/pages\/common\/contract-document\?url=/) +}) +test('ordinary browser still requests a browser download despite a JSSDK shell', async () => { + const h = harness({ wechat: false }) + assert.equal(await h.deliver(), 'browser-download') + assert.equal(h.calls.saved, 1) + assert.equal(h.calls.uploaded, 0) +}) +test('waits for the real JSSDK ordering: bridge ready then injected environment marker', async () => { + const h = harness({ pendingBridge: true }) + const result = h.deliver() + assert.equal(h.calls.saved, 0) + assert.equal(h.calls.uploaded, 0) + h.bridgeReady() + assert.equal(await result, 'mini-program-requested') + assert.equal(h.calls.uploaded, 1) + assert.equal(h.calls.navigated.length, 1) +}) +test('waits for an already loading JSSDK before delivering', async () => { + const h = harness({ delayedSdk: true }) + const result = h.deliver() + h.ready() + assert.equal(await result, 'mini-program-requested') + assert.equal(h.calls.saved, 0) +}) +test('unconfirmed WeChat environment does not silently fall back to browser downloads', async () => { + const h = harness({ env: false }) + await assert.rejects(h.deliver(), /小程序/) + assert.equal(h.calls.saved, 0) + assert.equal(h.calls.uploaded, 0) +}) +test('missing JSSDK times out without downloading or uploading', async () => { + const h = harness({ delayedSdk: true }) + await assert.rejects(h.deliver(), /微信文档接口未就绪/) + h.ready() + assert.equal(h.calls.saved, 0) + assert.equal(h.calls.uploaded, 0) +}) +test('closing while environment is pending prevents file upload and navigation', async () => { + const h = harness() + let active = true + const result = h.deliver(() => active) + active = false + assert.equal(await result, undefined) + assert.equal(h.calls.saved, 0) + assert.equal(h.calls.uploaded, 0) + assert.equal(h.calls.navigated.length, 0) +}) +for (const navigation of ['fail', 'silent']) { + test( + `native navigation ${navigation} rejects instead of reporting success`, + { timeout: 200 }, + async () => { + const h = harness({ marked: true, navigation }) + await assert.rejects(h.deliver(), /文档/) + assert.equal(h.calls.saved, 0) + } + ) +} diff --git a/web-admin/scripts/mobile-contract-seal-drag.test.ts b/web-admin/scripts/mobile-contract-seal-drag.test.ts new file mode 100644 index 000000000..3bf6be2fd --- /dev/null +++ b/web-admin/scripts/mobile-contract-seal-drag.test.ts @@ -0,0 +1,56 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +const moduleUrl = new URL('../src/views/m/crm/contract/print/sealPointerDrag.ts', import.meta.url) +test('touch pointer drags a seal and remove detaches listeners', async () => { + assert.ok(existsSync(moduleUrl), 'pointer seal adapter exists') + const { setupSealPointerDrag } = await import(moduleUrl.href) + const handlers = new Map() + const removed = [] + const doc = { + addEventListener: (key, fn) => handlers.set(key, fn), + removeEventListener: (key) => { + removed.push(key) + handlers.delete(key) + } + } + const body = { ownerDocument: doc, contains: () => true } + let changes = 0 + let cleanup + const editor = { + getBody: () => body, + mode: { get: () => 'design' }, + fire: () => changes++, + on: (name, fn) => (cleanup = fn) + } + setupSealPointerDrag(editor) + const img = { + tagName: 'IMG', + hasAttribute: () => true, + style: { left: '30px', top: '40px' }, + offsetLeft: 30, + offsetTop: 40, + setPointerCapture() {}, + releasePointerCapture() {} + } + const event = (x, y, id = 1) => ({ + target: img, + clientX: x, + clientY: y, + pointerId: id, + pointerType: 'touch', + button: 0, + preventDefault() {} + }) + handlers.get('pointerdown')(event(50, 60)) + handlers.get('pointermove')(event(80, 100, 2)) + assert.equal(img.style.left, '30px') + handlers.get('pointermove')(event(80, 100)) + assert.equal(img.style.left, '60px') + assert.equal(img.style.top, '80px') + handlers.get('pointerup')(event(80, 100)) + assert.equal(changes, 1) + cleanup() + assert.equal(handlers.size, 0) + assert.equal(removed.length, 4) +}) diff --git a/web-admin/scripts/test-mobile-contract-print.mjs b/web-admin/scripts/test-mobile-contract-print.mjs new file mode 100644 index 000000000..27114421d --- /dev/null +++ b/web-admin/scripts/test-mobile-contract-print.mjs @@ -0,0 +1,536 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile } from 'node:fs/promises' +import { chromium, webkit, devices } from 'playwright' + +// 本地 H5 交互回归:所有接口均由 fixture 拦截,不写真实业务库。 +const origin = process.env.CONTRACT_TEST_ORIGIN || 'http://localhost:3006' +const artifacts = '/tmp/shanxia-mobile-contract-print' +await mkdir(artifacts, { recursive: true }) + +const ios = process.env.CONTRACT_TEST_IOS === '1' +const browser = ios + ? await webkit.launch({ headless: true }) + : await chromium.launch({ headless: true, channel: 'chrome' }) +const context = await browser.newContext({ + ...(ios ? devices['iPhone 13'] : {}), + ...(process.env.CONTRACT_TEST_IPHONE_UA ? { userAgent: devices['iPhone 13'].userAgent } : {}), + ...(process.env.CONTRACT_TEST_WECHAT_UA + ? { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 27_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.65 NetType/WIFI Language/zh_CN' + } + : {}), + viewport: { width: 375, height: 812 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 1 +}) +if (process.env.CONTRACT_TEST_BLOCK_SRCDOC === '1') { + await context.addInitScript(() => { + const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'srcdoc') + Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', { + ...descriptor, + set(value) { + if (this.id.startsWith('tinymce-')) return // 模拟WebView未完成srcdoc加载 + descriptor.set.call(this, value) + } + }) + }) +} +await context.addInitScript(() => { + const start = () => { + const root = document.documentElement + if (!root) return + const hide = () => { + const el = document.getElementById('__vue-devtools-container__') + if (el) el.style.setProperty('display', 'none', 'important') + } + hide() + new MutationObserver(hide).observe(root, { childList: true, subtree: true }) + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start) + else start() +}) +let releaseEditorModel +let modelRequested +let slowEditor = ['1', 'timeout'].includes(process.env.CONTRACT_TEST_SLOW_EDITOR) +const modelStarted = new Promise((resolve) => { + modelRequested = resolve +}) +if (slowEditor) { + let heldModel = false + await context.route('**/tinymce/models/dom/model.min.js', async (route) => { + if (heldModel) { + await route.continue() + return + } + heldModel = true + modelRequested() + await new Promise((resolve) => { + releaseEditorModel = resolve + }) + await route.continue() + }) +} +let retryEditor = ['1', 'core'].includes(process.env.CONTRACT_TEST_RETRY_EDITOR) +if (retryEditor) { + let firstModel = true + await context.route( + process.env.CONTRACT_TEST_RETRY_EDITOR === 'core' + ? '**/tinymce/tinymce.min.js' + : '**/tinymce/models/dom/model.min.js', + async (route) => { + if (firstModel) { + firstModel = false + await route.fulfill({ + status: 503, + contentType: 'text/plain', + body: 'fixture transient failure' + }) + } else await route.continue() + } + ) +} +const page = await context.newPage() +page.setDefaultTimeout(15000) +const pageErrors = [] +const calls = [] +page.on('pageerror', (error) => { + pageErrors.push(error.message) + console.error('Browser error:', error.stack) +}) +page.on('console', (message) => { + if (message.type() === 'error') console.error('Console:', message.text()) +}) + +const baseContract = { + id: 'contract-1', + contractNo: 'HT-20261017-001', + contractName: '年度化工原料销售合同', + customerId: 'customer-1', + customerName: '宜昌测试客户', + contactName: '张经理', + contactMobile: '13800138000', + contractType: 'sale', + amount: 128600, + freight: 500, + barrelDeposit: 1000, + amountWithTax: 130100, + signDate: '2026-10-17', + startDate: '2026-10-18', + endDate: '2027-10-17', + ownerUserId: 'user-1', + ownerUserName: '测试销售', + status: 'draft', + canApprove: true, + partyA: '宜昌测试客户', + partyAContact: '张经理', + partyAMobile: '13800138000', + partyB: '山峡化工', + partyBContact: '李经理', + partyBMobile: '13900139000', + paymentTerms: '月结 30 天', + deliveryTerms: '送货上门', + contractTerms: '按约定履行', + attachmentFiles: JSON.stringify([{ ossId: '/fixtures/合同扫描件.pdf', category: 'scan' }]), + remark: 'fixture 合同', + createBy: 'user-1', + createTime: '2026-10-17 09:00:00', + products: [ + { + id: 'line-1', + standardProductName: '工业甲醇', + productName: '工业甲醇', + price: 12000, + subtotal: 120000, + taxAmount: 13805.31, + taxRate: 13, + quantity: 10, + unit: '吨', + unitPrice: 12000, + amount: 120000 + } + ] +} + +const contracts = [ + baseContract, + { + ...baseContract, + id: 'contract-2', + contractNo: 'HT-20261017-002', + contractName: '已审批合同', + status: 'approved', + canApprove: false + }, + { + ...baseContract, + id: 'contract-fail', + contractNo: 'HT-20261017-003', + contractName: '审批失败样本', + status: 'approving', + canApprove: true + } +] + +let approvals = [ + { + id: 'approval-1', + contractId: baseContract.id, + step: 1, + approverId: 'user-before', + approverName: '前置审批人', + approvalResult: 'pending', + approvalOpinion: '', + createTime: '2026-10-17 10:00:00' + }, + { + id: 'approval-2', + contractId: baseContract.id, + step: 2, + approverId: 'user-1', + approverName: '测试审批人', + approvalResult: 'pending', + approvalOpinion: '', + createTime: '2026-10-17 10:30:00' + } +] +let savedTemplate = + '

移动打印验证 {{contractNo}}

需方:{{partyB}}

可编辑的合同条款

' +let failUpload = false +let approvalEntry = false +let forbiddenMode = '' + +const dict = { + crm_contract_status: [ + { value: 'draft', label: '草稿', tagType: 'info' }, + { value: 'approving', label: '待审批', tagType: 'warning' }, + { value: 'approved', label: '已审批', tagType: 'success' }, + { value: 'invalid', label: '已作废', tagType: 'danger' } + ], + crm_contract_type: [ + { value: 'sale', label: '销售合同', tagType: 'primary' }, + { value: 'purchase', label: '采购合同', tagType: 'info' } + ] +} + +await context.addInitScript(() => { + window.wx = { + miniProgram: { + navigateBack() {}, + navigateTo(options) { + window.__documentNavigation = options.url + options.success?.({}) + }, + reLaunch() {}, + getEnv(callback) { + callback({ miniprogram: true }) + } + } + } +}) + +await context.route('**/api/**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (!url.pathname.startsWith('/api/')) return route.continue() + const path = url.pathname.replace(/^\/api/, '') + let body = Object.fromEntries(url.searchParams) + try { + body = { ...body, ...(request.postDataJSON() || {}) } + } catch { + // multipart 上传不参与 body 精确断言。 + } + calls.push({ path, body }) + + let data = {} + let code = 200 + let msg = 'ok' + if (path === '/web/auth/mp-webview-exchange') { + const ticket = String(body.ticket || '') + approvalEntry = ticket.includes('approve') + forbiddenMode = ticket.includes('forbidden-list') + ? 'list' + : ticket.includes('forbidden-detail') + ? 'detail' + : '' + data = { + token: 'fixture-token', + refreshToken: 'fixture-refresh', + userInfo: { + id: 'user-1', + userId: 'user-1', + userName: '测试用户', + role: 'admin', + roles: ['admin'] + } + } + } else if (path === '/web/crm/contract/getTemplate/contract-1') { + data = savedTemplate + } else if (path === '/web/crm/contract/saveTemplate') { + savedTemplate = body.templateContent + data = true + } else if (path === '/web/admin/attachment/download-url') { + data = { + downloadUrl: 'https://res.shanxiachem.com/ruoyi/contract-export/fixture.pdf?signature=fixture' + } + } else if (path === '/web/admin/dict-data/all') { + data = dict + } else if (path === '/web/admin/users/options') { + data = [{ value: 'user-1', label: '测试审批人', userNo: 'approver' }] + } else if (path === '/web/crm/contract/list') { + if (forbiddenMode === 'list') { + code = 403 + msg = '无权限访问合同列表' + } else { + const list = body.approvedByUserId + ? contracts.filter((item) => item.status === 'approved') + : approvalEntry + ? contracts.map((item) => ({ + ...item, + status: item.id === 'contract-2' ? 'approved' : 'approving' + })) + : contracts + data = { list, total: list.length } + } + } else if (/^\/web\/crm\/contract\/contract-(?:1|2|fail)$/.test(path)) { + if (forbiddenMode === 'detail') { + code = 403 + msg = '无权限访问合同详情' + } else { + const id = decodeURIComponent(path.split('/').pop()) + const row = contracts.find((item) => item.id === id) || baseContract + data = { + ...row, + status: approvalEntry + ? approvals.some((item) => item.approvalResult === 'pending') + ? 'approving' + : 'approved' + : row.status + } + } + } else if (/^\/web\/crm\/contract\/approval\/flow\//.test(path)) { + data = { list: approvals } + } else if (path === '/web/crm/contract-approver-setting/get') { + data = { approverIds: ['user-1'], status: '0' } + } else if (path === '/web/crm/contract/batchSubmitApproval') { + data = body.ids?.length || 0 + } else if (path === '/web/crm/contract/approval') { + if (body.id === 'contract-fail') { + await route.abort('failed') + return + } else { + approvals = approvals.map((item) => ({ + ...item, + approvalResult: body.approvalResult, + approvalOpinion: body.approvalOpinion, + approvalTime: '2026-10-17 11:00:00' + })) + data = true + } + } else if (path === '/web/admin/attachment/upload') { + if (failUpload) { + code = 500 + msg = '模拟上传失败' + } + data = { id: 'upload-1', fileName: '补充扫描件.png', fileUrl: '/fixtures/补充扫描件.png' } + } else if (path === '/web/crm/contract/uploadAttachments') { + data = true + } else if (path === '/web/crm/contract/invalid') { + data = true + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code, msg, data }) + }) +}) + +async function openPrint() { + const direct = page.getByRole('button', { name: '合同打印', exact: true }) + if (!(await direct.isVisible())) + await page.getByRole('button', { name: '更多', exact: true }).click() + await direct.click() + await page.locator('.mp-contract-print').waitFor() + if (retryEditor) { + await page.getByRole('button', { name: '重新加载编辑器', exact: true }).click() + retryEditor = false + } + + if (slowEditor) { + await modelStarted + const pdf = page + .locator('.mp-contract-print') + .getByRole('button', { name: '生成 PDF', exact: true }) + assert.equal(await pdf.isDisabled(), true, '初始化未完成时不能读取serializer或导出') + assert.equal( + await page + .locator('.mp-contract-print') + .getByRole('button', { name: '保存模板', exact: true }) + .isDisabled(), + true + ) + assert.equal( + await page + .locator('.mp-contract-print') + .getByRole('button', { name: '关闭', exact: true }) + .isEnabled(), + true + ) + slowEditor = false + if (process.env.CONTRACT_TEST_SLOW_EDITOR === 'timeout') { + await page + .getByRole('button', { name: '重新加载页面', exact: true }) + .click({ timeout: 25000 }) + await page.waitForFunction(() => window.tinymce?.activeEditor?.initialized) + } + releaseEditorModel() + } + await page.waitForFunction(() => window.tinymce?.activeEditor?.initialized) +} +async function clickPrintButton(name) { + await page.locator('.mp-contract-print').getByRole('button', { name, exact: true }).click() +} +try { + await page.goto(`${origin}/#/m/crm/contract/contract-1?mpTicket=print-fixture`) + await page.getByRole('heading', { name: '合同详情', exact: true }).waitFor() + await openPrint() + assert.match( + await page.evaluate(() => window.tinymce.activeEditor.getContent()), + /HT-20261017-001/ + ) + for (const width of [375, 414]) { + await page.setViewportSize({ width, height: 812 }) + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), + false + ) + await page.waitForFunction( + (expected) => + Math.round(document.querySelector('.mp-contract-print').getBoundingClientRect().width) === + expected, + width + ) + assert.equal(Math.round((await page.locator('.mp-contract-print').boundingBox()).width), width) + const box = await page + .locator('.mp-contract-print') + .getByRole('button', { name: '生成 PDF', exact: true }) + .boundingBox() + assert.ok(box && box.x >= 0 && box.x + box.width <= width && box.y + box.height <= 812) + } + await page.evaluate(() => { + const editor = window.tinymce.activeEditor + editor.setContent(editor.getContent() + '

手机编辑保留内容

') + editor.fire('change') + }) + await Promise.all([ + page.waitForResponse((response) => response.url().endsWith('/saveTemplate')), + clickPrintButton('保存模板') + ]) + assert.match(savedTemplate, /手机编辑保留内容/) + const save = calls.findLast((call) => call.path.endsWith('/saveTemplate')) + assert.equal(save.body.id, 'contract-1') + assert.equal('products' in save.body, false) + await clickPrintButton('预览模式') + await page + .locator('.mp-contract-print') + .getByRole('button', { name: '编辑模式', exact: true }) + .waitFor() + await clickPrintButton('编辑模式') + await clickPrintButton('关闭') + await page.locator('.mp-contract-print').waitFor({ state: 'hidden' }) + await openPrint() + assert.match( + await page.evaluate(() => window.tinymce.activeEditor.getContent()), + /手机编辑保留内容/ + ) + await clickPrintButton('关闭') + await page.locator('.mp-contract-print').waitFor({ state: 'hidden' }) + savedTemplate = '' + await openPrint() + await page.waitForFunction(() => window.tinymce.activeEditor.getContent().includes('工业甲醇')) + assert.match(await page.evaluate(() => window.tinymce.activeEditor.getContent()), /产品名称/) + assert.ok( + await page.evaluate( + () => + window.tinymce.activeEditor.getBody().scrollWidth > + window.tinymce.activeEditor.getWin().innerWidth + ) + ) + await clickPrintButton('隐藏红章') + await page.waitForFunction(() => !window.tinymce.activeEditor.getContent().includes('alt="印章"')) + await clickPrintButton('显示红章') + await page.waitForFunction(() => window.tinymce.activeEditor.getContent().includes('alt="印章"')) + await page.screenshot({ path: `${artifacts}/editor-414.png` }) + // 本阶段验证普通浏览器下载;之后单独切到微信宿主验证原生交付。 + await page.evaluate(() => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: navigator.userAgent.replace(/MicroMessenger\/\S+|miniProgram/gi, '') + }) + }) + for (const [label, extension] of [ + ['生成 Word', '.docx'], + ['生成 PDF', '.pdf'] + ]) { + const downloadPromise = page.waitForEvent('download', { timeout: 60000 }) + await clickPrintButton(label) + const download = await downloadPromise + assert.ok(download.suggestedFilename().endsWith(extension)) + await download.saveAs(`${artifacts}/contract${extension}`) + const bytes = await readFile(`${artifacts}/contract${extension}`) + assert.ok(bytes.length > 100) + if (extension === '.pdf') assert.equal(bytes.subarray(0, 4).toString(), '%PDF') + else assert.equal(bytes.subarray(0, 2).toString(), 'PK') + } + await page.evaluate(() => { + // iPhone 同步环境标记缺失,但 JSSDK getEnv 能确认真实小程序宿主。 + delete window.__wxjs_environment + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 MicroMessenger/8.0' + }) + window.wx.miniProgram.getEnv = (callback) => + setTimeout(() => callback({ miniprogram: true }), 30) + }) + failUpload = true + await clickPrintButton('生成 PDF') + await page.getByText('模拟上传失败', { exact: true }).first().waitFor() + await page + .locator('.mp-contract-print') + .getByRole('button', { name: '生成 PDF', exact: true }) + .waitFor() + assert.match(await page.evaluate(() => window.tinymce.activeEditor.getContent()), /工业甲醇/) + assert.equal(await page.evaluate(() => window.__documentNavigation), undefined) + failUpload = false + await clickPrintButton('生成 PDF') + await page.waitForFunction(() => window.__documentNavigation) + assert.match( + await page.evaluate(() => window.__documentNavigation), + /pages\/common\/contract-document/ + ) + assert.ok(calls.some((call) => call.path === '/web/admin/attachment/upload')) + assert.ok(calls.some((call) => call.path === '/web/admin/attachment/download-url')) + await clickPrintButton('关闭') + await page.locator('.mp-contract-print').waitFor({ state: 'hidden' }) + baseContract.contractNo = 'YD/X-JT20261031001' + await openPrint() + await page.evaluate(() => { + window.tinymce.activeEditor.fire('SetContent') + const close = [...document.querySelectorAll('.mp-contract-print button')].find( + (button) => button.textContent.trim() === '关闭' + ) + close.click() + }) + await page.locator('.mp-contract-print').waitFor({ state: 'hidden' }) + // 等待Logo回调的500ms窗口,验证关闭后没有访问已销毁的编辑器。 + await page.waitForTimeout(650) + assert.deepEqual(pageErrors, []) + console.log( + 'PASS mobile contract print: 375/414 layout, template save, preview, DOCX/PDF downloads, mini-program file bridge; fixture only' + ) +} finally { + await page.screenshot({ path: `${artifacts}/last-state.png` }).catch(() => {}) + await browser.close() +} diff --git a/web-admin/src/views/m/crm/contract/detail.vue b/web-admin/src/views/m/crm/contract/detail.vue index d2503a18e..355b47fa1 100644 --- a/web-admin/src/views/m/crm/contract/detail.vue +++ b/web-admin/src/views/m/crm/contract/detail.vue @@ -1,5 +1,6 @@