vue H5 模版下载

0 阅读3分钟

H5 模板下载功能技术文档

1. 功能概述

项目基本信息导入模板下载功能,支持在 钉钉 H5 微应用普通浏览器 环境下,将后端提供的 Excel 模板文件下载到用户设备。

  • 模板文件项目基本信息导入模板.xlsx
  • 后端接口GET /xxx/get-import-template(免鉴权)
  • 核心挑战:钉钉环境对文件下载有严格的安全策略限制

2. 调用链路总览

用户点击「下载模板」
    ↓
BasicInfoSection.vue::handleDownloadTemplate()
    ↓
api.downloadImportTemplate()  [src/http/api/upload.ts]
    ↓
┌─────────────────────────────────────────────────────────┐
│  环境判断                                                │
│  ├── 钉钉 H5initDingTalkJsApi() → 按平台分发        │
│  │              ├── PC 钉钉  → dd.biz.util.openLink()   │
│  │              └── 移动钉钉 → saveTemplateToDingPan()   │
│  │                              → dd.biz.cspace.saveFile│
│  │                              → dd.biz.cspace.preview │
│  ├── 普通浏览器 → fetchbloba[download]            │
│  └── 非 H5(小程序/App)→ uni.downloadFileuni.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
加签 URLlocation.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无法携带请求头,因此后端接口必须免鉴权
  • previewmode 参数故意不传:它是可选枚举,不同钉钉版本差异大,传了反而可能被入参校验拒绝
  • 用户取消保存时,spaceId/fileId 为空,Toast 提示后静默返回

4. 环境适配矩阵

环境判断条件下载方式备注
钉钉 PCisInDingTalk() && dd.env.platform === 'pc'dd.biz.util.openLink()调起系统浏览器下载
钉钉移动isInDingTalk() && dd.env.platform !== 'pc'dd.biz.cspace.saveFile()preview()转存钉盘 + 自动预览
普通浏览器!isInDingTalk()fetchbloba[download]标准 H5 下载
小程序/App非 H5 条件编译uni.downloadFileuni.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 秒超时兜底
用户取消用户在钉盘保存界面点取消时,返回结果为空,需静默处理
预览参数previewmode 参数在不同钉钉版本表现不一致,建议不传

6. 配置依赖

6.1 环境变量

变量用途示例
VITE_BASE_URLAPI 基础地址http://58.240.98.58:5000(测试)/ https://www.tongtuzhengxin.com(生产)
VITE_API_ROUTEAPI 路由前缀/admin-api/
VITE_APP_DINGTALK_CORP_ID钉钉企业 IDding89dd23cd60dac66eacaaa37764f94726
VITE_APP_TENANT_ID租户 ID1

6.2 后端接口

接口方法说明
/project/basic-info/get-import-templateGET下载模板,必须免鉴权
/project/integration/dingtalk/jsapi-signatureGET获取钉钉 JSAPI 签名

7. 文件清单

文件职责
src/pages/initiate/components/BasicInfoSection.vue页面入口,处理点击事件与 loading 状态
src/http/api/upload.tsAPI 层:环境判断、钉钉鉴权、钉盘转存、浏览器下载
src/utils/dingding.ts钉钉工具函数:isInDingTalk()getCorpId()
src/config/env.ts环境配置:API 地址、路由前缀
src/http/config.tsHTTP 基础配置导出

8. 后续优化建议

  1. URL 安全校验:对 saveFile 的 URL 做白名单校验,非 HTTPS 域名时降级为 openLink
  2. 代理下载:后端提供文件代理接口,将内网文件通过 HTTPS 域名暴露,解决 IP 限制
  3. 错误码细化:区分「用户取消」「网络失败」「钉钉拦截」等场景,给用户更明确的提示
  4. 下载进度:大文件时增加进度提示,当前实现无进度反馈