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 通过(新增路由已出现在构建产物中)。
This commit is contained in:
@@ -22,40 +22,47 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<a
|
<button
|
||||||
:href="file.url"
|
type="button"
|
||||||
target="_blank"
|
class="min-w-0 flex-1 truncate text-left text-sm text-blue-600 hover:underline disabled:opacity-60"
|
||||||
rel="noopener"
|
|
||||||
class="min-w-0 flex-1 truncate text-sm text-blue-600 hover:underline"
|
|
||||||
:title="nameOf(file)"
|
:title="nameOf(file)"
|
||||||
|
:disabled="busyIndex === idx"
|
||||||
|
@click="openFile(file, idx)"
|
||||||
>
|
>
|
||||||
{{ nameOf(file) }}
|
{{ nameOf(file) }}
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
<!--
|
<button
|
||||||
附件是第三方 OSS 直链:`download` 属性跨域不生效,直接新标签打开
|
type="button"
|
||||||
(PDF/图片由浏览器预览,其余类型浏览器自己下载)。
|
class="shrink-0 text-sm text-blue-600 hover:underline disabled:opacity-60"
|
||||||
-->
|
:disabled="busyIndex === idx"
|
||||||
<a
|
@click="openFile(file, idx)"
|
||||||
:href="file.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="shrink-0 text-sm text-blue-600 hover:underline"
|
|
||||||
>
|
>
|
||||||
下载
|
{{ busyIndex === idx ? '正在准备…' : '下载' }}
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<p v-if="errorText" class="mt-2 text-sm text-red-500">{{ errorText }}</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HjcTenderFile } from '~/types/tender'
|
import type { HjcTenderFile } from '~/types/tender'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标书附件列表(订单列表页与订单详情页共用)。
|
* 附件列表(订单列表页、订单详情页、项目详情的公告附件共用)。
|
||||||
*
|
*
|
||||||
* 只渲染后端下发的 `tenderFiles`:地址本身是付费内容,未支付订单后端不下发,
|
* 只渲染后端下发的 `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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -71,7 +78,66 @@ const files = computed<HjcTenderFile[]>(() =>
|
|||||||
(props.files || []).filter((f) => !!f && typeof f.url === 'string' && f.url.length > 0)
|
(props.files || []).filter((f) => !!f && typeof f.url === 'string' && f.url.length > 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 正在取票的下标(同一时刻只允许一个,避免连点重复取票) */
|
||||||
|
const busyIndex = ref(-1)
|
||||||
|
const errorText = ref('')
|
||||||
|
|
||||||
function nameOf(file: HjcTenderFile) {
|
function nameOf(file: HjcTenderFile) {
|
||||||
return file.name || '标书附件'
|
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>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { $fetch } from 'ofetch'
|
||||||
|
import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3'
|
||||||
|
import { useRuntimeConfig } from '#imports'
|
||||||
|
import { getTenantFromContext } from '../../utils/tenant'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取附件下载票据(一次性、短时)。
|
||||||
|
* GET /api/tender/attachment-ticket?kind=tender&orderNo=xxx&index=0
|
||||||
|
* GET /api/tender/attachment-ticket?kind=bulletin&projectNo=xxx&index=0
|
||||||
|
*
|
||||||
|
* 为什么要它:一站式的静态文件服务加了鉴权,附件不再直连,改由后端反代
|
||||||
|
* (工作区根目录 `docs/adr/0007-attachment-download-via-platform-proxy.md`)。
|
||||||
|
* 下发给前端的附件地址是**本平台的资源地址**(只有 kind/订单号或项目编号/序号,
|
||||||
|
* 没有路径、也没有票据);**点击下载时**才来这里换一张票据,再用票据去
|
||||||
|
* `{modulesApiBase}/api/hjc/attachment?ticket=…` 取文件。
|
||||||
|
*
|
||||||
|
* 为什么票据要现取而不能直接放进列表里的地址:票据很短命,"页面开着放一会儿再点"
|
||||||
|
* 会撞过期;现取则永远新鲜(ADR 0007 补记)。
|
||||||
|
*
|
||||||
|
* 约定:原样透传 ApiResult{code,message,data},HTTP 状态码恒为 200。
|
||||||
|
* `code=401`(未登录)与 `code=403`(未支付/无权/已下架)都是**业务码**,
|
||||||
|
* 前端必须按 code 判定——401 要清凭据跳登录,403 只提示。
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const ctx = getTenantFromContext(event, config)
|
||||||
|
const { kind, orderNo, projectNo, index } = getQuery(event)
|
||||||
|
const cookieToken = getCookie(event, 'hjc_token')
|
||||||
|
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch('/hjc/attachment/ticket', {
|
||||||
|
baseURL: config.public.modulesApiBase,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
TenantId: ctx.tenantId,
|
||||||
|
...(auth ? { Authorization: auth } : {})
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
TenantId: ctx.tenantId,
|
||||||
|
kind,
|
||||||
|
index,
|
||||||
|
...(orderNo ? { orderNo } : {}),
|
||||||
|
...(projectNo ? { projectNo } : {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||||
|
statusMessage: error?.statusMessage || 'Failed to fetch attachment ticket'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user