H5 模板下载功能技术文档
1. 功能概述
项目基本信息导入模板下载功能,支持在 钉钉 H5 微应用 和 普通浏览器 环境下,将后端提供的 Excel 模板文件下载到用户设备。
- 模板文件:
项目基本信息导入模板.xlsx - 后端接口:
GET /xxx/get-import-template(免鉴权) - 核心挑战:钉钉环境对文件下载有严格的安全策略限制
2. 调用链路总览
用户点击「下载模板」
↓
BasicInfoSection.vue::handleDownloadTemplate()
↓
api.downloadImportTemplate() [src/http/api/upload.ts]
↓
┌─────────────────────────────────────────────────────────┐
│ 环境判断 │
│ ├── 钉钉 H5 → initDingTalkJsApi() → 按平台分发 │
│ │ ├── PC 钉钉 → dd.biz.util.openLink() │
│ │ └── 移动钉钉 → saveTemplateToDingPan() │
│ │ → dd.biz.cspace.saveFile│
│ │ → dd.biz.cspace.preview │
│ ├── 普通浏览器 → fetch → blob → a[download] │
│ └── 非 H5(小程序/App)→ uni.downloadFile → uni.openDocument │
└─────────────────────────────────────────────────────────┘
3. 核心模块拆解
3.1 入口:页面组件
文件:src/pages/BasicInfoSection.vue
const handleDownloadTemplate = async () => {
if (downloadingTemplate.value) return
downloadingTemplate.value = true
try {
await api.downloadImportTemplate()
} catch (err: any) {
uni.showToast({ title: err?.message || '模板下载失败', icon: 'none' })
} finally {
downloadingTemplate.value = false
}
}
- 通过
downloadingTemplate防止重复点击 - 统一捕获异常并 Toast 提示
3.2 API 层:downloadImportTemplate
文件:src/http/api/upload.ts
downloadImportTemplate: async (): Promise<void> => {
const url = `${base_url || ''}${apiRoute}project/get-import-template`
// #ifdef H5
if (isInDingTalk()) {
await initDingTalkJsApi(DD_DOWNLOAD_JS_APIS)
if (dd.env.platform === 'pc') {
await dd.biz.util.openLink({ url })
} else {
await saveTemplateToDingPan(url)
}
return
}
// 普通浏览器:fetch → blob → a[download]
const res = await fetch(url)
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = IMPORT_TEMPLATE_FILE_NAME
a.click()
// ...
// #endif
// #ifndef H5
// 小程序/App:uni.downloadFile → uni.openDocument
// #endif
}
关键设计:
- 使用
// #ifdef H5/// #ifndef H5条件编译,区分平台 - 钉钉环境必须先完成 JS-SDK 鉴权,再调用对应 API
3.3 钉钉 JS-SDK 鉴权:initDingTalkJsApi
文件:src/http/api/upload.ts
async function initDingTalkJsApi(jsApiList: string[]): Promise<void> {
if (dingTalkAuthPromise) return dingTalkAuthPromise // Promise 缓存,避免重复 config
const signUrl = location.href.split('#')[0] // 加签 URL 必须与后端一致:去 hash、留 query
dingTalkAuthPromise = (async () => {
// 1. 请求后端获取签名
const res = await fetchDingTalkJsapiSignature(signUrl)
const sign = res?.data ?? res ?? {}
// 2. 组装 dd.config 参数
const configParams = {
agentId: sign.agentId || sign.agentID || '',
corpId: sign.corpId || sign.corpid || getCorpId(),
timeStamp: String(sign.timeStamp || sign.timestamp || ''),
nonceStr: String(sign.nonceStr || sign.noncestr || ''),
signature: sign.signature || sign.sign || '',
jsApiList, // 必须声明要用到的 API,否则判为未鉴权
}
// 3. 参数完整性校验
const missing = Object.keys(configParams).filter(
(key) => key !== 'jsApiList' && !configParams[key]
)
if (missing.length) throw new Error(`签名参数缺失:${missing.join('、')}`)
// 4. dd.config + dd.ready / dd.error
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('初始化超时')), 10000)
dd.config(configParams)
dd.ready(() => { clearTimeout(timer); resolve() })
dd.error((err) => { clearTimeout(timer); reject(err) })
})
})()
return dingTalkAuthPromise
}
关键设计:
| 设计点 | 说明 |
|---|---|
| Promise 缓存 | dingTalkAuthPromise 全局缓存,避免同一页面重复 dd.config |
| 加签 URL | location.href.split('#')[0],去掉 hash、保留 query,与后端签名算法严格一致 |
| 参数回退 | corpId 后端未返回时,回退到环境变量 VITE_APP_DINGTALK_CORP_ID |
| 超时兜底 | 10 秒超时,解决 iOS 上 ready/error 都不回调的兼容性问题 |
| 错误重置 | 失败时置空 dingTalkAuthPromise,允许下次重试 |
必须声明的 JSAPI:
const DD_DOWNLOAD_JS_APIS = [
'biz.cspace.saveFile', // 转存钉盘
'biz.cspace.preview', // 钉盘预览
'biz.util.openLink' // 打开链接(PC 降级)
]
3.4 移动端核心:saveTemplateToDingPan
文件:src/http/api/upload.ts
async function saveTemplateToDingPan(downloadUrl: string): Promise<void> {
const corpId = getCorpId()
// 调用钉钉 JSAPI 转存到钉盘
const res = await dd.biz.cspace.saveFile({
corpId,
url: downloadUrl,
name: IMPORT_TEMPLATE_FILE_NAME,
})
// 兼容两种返回结构:{ data: [{...}] } 或平铺
const saved = (Array.isArray(res?.data) ? res.data[0] : undefined) || res
if (!saved?.spaceId || !saved?.fileId) {
uni.showToast({ title: '已取消保存到钉盘', icon: 'none' })
return
}
// 保存成功后自动预览
try {
await dd.biz.cspace.preview({
corpId,
spaceId: String(saved.spaceId),
fileId: String(saved.fileId),
fileName: saved.fileName || IMPORT_TEMPLATE_FILE_NAME,
fileSize: Number(saved.fileSize) || 0,
fileType: 'xlsx',
})
} catch (err) {
uni.showToast({ title: '已保存到钉盘,请在钉盘中查看', icon: 'none' })
}
}
关键设计:
saveFile入参只有corpId/url/name,无法携带请求头,因此后端接口必须免鉴权preview的mode参数故意不传:它是可选枚举,不同钉钉版本差异大,传了反而可能被入参校验拒绝- 用户取消保存时,
spaceId/fileId为空,Toast 提示后静默返回
4. 环境适配矩阵
| 环境 | 判断条件 | 下载方式 | 备注 |
|---|---|---|---|
| 钉钉 PC | isInDingTalk() && dd.env.platform === 'pc' | dd.biz.util.openLink() | 调起系统浏览器下载 |
| 钉钉移动 | isInDingTalk() && dd.env.platform !== 'pc' | dd.biz.cspace.saveFile() → preview() | 转存钉盘 + 自动预览 |
| 普通浏览器 | !isInDingTalk() | fetch → blob → a[download] | 标准 H5 下载 |
| 小程序/App | 非 H5 条件编译 | uni.downloadFile → uni.openDocument | 使用 uni-app 原生 API |
5. 已知问题与限制
5.1 钉钉 saveFile 的 URL 安全策略
现象:dd.biz.cspace.saveFile 报 "参数错误",但入参 corpId / url / name 格式完全正确。
根因:钉钉客户端对 url 有安全校验,以下情况会被拦截:
- 使用 HTTP 而非 HTTPS
- 使用 裸 IP 地址(如
http://58.240.98.58:28081/...) - 使用 非标准端口(如
:28081)
对比:
| URL | 结果 |
|---|---|
http://58.240.98.58:28081/admin-api/... | ❌ 参数错误 |
https://disk.sample.cat/samples/xlsx/sample2.xlsx | ✅ 正常 |
当前代码状态:未做 URL 白名单校验,测试环境(IP + HTTP)会触发此问题。
建议修复方案:
function isDingPanSaveableUrl(url: string): boolean {
try {
const u = new URL(url)
if (u.protocol !== 'https:') return false
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(u.hostname)) return false
if (u.hostname === 'localhost') return false
return true
} catch {
return false
}
}
// 在 saveTemplateToDingPan 中:
if (!isDingPanSaveableUrl(downloadUrl)) {
await dd.biz.util.openLink({ url: downloadUrl }) // 降级处理
return
}
5.2 其他限制
| 限制 | 说明 |
|---|---|
| 免鉴权要求 | saveFile 无法携带请求头,后端接口必须允许匿名访问 |
| iOS 兼容性 | dd.ready / dd.error 可能都不回调,已用 10 秒超时兜底 |
| 用户取消 | 用户在钉盘保存界面点取消时,返回结果为空,需静默处理 |
| 预览参数 | preview 的 mode 参数在不同钉钉版本表现不一致,建议不传 |
6. 配置依赖
6.1 环境变量
| 变量 | 用途 | 示例 |
|---|---|---|
VITE_BASE_URL | API 基础地址 | http://58.240.98.58:5000(测试)/ https://www.tongtuzhengxin.com(生产) |
VITE_API_ROUTE | API 路由前缀 | /admin-api/ |
VITE_APP_DINGTALK_CORP_ID | 钉钉企业 ID | ding89dd23cd60dac66eacaaa37764f94726 |
VITE_APP_TENANT_ID | 租户 ID | 1 |
6.2 后端接口
| 接口 | 方法 | 说明 |
|---|---|---|
/project/basic-info/get-import-template | GET | 下载模板,必须免鉴权 |
/project/integration/dingtalk/jsapi-signature | GET | 获取钉钉 JSAPI 签名 |
7. 文件清单
| 文件 | 职责 |
|---|---|
src/pages/initiate/components/BasicInfoSection.vue | 页面入口,处理点击事件与 loading 状态 |
src/http/api/upload.ts | API 层:环境判断、钉钉鉴权、钉盘转存、浏览器下载 |
src/utils/dingding.ts | 钉钉工具函数:isInDingTalk()、getCorpId() |
src/config/env.ts | 环境配置:API 地址、路由前缀 |
src/http/config.ts | HTTP 基础配置导出 |
8. 后续优化建议
- URL 安全校验:对
saveFile的 URL 做白名单校验,非 HTTPS 域名时降级为openLink - 代理下载:后端提供文件代理接口,将内网文件通过 HTTPS 域名暴露,解决 IP 限制
- 错误码细化:区分「用户取消」「网络失败」「钉钉拦截」等场景,给用户更明确的提示
- 下载进度:大文件时增加进度提示,当前实现无进度反馈