feat(contract): 接入移动端合同打印与小程序文档交付以支持导出分享。
This commit is contained in:
1 parent
0cd120ef5e
commit
13630045df
16 files changed
+3666
-4
No files matched your search
@@ -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。
|
||||
@@ -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配置检查通过。未上传或发布,若域名已配置仍报错,需要用更新后的原生包取得实际错误继续排查。
|
||||
@@ -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 流程可运行;关闭或切换合同不串数据;导出失败不清空内容;文件页拒绝非法链接。执行定向回归与构建,真机未执行须明确标记。
|
||||
@@ -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);地磅页面与公共组件见上文「已接通业务:地磅管理」。
|
||||
@@ -54,6 +54,7 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{ "path": "pages/common/contract-document", "style": { "navigationBarTitleText": "合同文档" } },
|
||||
{
|
||||
"path": "pages/common/biz-placeholder",
|
||||
"style": {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<view class="contract-document">
|
||||
<text class="contract-document__title">{{ fileName }}</text>
|
||||
<text>{{ message }}</text>
|
||||
<button :disabled="busy || !documentInfo" @click="openDocument">{{ busy ? '正在打开…' : '打开文档' }}</button>
|
||||
<text class="contract-document__hint">打开后可从右上角菜单转发文件;打印请将 PDF 交给支持打印的应用或电脑。</text>
|
||||
<button @click="goBack">返回合同</button>
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { MINIO_BASE_URL } from '@/config/env'
|
||||
import { validateContractDocument, contractDownloadErrorMessage } from '@/utils/contract-document'
|
||||
const fileName = ref('合同文档')
|
||||
const message = ref('正在准备文档…')
|
||||
const busy = ref(false)
|
||||
const documentInfo = ref(null)
|
||||
let localPath = ''
|
||||
let alive = true
|
||||
let downloadTask
|
||||
function removeTemp(path) {
|
||||
if (!path) return
|
||||
try { uni.getFileSystemManager().unlink({ filePath: path, fail() {} }) } catch {}
|
||||
}
|
||||
function goBack() { uni.navigateBack({ delta: 1 }) }
|
||||
async function openDocument() {
|
||||
if (busy.value || !documentInfo.value) return
|
||||
busy.value = true
|
||||
message.value = '正在下载并打开文档…'
|
||||
try {
|
||||
if (!localPath) {
|
||||
localPath = await new Promise((resolve, reject) => {
|
||||
downloadTask = uni.downloadFile({
|
||||
url: documentInfo.value.url,
|
||||
success: (res) => {
|
||||
if (!alive) { removeTemp(res.tempFilePath); reject(new Error('已关闭')); return }
|
||||
if (res.statusCode !== 200) { removeTemp(res.tempFilePath); reject(new Error('文件下载失败,请返回合同重新生成')); return }
|
||||
resolve(res.tempFilePath)
|
||||
},
|
||||
fail: (error) => reject(new Error(contractDownloadErrorMessage(error, documentInfo.value.url)))
|
||||
})
|
||||
})
|
||||
}
|
||||
if (!alive) return
|
||||
await new Promise((resolve, reject) => uni.openDocument({
|
||||
filePath: localPath,
|
||||
fileType: documentInfo.value.type,
|
||||
showMenu: true,
|
||||
success: resolve,
|
||||
fail: () => reject(new Error('无法打开文档,请重试'))
|
||||
}))
|
||||
message.value = '文档已打开,可再次打开或返回继续编辑合同。'
|
||||
} catch (error) {
|
||||
if (alive) message.value = error.message || '打开失败,请重试'
|
||||
} finally { if (alive) busy.value = false }
|
||||
}
|
||||
onLoad((query) => {
|
||||
try {
|
||||
// uni-app 页面参数按平台可能已解码,最多解码一次完整 URL。
|
||||
const url = /^https?:\/\//.test(query?.url || '') ? query.url : decodeURIComponent(query?.url || '')
|
||||
documentInfo.value = validateContractDocument({ ...query, url }, MINIO_BASE_URL)
|
||||
fileName.value = documentInfo.value.name
|
||||
try { fileName.value = decodeURIComponent(fileName.value) } catch { /* 参数可能已由宿主解码 */ }
|
||||
void openDocument()
|
||||
} catch (error) { message.value = error.message || '无效的文档地址' }
|
||||
})
|
||||
onUnload(() => { alive = false; downloadTask?.abort(); removeTemp(localPath) })
|
||||
</script>
|
||||
<style scoped>
|
||||
.contract-document { padding: 24px 16px; display: flex; flex-direction: column; gap: 16px; color: #243b33; }
|
||||
.contract-document__title { font-size: 18px; font-weight: 600; word-break: break-all; }
|
||||
.contract-document button { width: 100%; min-height: 44px; color: #0a9f6d; background: #e9f9f2; }
|
||||
.contract-document__hint { font-size: 13px; color: #6b7b74; line-height: 1.6; }
|
||||
</style>
|
||||
@@ -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('<script setup>')[1].split('</script>')[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/)
|
||||
})
|
||||
@@ -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},请检查网络或稍后重试`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 =
|
||||
'<div style="width:700px"><h2>移动打印验证 {{contractNo}}</h2><p>需方:{{partyB}}</p><p>可编辑的合同条款</p></div>'
|
||||
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() + '<p>手机编辑保留内容</p>')
|
||||
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()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<component :is="props.embedded ? 'div' : MpWebviewShell" :class="{ 'mp-contract-embedded': props.embedded }" @ready="onShellReady">
|
||||
<MobileContractPrint v-if="printActive" :contract-id="printContractId" @close="printActive = false" />
|
||||
<div
|
||||
class="mp-h5-detail-page mp-contract-detail-page mp-contract-detail-touch"
|
||||
:style="{
|
||||
@@ -858,6 +859,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const MobileContractPrint = defineAsyncComponent(() => import('./print/MobileContractPrint.vue'))
|
||||
const printActive = ref(false)
|
||||
const printContractId = ref('')
|
||||
|
||||
import { mpContractRelatedLocation } from '@/utils/mp-webview'
|
||||
import ContractPickerSheet from './ContractPickerSheet.vue'
|
||||
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
|
||||
@@ -1291,13 +1296,14 @@
|
||||
{ key: 'reject', label: '驳回', danger: true }
|
||||
)
|
||||
}
|
||||
actions.push({ key: 'upload', label: '上传扫描件' })
|
||||
actions.push({ key: 'print', label: '合同打印' }, { key: 'upload', label: '上传扫描件' })
|
||||
return actions
|
||||
}
|
||||
|
||||
const status = c.status || ''
|
||||
if (status === 'invalid') {
|
||||
return [
|
||||
{ key: 'print', label: '合同打印' },
|
||||
{ key: 'restore', label: '作废恢复' },
|
||||
{ key: 'copy', label: '复制' },
|
||||
{ key: 'upload', label: '上传扫描件' }
|
||||
@@ -1320,7 +1326,7 @@
|
||||
if (canInvalid.value) {
|
||||
actions.push({ key: 'void', label: '作废', danger: true })
|
||||
}
|
||||
actions.push({ key: 'copy', label: '复制' }, { key: 'upload', label: '上传扫描件' })
|
||||
actions.push({ key: 'print', label: '合同打印' }, { key: 'copy', label: '复制' }, { key: 'upload', label: '上传扫描件' })
|
||||
return actions
|
||||
})
|
||||
|
||||
@@ -1487,6 +1493,7 @@
|
||||
)
|
||||
}
|
||||
function closeRecordActions() {
|
||||
printActive.value = false
|
||||
operationEpoch++
|
||||
acting.value = false
|
||||
moreOpen.value = false
|
||||
@@ -1663,8 +1670,14 @@
|
||||
}
|
||||
|
||||
async function runAction(key: string) {
|
||||
if (!captureOperation(key)) return
|
||||
const operation = captureOperation(key)
|
||||
if (!operation) return
|
||||
moreOpen.value = false
|
||||
if (key === 'print') {
|
||||
printContractId.value = operation.id
|
||||
printActive.value = true
|
||||
return
|
||||
}
|
||||
if (key === 'submit') {
|
||||
await openSubmitSheet()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
<template>
|
||||
<div class="tinymce-editor-container">
|
||||
<div v-if="!editorReady" class="mobile-editor-status" :role="initError ? 'alert' : 'status'">
|
||||
<span>{{ initError || '合同编辑器正在加载…' }}</span>
|
||||
<el-button v-if="initError" @click="retryEditor"
|
||||
><el-icon><Refresh /></el-icon
|
||||
>{{ reloadPageRequired ? '重新加载页面' : '重新加载编辑器' }}</el-button
|
||||
>
|
||||
</div>
|
||||
<Editor
|
||||
v-if="runtimeReady && !initError"
|
||||
:key="editorAttempt"
|
||||
:id="editorId"
|
||||
v-model="contentValue"
|
||||
:init="editorInit"
|
||||
:disabled="disabled"
|
||||
tinymce-script-src="/tinymce/tinymce.min.js"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Editor from '@tinymce/tinymce-vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { setupSealPointerDrag } from './sealPointerDrag'
|
||||
import { fetchUploadAttachment } from '@/api/admin'
|
||||
import { resolveCrmAttachmentUrl } from '@/utils/crm-attachment'
|
||||
import { buildAttachmentUploadFormData, CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload'
|
||||
import { setupTableEdgeResize } from '@/utils/tinymce-table-edge-resize'
|
||||
|
||||
/** 须高于 ElConfigProvider 初始 z-index(3000) 及多层 el-dialog 叠加 */
|
||||
onMounted(() => document.body.classList.add('mp-contract-editor-active'))
|
||||
onBeforeUnmount(() => document.body.classList.remove('mp-contract-editor-active'))
|
||||
const TINYMCE_Z_INDEX_FLOOR = 10000
|
||||
|
||||
/** 读取当前页面 Element Plus 弹层最高 z-index,保证 TinyMCE 浮层在其上 */
|
||||
const resolveTinyMceBaseZIndex = (): number => {
|
||||
let maxZ = 3000
|
||||
document.querySelectorAll('.el-overlay, .el-dialog, .el-popper').forEach((el) => {
|
||||
const z = Number.parseInt(window.getComputedStyle(el).zIndex || '0', 10)
|
||||
if (!Number.isNaN(z) && z > maxZ) maxZ = z
|
||||
})
|
||||
return Math.max(maxZ + 200, TINYMCE_Z_INDEX_FLOOR)
|
||||
}
|
||||
|
||||
const syncTinyMceFloatingZIndex = (baseZ: number) => {
|
||||
const z = String(baseZ)
|
||||
const backdropZ = String(baseZ - 1)
|
||||
document.querySelectorAll('.tox-tinymce-aux').forEach((el) => {
|
||||
;(el as HTMLElement).style.zIndex = z
|
||||
})
|
||||
document.querySelectorAll('.tox-dialog-wrap').forEach((el) => {
|
||||
;(el as HTMLElement).style.zIndex = z
|
||||
})
|
||||
document.querySelectorAll('.tox-dialog').forEach((el) => {
|
||||
;(el as HTMLElement).style.zIndex = String(baseZ + 1)
|
||||
})
|
||||
document.querySelectorAll('.tox-dialog__backdrop').forEach((el) => {
|
||||
;(el as HTMLElement).style.zIndex = backdropZ
|
||||
})
|
||||
document
|
||||
.querySelectorAll('.tox-menu, .tox-pop, .tox-toolbar__overflow, .tox-collection')
|
||||
.forEach((el) => {
|
||||
;(el as HTMLElement).style.zIndex = String(baseZ + 2)
|
||||
})
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
disabled?: boolean
|
||||
height?: number
|
||||
toolbar?: boolean
|
||||
contractNo?: string // 合同编号,用于提取供方编码
|
||||
supplierCode?: string // 供应商编码,直接传入(优先级高于从contractNo提取)
|
||||
/** TinyMCE 编辑区 body 内边距,默认 20px;入库单等紧凑预览可传 0 */
|
||||
contentBodyPadding?: string
|
||||
/** TinyMCE 表格单元格内边距,默认 10px;入库单等可传 4px */
|
||||
tableCellPadding?: string
|
||||
/** 追加到 TinyMCE content_style(如入库单打印专用 CSS) */
|
||||
contentStyleExtra?: string
|
||||
/** 编辑器 setup 阶段追加回调(如送(提)货单多选项交互) */
|
||||
editorSetupExtra?: (editor: any) => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
height: 600,
|
||||
toolbar: true,
|
||||
contractNo: '',
|
||||
supplierCode: '',
|
||||
contentBodyPadding: '20px',
|
||||
tableCellPadding: '10px',
|
||||
contentStyleExtra: '',
|
||||
editorSetupExtra: undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'ready-change'])
|
||||
const editorReady = ref(false)
|
||||
const runtimeReady = ref(false)
|
||||
let pendingScript: HTMLScriptElement | null = null
|
||||
const initError = ref('')
|
||||
const reloadPageRequired = ref(false)
|
||||
const editorAttempt = ref(0)
|
||||
let initTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let editorDisposed = false
|
||||
const requestedAddons: Array<{ manager: string; name: string }> = []
|
||||
const failEditor = (message: string) => {
|
||||
if (editorDisposed || editorReady.value) return
|
||||
clearTimeout(initTimer)
|
||||
initError.value = message
|
||||
emit('ready-change', false)
|
||||
}
|
||||
const startInitTimer = () => {
|
||||
clearTimeout(initTimer)
|
||||
initTimer = setTimeout(() => {
|
||||
reloadPageRequired.value = true
|
||||
failEditor('编辑器加载超时,请重新加载页面。将重新打开当前合同,已保存内容不会丢失。')
|
||||
}, 20000)
|
||||
}
|
||||
const loadEditorRuntime = () => {
|
||||
if ((window as any).tinymce) {
|
||||
runtimeReady.value = true
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
pendingScript = script
|
||||
script.src = '/tinymce/tinymce.min.js'
|
||||
script.onload = () => {
|
||||
if (editorDisposed || pendingScript !== script) return
|
||||
pendingScript = null
|
||||
script.onload = script.onerror = null
|
||||
if ((window as any).tinymce) runtimeReady.value = true
|
||||
else failEditor('编辑器脚本未能启动,请重新加载编辑器。合同内容已保留。')
|
||||
}
|
||||
script.onerror = () => {
|
||||
if (editorDisposed || pendingScript !== script) return
|
||||
pendingScript = null
|
||||
script.remove()
|
||||
failEditor('编辑器脚本加载失败,请检查网络后重试。合同内容已保留。')
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
const clearPendingScript = () => {
|
||||
if (!pendingScript) return
|
||||
pendingScript.onload = pendingScript.onerror = null
|
||||
pendingScript.remove()
|
||||
pendingScript = null
|
||||
}
|
||||
const retryEditor = () => {
|
||||
if (reloadPageRequired.value) {
|
||||
// 请求持续挂起时,TinyMCE 的共享脚本队列仍在等待;完整刷新释放旧队列。
|
||||
const url = new URL(window.location.href)
|
||||
const [path, query = ''] = url.hash.slice(1).split('?')
|
||||
const params = new URLSearchParams(query)
|
||||
params.set('action', 'print')
|
||||
url.hash = `${path}?${params.toString()}`
|
||||
window.history.replaceState(window.history.state, '', url.href)
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
clearPendingScript()
|
||||
// 超时和失败都会留下地址缓存;仅清理本编辑器请求且尚未注册的项。
|
||||
const tiny = (window as any).tinymce
|
||||
for (const { manager, name } of requestedAddons.splice(0)) {
|
||||
const registry = tiny?.[manager]
|
||||
if (name && registry && !registry.get(name)) registry.remove(name)
|
||||
}
|
||||
tinymceEditor = null
|
||||
editorReady.value = false
|
||||
initError.value = ''
|
||||
editorAttempt.value++
|
||||
emit('ready-change', false)
|
||||
startInitTimer()
|
||||
loadEditorRuntime()
|
||||
}
|
||||
onMounted(() => {
|
||||
startInitTimer()
|
||||
loadEditorRuntime()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
editorDisposed = true
|
||||
clearPendingScript()
|
||||
clearTimeout(initTimer)
|
||||
tinymceEditor = null
|
||||
})
|
||||
|
||||
// 生成唯一 ID
|
||||
const editorId = `tinymce-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// 内容
|
||||
const contentValue = ref(props.modelValue)
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal !== contentValue.value) {
|
||||
contentValue.value = newVal
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听内容变化
|
||||
watch(contentValue, (newVal) => {
|
||||
emit('update:modelValue', newVal)
|
||||
})
|
||||
|
||||
// 从合同编号中提取供方编码
|
||||
const getSupplierCode = (contractNo?: string): string => {
|
||||
if (!contractNo) return 'SX' // 默认值
|
||||
// 合同编号格式:{供方编码}/X-JT + yyyyMMdd + 3位流水号
|
||||
// 例如:SX/X-JT20250926002
|
||||
const match = contractNo.match(/^([A-Z]+)\/X-JT/)
|
||||
return match && match[1] ? match[1] : 'SX'
|
||||
}
|
||||
|
||||
// 获取供应商编码(优先使用直接传入的supplierCode,否则从contractNo提取)
|
||||
const getCurrentSupplierCode = computed(() => {
|
||||
if (props.supplierCode) {
|
||||
return props.supplierCode
|
||||
}
|
||||
return getSupplierCode(props.contractNo)
|
||||
})
|
||||
|
||||
// 判断是否为SX供方编码
|
||||
const isSXSupplier = computed(() => {
|
||||
return getCurrentSupplierCode.value === 'SX'
|
||||
})
|
||||
|
||||
// TinyMCE 配置
|
||||
let tinymceEditor: any = null
|
||||
|
||||
const editorInit = computed(() => {
|
||||
const baseZIndex = resolveTinyMceBaseZIndex()
|
||||
const backgroundStyle = isSXSupplier.value
|
||||
? `
|
||||
/* 编辑区水印贴在视口右下角,避免盖住产品表单价。 */
|
||||
html { background: #fff; }
|
||||
body { background: transparent !important; }
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url('/watermask.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 16px bottom 16px;
|
||||
background-size: 140px auto;
|
||||
opacity: 0.18;
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
`
|
||||
: ''
|
||||
|
||||
return {
|
||||
// 基础路径配置
|
||||
skin_url: '/tinymce/skins/ui/oxide',
|
||||
cache_suffix: editorAttempt.value ? `?mpPrintRetry=${editorAttempt.value}` : '',
|
||||
...(editorAttempt.value
|
||||
? {
|
||||
model_url: `/tinymce/models/dom/model.min.js?mpPrintRetry=${editorAttempt.value}`,
|
||||
theme_url: `/tinymce/themes/silver/theme.min.js?mpPrintRetry=${editorAttempt.value}`
|
||||
}
|
||||
: {}),
|
||||
|
||||
// 语言配置
|
||||
language: 'zh_CN',
|
||||
language_url: '/tinymce/langs/zh_CN.js',
|
||||
|
||||
// 基础设置
|
||||
// 移动 WebView 使用同步 iframe 初始化,避免 srcdoc 的 load 未完成时停在空白编辑区。
|
||||
init_content_sync: true,
|
||||
height: props.height,
|
||||
menubar: false,
|
||||
mobile: { toolbar_mode: 'wrap' },
|
||||
statusbar: false,
|
||||
readonly: props.disabled,
|
||||
|
||||
// 插件(如果有工具栏才加载)
|
||||
plugins: props.toolbar
|
||||
? [
|
||||
'advlist',
|
||||
'autolink',
|
||||
'lists',
|
||||
'link',
|
||||
'image',
|
||||
'charmap',
|
||||
'anchor',
|
||||
'searchreplace',
|
||||
'visualblocks',
|
||||
'code',
|
||||
'fullscreen',
|
||||
'insertdatetime',
|
||||
'media',
|
||||
'table',
|
||||
'help',
|
||||
'wordcount'
|
||||
]
|
||||
: [],
|
||||
|
||||
// 工具栏:突出表格排版(合并/拆分、单元格属性、对齐、换行)+ 盖章
|
||||
toolbar: props.toolbar
|
||||
? 'undo redo | ' +
|
||||
'bold italic underline | ' +
|
||||
'alignleft aligncenter alignright | ' +
|
||||
'table tableprops tablecellprops tablemergecells tablesplitcells | ' +
|
||||
'tablecellhalign tablecellvalign tablecellwrap | ' +
|
||||
'bullist numlist | ' +
|
||||
'link image | ' +
|
||||
'sealMode cancelSeal | ' +
|
||||
'forecolor backcolor | ' +
|
||||
'removeformat'
|
||||
: false,
|
||||
|
||||
// 内容样式
|
||||
content_style: `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
padding: ${props.contentBodyPadding};
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-width: 754px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
img[data-seal] { touch-action: none; }
|
||||
${backgroundStyle}
|
||||
/* 表格:固定布局;不抢占内联宽高/对齐/边框 */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
table td, table th {
|
||||
border: 1px solid #ddd;
|
||||
padding: ${props.tableCellPadding};
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
vertical-align: middle;
|
||||
cursor: inherit;
|
||||
/* 与 getBoundingClientRect 量到的边框盒宽度一致,避免快照写回时每列被放大 */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
table th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
/* 列宽/行高拖拽已关闭,避免调整条改写表格列宽 */
|
||||
.ephox-snooker-resizer-bar {
|
||||
display: none !important;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
h2 {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
margin-bottom: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
p {
|
||||
line-height: 1.8;
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
div {
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
}
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
/* 盖章图片样式 */
|
||||
img[data-seal] {
|
||||
position: absolute !important;
|
||||
z-index: 999 !important;
|
||||
}
|
||||
${props.contentStyleExtra || ''}
|
||||
`,
|
||||
|
||||
// 保留所有 HTML
|
||||
extended_valid_elements: '*[*]',
|
||||
valid_elements: '*[*]',
|
||||
|
||||
// 表格配置:固定像素列宽,不显示列/行拖拽条
|
||||
table_toolbar:
|
||||
'tableprops tablecellprops tabledelete | tablemergecells tablesplitcells | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablecellhalign tablecellvalign tablecellwrap',
|
||||
table_appearance_options: true,
|
||||
table_grid: true,
|
||||
table_resize_bars: false,
|
||||
table_sizing_mode: 'fixed',
|
||||
table_use_colgroups: true,
|
||||
table_default_attributes: {
|
||||
border: '1'
|
||||
},
|
||||
table_default_styles: {
|
||||
'border-collapse': 'collapse',
|
||||
width: '100%'
|
||||
},
|
||||
|
||||
// 其他配置
|
||||
branding: false,
|
||||
promotion: false,
|
||||
|
||||
// 设置弹窗的 z-index,确保在 Element Plus Dialog 之上(须高于 App.vue ElConfigProvider :z-index="3000")
|
||||
base_z_index: baseZIndex,
|
||||
|
||||
// 图片上传配置(复用门户统一附件接口,与 CRM 其它上传一致)
|
||||
images_upload_handler: (blobInfo: any, progress: any) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
progress(10)
|
||||
const file = new File([blobInfo.blob()], blobInfo.filename(), {
|
||||
type: blobInfo.blob().type || 'image/jpeg'
|
||||
})
|
||||
const formData = buildAttachmentUploadFormData(file, {
|
||||
bizType: CRM_UPLOAD_BIZ_TYPE.QUOTE_ATTACHMENT
|
||||
})
|
||||
progress(40)
|
||||
const res = await fetchUploadAttachment(formData)
|
||||
const fileUrl = res?.fileUrl
|
||||
if (!fileUrl) {
|
||||
reject('上传失败')
|
||||
return
|
||||
}
|
||||
progress(100)
|
||||
const imageUrl = resolveCrmAttachmentUrl(fileUrl)
|
||||
resolve(imageUrl)
|
||||
console.log('图片上传成功:', imageUrl)
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '上传失败'
|
||||
reject(errorMsg)
|
||||
console.error('图片上传失败:', errorMsg)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 允许粘贴图片
|
||||
paste_data_images: true,
|
||||
automatic_uploads: true,
|
||||
|
||||
// 图片文件类型
|
||||
file_picker_types: 'image',
|
||||
|
||||
// 图片上传限制
|
||||
images_file_types: 'jpg,jpeg,png,gif,bmp,webp',
|
||||
|
||||
// 图片高级编辑功能
|
||||
image_advtab: true,
|
||||
image_caption: true,
|
||||
image_title: true,
|
||||
|
||||
// 图片仍可拖拽缩放;表格只在第一行右边界、第一列下边界拖拽
|
||||
object_resizing: 'img,figure.image,div,video,iframe',
|
||||
|
||||
// 允许拖拽图片
|
||||
draggable_modal: false,
|
||||
|
||||
// 初始化回调
|
||||
setup: (editor: any) => {
|
||||
tinymceEditor = editor
|
||||
requestedAddons.push({ manager: 'ModelManager', name: editor.options.get('model') })
|
||||
requestedAddons.push({ manager: 'ThemeManager', name: editor.options.get('theme') })
|
||||
for (const name of editor.options.get('plugins')) {
|
||||
requestedAddons.push({ manager: 'PluginManager', name })
|
||||
}
|
||||
editor.on('init', () => {
|
||||
if (editorDisposed || initError.value || editor !== tinymceEditor || !editor.serializer)
|
||||
return
|
||||
clearTimeout(initTimer)
|
||||
editorReady.value = true
|
||||
initError.value = ''
|
||||
emit('ready-change', true)
|
||||
})
|
||||
editor.on('SkinLoadError ThemeLoadError ModelLoadError PluginLoadError', () => {
|
||||
if (editor === tinymceEditor)
|
||||
failEditor('编辑器资源加载失败,请检查网络后重试。合同内容已保留。')
|
||||
})
|
||||
props.editorSetupExtra?.(editor)
|
||||
setupTableEdgeResize(editor)
|
||||
|
||||
const applyCellStyle = (style: string, value: string) => {
|
||||
if (editor.queryCommandSupported('mceTableApplyCellStyle')) {
|
||||
editor.execCommand('mceTableApplyCellStyle', false, { [style]: value })
|
||||
}
|
||||
}
|
||||
|
||||
// 单元格水平对齐(table 插件自带垂直对齐 tablecellvalign,水平需自建)
|
||||
editor.ui.registry.addMenuButton('tablecellhalign', {
|
||||
icon: 'align-center',
|
||||
tooltip: '单元格水平对齐',
|
||||
fetch: (callback: (items: any[]) => void) => {
|
||||
callback([
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '左对齐',
|
||||
icon: 'align-left',
|
||||
onAction: () => applyCellStyle('text-align', 'left')
|
||||
},
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '居中',
|
||||
icon: 'align-center',
|
||||
onAction: () => applyCellStyle('text-align', 'center')
|
||||
},
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '右对齐',
|
||||
icon: 'align-right',
|
||||
onAction: () => applyCellStyle('text-align', 'right')
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
// 单元格文字换行
|
||||
editor.ui.registry.addMenuButton('tablecellwrap', {
|
||||
icon: 'line',
|
||||
tooltip: '单元格文字换行',
|
||||
fetch: (callback: (items: any[]) => void) => {
|
||||
callback([
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '自动换行',
|
||||
onAction: () => {
|
||||
applyCellStyle('white-space', 'normal')
|
||||
applyCellStyle('word-wrap', 'break-word')
|
||||
applyCellStyle('overflow-wrap', 'break-word')
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '不换行',
|
||||
onAction: () => applyCellStyle('white-space', 'nowrap')
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
const refreshFloatingLayers = () => {
|
||||
const z = resolveTinyMceBaseZIndex()
|
||||
syncTinyMceFloatingZIndex(z)
|
||||
}
|
||||
|
||||
editor.on('init', () => {
|
||||
refreshFloatingLayers()
|
||||
})
|
||||
|
||||
editor.on('OpenWindow', refreshFloatingLayers)
|
||||
editor.on('CloseWindow', refreshFloatingLayers)
|
||||
editor.on('ShowMenu', refreshFloatingLayers)
|
||||
editor.on('focus', refreshFloatingLayers)
|
||||
|
||||
// 编辑器初始化完成
|
||||
// 注意:水印已在 content_style 的 body::before 伪元素中设置,无需重复添加
|
||||
editor.on('init', () => {
|
||||
console.log('TinyMCE编辑器初始化完成')
|
||||
})
|
||||
|
||||
// 注册"盖章模式"按钮 - 可拖动的浮动图片
|
||||
editor.ui.registry.addButton('sealMode', {
|
||||
text: '盖章模式',
|
||||
tooltip: '将选中的图片设置为可拖动的盖章(浮于文字上方)',
|
||||
onAction: () => {
|
||||
const img = editor.selection.getNode()
|
||||
if (img && img.nodeName === 'IMG') {
|
||||
console.log('选中的图片:', img)
|
||||
console.log('图片 src:', img.src)
|
||||
console.log('图片宽度:', img.width)
|
||||
|
||||
const imageRect = img.getBoundingClientRect()
|
||||
const positioningParent = img.offsetParent || editor.getBody()
|
||||
const parentRect = positioningParent.getBoundingClientRect()
|
||||
const imgWidth = img.width || 120
|
||||
|
||||
// 直接修改图片样式,不删除重建
|
||||
editor.dom.setStyles(img, {
|
||||
position: 'absolute',
|
||||
left: `${imageRect.left - parentRect.left + positioningParent.scrollLeft - positioningParent.clientLeft}px`,
|
||||
top: `${imageRect.top - parentRect.top + positioningParent.scrollTop - positioningParent.clientTop}px`,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
transform: 'none',
|
||||
'touch-action': 'none',
|
||||
'z-index': '9999',
|
||||
opacity: '0.6',
|
||||
width: imgWidth + 'px',
|
||||
cursor: 'move'
|
||||
})
|
||||
editor.dom.setAttrib(img, 'data-seal', 'draggable')
|
||||
editor.dom.addClass(img, 'seal-image-draggable')
|
||||
|
||||
editor.fire('change')
|
||||
|
||||
console.log('盖章设置完成,图片样式:', img.style.cssText)
|
||||
alert('盖章已设置!现在可以拖动图片到任意位置')
|
||||
} else {
|
||||
alert('请先选中一张图片')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 注册"取消盖章"按钮
|
||||
editor.ui.registry.addButton('cancelSeal', {
|
||||
text: '取消盖章',
|
||||
tooltip: '将图片恢复为正常模式',
|
||||
onAction: () => {
|
||||
const selectedImage = editor.selection.getNode()
|
||||
if (selectedImage && selectedImage.nodeName === 'IMG') {
|
||||
// 移除盖章样式
|
||||
editor.dom.setStyles(selectedImage, {
|
||||
position: '',
|
||||
left: '',
|
||||
right: '',
|
||||
top: '',
|
||||
bottom: '',
|
||||
transform: '',
|
||||
'z-index': '',
|
||||
opacity: '',
|
||||
'pointer-events': '',
|
||||
'touch-action': '',
|
||||
cursor: '',
|
||||
'max-width': ''
|
||||
})
|
||||
editor.dom.setAttrib(selectedImage, 'data-seal', null)
|
||||
editor.dom.setAttrib(selectedImage, 'draggable', null)
|
||||
|
||||
// 恢复父容器样式
|
||||
const parent = selectedImage.parentElement
|
||||
if (parent) {
|
||||
editor.dom.setStyle(parent, 'position', '')
|
||||
}
|
||||
|
||||
editor.fire('change')
|
||||
} else {
|
||||
alert('请先选中一张图片')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
editor.on('init', () => {
|
||||
console.log('TinyMCE 编辑器初始化完成')
|
||||
console.log('内容长度:', editor.getContent().length)
|
||||
|
||||
// 只读模式隐藏工具栏
|
||||
if (props.disabled) {
|
||||
const container = editor.getContainer()
|
||||
const toolbar = container.querySelector('.tox-toolbar')
|
||||
if (toolbar) {
|
||||
toolbar.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// 根据供方编码控制logo显示的函数
|
||||
const hideLogoIfNeeded = () => {
|
||||
const editorBody = editor.getBody()
|
||||
if (editor.removed || !editorBody) return
|
||||
const supplierCode = getCurrentSupplierCode.value
|
||||
const isSX = supplierCode === 'SX'
|
||||
|
||||
if (!isSX) {
|
||||
// 如果不是SX供方编码,隐藏logo
|
||||
const logoImages = editorBody.querySelectorAll('img')
|
||||
logoImages.forEach((img: any) => {
|
||||
const src = img.src || img.getAttribute('src') || ''
|
||||
// 检查是否是logo图片(路径包含logo.gif或logo)
|
||||
if (
|
||||
src.includes('logo.gif') ||
|
||||
src.includes('/logo') ||
|
||||
(src.includes('logo') && !src.includes('watermask'))
|
||||
) {
|
||||
// 隐藏logo及其父容器(如果有)
|
||||
img.style.display = 'none'
|
||||
const parent = img.parentElement
|
||||
if (parent && parent.tagName === 'DIV' && parent.children.length === 1) {
|
||||
parent.style.display = 'none'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时隐藏logo
|
||||
setTimeout(() => {
|
||||
hideLogoIfNeeded()
|
||||
}, 500)
|
||||
|
||||
// 监听内容变化,重新检查logo
|
||||
editor.on('SetContent', () => {
|
||||
setTimeout(() => {
|
||||
hideLogoIfNeeded()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// 监听节点变化,重新检查logo
|
||||
editor.on('NodeChange', () => {
|
||||
setTimeout(() => {
|
||||
hideLogoIfNeeded()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
setupSealPointerDrag(editor)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 导出、保存前读取编辑器里正在显示的 HTML(含拖过的列宽、行高)。 */
|
||||
const getHtml = (): string => {
|
||||
if (
|
||||
!editorReady.value ||
|
||||
!tinymceEditor?.initialized ||
|
||||
!tinymceEditor?.serializer ||
|
||||
tinymceEditor.removed
|
||||
) {
|
||||
throw new Error('合同编辑器尚未就绪,请等待加载完成或重新加载编辑器')
|
||||
}
|
||||
return tinymceEditor.getContent()
|
||||
}
|
||||
|
||||
defineExpose({ getHtml })
|
||||
|
||||
onMounted(() => {
|
||||
console.log('TinyMCE 组件挂载,内容:', props.modelValue?.substring(0, 100))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mobile-editor-status {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: #6b7b74;
|
||||
}
|
||||
.tinymce-editor-container {
|
||||
:deep(.tox-tinymce) {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.tox-editor-header) {
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
body.mp-contract-editor-active .tox-tinymce-aux,
|
||||
body.mp-contract-editor-active .tox-dialog-wrap {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
body.mp-contract-editor-active .tox-dialog {
|
||||
z-index: 10001 !important;
|
||||
}
|
||||
body.mp-contract-editor-active .tox-dialog__backdrop {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
body.mp-contract-editor-active .tox-menu,
|
||||
body.mp-contract-editor-active .tox-pop {
|
||||
z-index: 10002 !important;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
||||
import FileSaver from 'file-saver'
|
||||
import { fetchUploadAttachment, fetchAttachmentDownloadUrl } from '@/api/admin'
|
||||
import { isInMiniProgramWebView } from '@/utils/mp-webview'
|
||||
import { buildAttachmentUploadFormData, CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload'
|
||||
|
||||
type MiniDocumentApi = {
|
||||
getEnv?: (callback: (env: { miniprogram?: boolean }) => void) => void
|
||||
navigateTo: (options: { url: string; success: () => void; fail: () => void }) => void
|
||||
}
|
||||
|
||||
/** 微信桥接就绪前,同步环境标记可能不存在;此时不能退回浏览器 Blob 下载。 */
|
||||
async function resolveMiniDocumentApi(): Promise<MiniDocumentApi | null> {
|
||||
if (!isInMiniProgramWebView() && !/MicroMessenger/i.test(navigator.userAgent)) return null
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.querySelector('script[data-mp-jweixin]')
|
||||
let checking = false
|
||||
const finish = (mini?: MiniDocumentApi, message?: string) => {
|
||||
clearTimeout(timer)
|
||||
script?.removeEventListener('load', check)
|
||||
script?.removeEventListener('error', failed)
|
||||
document.removeEventListener('WeixinJSBridgeReady', check)
|
||||
if (mini) resolve(mini)
|
||||
else reject(new Error(message || '微信文档接口未就绪,请返回合同后重新打开打印窗口'))
|
||||
}
|
||||
const failed = () => finish()
|
||||
const check = () => {
|
||||
const mini = (window as unknown as { wx?: { miniProgram?: MiniDocumentApi } }).wx?.miniProgram
|
||||
if (!mini?.navigateTo) return
|
||||
if (isInMiniProgramWebView()) return finish(mini)
|
||||
if (checking || !mini.getEnv) return
|
||||
checking = true
|
||||
try {
|
||||
mini.getEnv((env) => {
|
||||
if (env.miniprogram) finish(mini)
|
||||
else finish(undefined, '请从小程序的合同管理进入后生成文档')
|
||||
})
|
||||
} catch {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(failed, 8000)
|
||||
script?.addEventListener('load', check)
|
||||
script?.addEventListener('error', failed)
|
||||
document.addEventListener('WeixinJSBridgeReady', check)
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
/** 微信文件通过原生页打开,浏览器仍使用原下载方式。 */
|
||||
export async function deliverContractFile(blob: Blob, fileName: string, isActive = () => true) {
|
||||
if (!isActive()) return
|
||||
const mini = await resolveMiniDocumentApi()
|
||||
if (!isActive()) return
|
||||
if (!mini) {
|
||||
FileSaver.saveAs(blob, fileName)
|
||||
return 'browser-download' as const
|
||||
}
|
||||
const type = fileName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'docx'
|
||||
const data = buildAttachmentUploadFormData(new File([blob], fileName, { type: blob.type }), {
|
||||
bizType: CRM_UPLOAD_BIZ_TYPE.CONTRACT_ATTACHMENT
|
||||
})
|
||||
const response: any = await fetchUploadAttachment(data)
|
||||
if (!isActive()) return
|
||||
const uploaded = response?.data ?? response
|
||||
if (!uploaded?.fileUrl) throw new Error('生成文件上传失败,请重试')
|
||||
const result: any = await fetchAttachmentDownloadUrl({ fileUrl: uploaded.fileUrl, fileName })
|
||||
if (!isActive()) return
|
||||
const url = (result?.data ?? result)?.downloadUrl
|
||||
if (!url) throw new Error('无法取得文件下载地址')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('文档页打开超时,请返回合同后重试')), 10000)
|
||||
try {
|
||||
mini.navigateTo({
|
||||
url: `/pages/common/contract-document?url=${encodeURIComponent(url)}&type=${type}&name=${encodeURIComponent(fileName)}`,
|
||||
success: () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
},
|
||||
fail: () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('文档页打开失败,请确认小程序已更新后重试'))
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
return 'mini-program-requested' as const
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/** 移动打印专用:Pointer Events 同时支持手指、触控笔和鼠标;移除编辑器时解绑。 */
|
||||
export function setupSealPointerDrag(editor: any) {
|
||||
const body = editor.getBody() as HTMLElement
|
||||
const doc = body.ownerDocument
|
||||
let active: {
|
||||
image: HTMLElement
|
||||
id: number
|
||||
x: number
|
||||
y: number
|
||||
left: number
|
||||
top: number
|
||||
} | null = null
|
||||
const down = (event: PointerEvent) => {
|
||||
const image = event.target as HTMLElement
|
||||
if (
|
||||
active ||
|
||||
event.button !== 0 ||
|
||||
editor.mode.get() === 'readonly' ||
|
||||
!body.contains(image) ||
|
||||
image?.tagName !== 'IMG' ||
|
||||
!image.hasAttribute('data-seal')
|
||||
)
|
||||
return
|
||||
event.preventDefault()
|
||||
active = {
|
||||
image,
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
left: image.offsetLeft,
|
||||
top: image.offsetTop
|
||||
}
|
||||
image.style.cursor = 'grabbing'
|
||||
try {
|
||||
image.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
/* iframe implementations may not capture */
|
||||
}
|
||||
}
|
||||
const move = (event: PointerEvent) => {
|
||||
if (!active || active.id !== event.pointerId) return
|
||||
event.preventDefault()
|
||||
active.image.style.left = `${active.left + event.clientX - active.x}px`
|
||||
active.image.style.top = `${active.top + event.clientY - active.y}px`
|
||||
active.image.style.right = 'auto'
|
||||
active.image.style.bottom = 'auto'
|
||||
}
|
||||
const finish = (event: PointerEvent) => {
|
||||
if (!active || active.id !== event.pointerId) return
|
||||
active.image.style.cursor = 'move'
|
||||
try {
|
||||
active.image.releasePointerCapture(event.pointerId)
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
active = null
|
||||
editor.fire('change')
|
||||
}
|
||||
doc.addEventListener('pointerdown', down)
|
||||
doc.addEventListener('pointermove', move, { passive: false })
|
||||
doc.addEventListener('pointerup', finish)
|
||||
doc.addEventListener('pointercancel', finish)
|
||||
editor.on('remove', () => {
|
||||
active = null
|
||||
doc.removeEventListener('pointerdown', down)
|
||||
doc.removeEventListener('pointermove', move)
|
||||
doc.removeEventListener('pointerup', finish)
|
||||
doc.removeEventListener('pointercancel', finish)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user