feat(contract): 接入移动端合同打印与小程序文档交付以支持导出分享。
This commit is contained in:
1 parent
0cd120ef5e
commit
13630045df
16 files changed
+3666
-4
No files matched your search
@@ -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},请检查网络或稍后重试`
|
||||
}
|
||||
Reference in new issue
Block a user