Files
hjc-web/app/components/HjcTenderFiles.vue
T
weicw1996 6c40165f86 feat(hjc-web): 附件下载改为「点击时取票再下载」
一站式的静态文件服务已加鉴权,附件不再直连(工作区根目录 docs/adr/0007):
后端下发的地址现在是**本平台资源地址**(kind + 订单号/项目编号 + 序号),
既没有文件路径(路径由后端查,防越权)也没有票据。

- 新增 Nitro 透传 server/api/tender/attachment-ticket.get.ts:带 Authorization
  (请求头或 hjc_token cookie)+ TenantId 调后端取票,原样透传 ApiResult。
- HjcTenderFiles.vue:`<a :href>` → 按钮 + 点击时取票 + `window.location.href`。
  用 location 而不是 window.open:取票是异步的,异步之后弹窗会被浏览器拦截;
  附件响应带 Content-Disposition: attachment,下载完仍留在本页。
  该组件同时被订单列表/订单详情/项目详情(公告附件)复用,一处改动覆盖三处。
- 错误提示直接复用后端 401/403 约定,组件内联展示,不引入新的 toast 依赖。

校验:npm run build 通过(新增路由已出现在构建产物中)。
2026-09-18 17:09:52 +08:00

144 lines
5.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<ul v-if="files.length" class="space-y-2">
<li
v-for="(file, idx) in files"
:key="file.url + idx"
class="flex items-center gap-3 rounded-lg border border-gray-200 px-4 py-2.5"
>
<!-- 用内联 SVG避免为一个小图标引入图标库依赖 PageAttachments.vue -->
<span class="shrink-0 text-blue-600" aria-hidden="true">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
</span>
<button
type="button"
class="min-w-0 flex-1 truncate text-left text-sm text-blue-600 hover:underline disabled:opacity-60"
:title="nameOf(file)"
:disabled="busyIndex === idx"
@click="openFile(file, idx)"
>
{{ nameOf(file) }}
</button>
<button
type="button"
class="shrink-0 text-sm text-blue-600 hover:underline disabled:opacity-60"
:disabled="busyIndex === idx"
@click="openFile(file, idx)"
>
{{ busyIndex === idx ? '正在准备…' : '下载' }}
</button>
</li>
</ul>
<p v-if="errorText" class="mt-2 text-sm text-red-500">{{ errorText }}</p>
</template>
<script setup lang="ts">
import type { HjcTenderFile } from '~/types/tender'
/**
* 附件列表(订单列表页、订单详情页、项目详情的公告附件共用)。
*
* 只渲染后端下发的 `tenderFiles` / `bulletinFiles`**发不发由后端定**
* (标书附件仅 `payStatus === 1` 的订单下发、公告附件随项目上下架),
* 所以这里不做任何权限判断,也不从项目字段里自己拼地址。
*
* **下载为什么要绕一圈**:一站式的静态文件服务已加鉴权,附件不再直连
* (工作区根目录 `docs/adr/0007-attachment-download-via-platform-proxy.md`)。
* 下发的地址是**本平台资源地址**,形如
* `/api/hjc/attachment?kind=tender&orderNo=xxx&index=0` —— 只有资源标识,
* 既没有文件路径(路径由后端查,防越权),也没有票据。**点击时**才用这里的
* 标识去换一张一次性短时票据,再拿票据去后端取文件(ADR 0007 补记:现取的
* 票据永远新鲜,不会出现"页面放久了点不动")。
*/
const props = withDefaults(
defineProps<{
files?: HjcTenderFile[] | null
}>(),
{
files: () => []
}
)
/** 过滤掉没有地址的脏数据(后端已保证,前端再兜一层,避免渲染出死链) */
const files = computed<HjcTenderFile[]>(() =>
(props.files || []).filter((f) => !!f && typeof f.url === 'string' && f.url.length > 0)
)
/** 正在取票的下标(同一时刻只允许一个,避免连点重复取票) */
const busyIndex = ref(-1)
const errorText = ref('')
function nameOf(file: HjcTenderFile) {
return file.name || '标书附件'
}
/**
* 从资源地址里取出取票所需的标识。
*
* 地址形态由后端 `HjcAttachmentResolver` 固定生成,只有这几个参数:
* `kind`tender/bulletin+ `orderNo` 或 `projectNo` + `index`。
*/
function ticketQueryOf(url: string) {
const query = url.split('?')[1]
if (!query) return null
const params = new URLSearchParams(query)
const kind = params.get('kind')
const index = params.get('index')
if (!kind || index === null) return null
const orderNo = params.get('orderNo')
const projectNo = params.get('projectNo')
if (!orderNo && !projectNo) return null
return {
kind,
index,
...(orderNo ? { orderNo } : { projectNo: projectNo as string })
}
}
/** 票据地址是相对后端的(`/api/hjc/attachment?ticket=…`),补上后端域名 */
function absoluteApiUrl(url: string) {
if (/^https?:\/\//i.test(url)) return url
const base = String(useRuntimeConfig().public.modulesApiBase || '').replace(/\/api\/?$/, '')
return base + (url.startsWith('/') ? url : `/${url}`)
}
async function openFile(file: HjcTenderFile, idx: number) {
errorText.value = ''
const query = ticketQueryOf(String(file.url || ''))
if (!query) {
errorText.value = '附件地址不可用,请刷新页面后重试'
return
}
busyIndex.value = idx
try {
const res: any = await $fetch('/api/tender/attachment-ticket', { query })
if (!res || res.code !== 0 || !res.data?.url) {
errorText.value = res?.message || '获取下载链接失败,请稍后重试'
return
}
// 用 location 而不是 window.open:取票是异步的,异步之后再弹窗会被浏览器拦截;
// 而附件响应带 Content-Disposition: attachment,浏览器下载完仍留在本页。
window.location.href = absoluteApiUrl(String(res.data.url))
} catch (error: any) {
errorText.value =
error?.data?.message || error?.statusMessage || error?.message || '下载失败,请稍后重试'
} finally {
busyIndex.value = -1
}
}
</script>