feat(hjc-web): 订单页展示并下载电子发票
发票由一站式平台开具后推给后端(官方网不开票,见工作区根目录 docs/adr/0006), 订单响应里新增 invoices;文件是本平台副本,下载走与附件同一条链路(点击时取票)。 - 抽出 composable useAttachmentDownload:把「先换票据、再取文件」的逻辑从 HjcTenderFiles 里提出来,附件与发票共用(各写一份必然分叉);同时把资源标识解析 扩到 orderNo/projectNo/invoiceId 三种。 - 新增 HjcInvoiceFiles.vue:发票号/类型/开票日期/金额 + 下载;作废(红冲)的也列出来 且仍可下载——它是留档凭证。 - HjcTenderFiles.vue 改用 composable,行为不变。 - 订单详情加发票区块,订单列表加「已开票」标记与发票列表。 - 不判"是否已支付":有没有发票取决于甲方开没开票,与订单当前状态无关。 校验:npm run build 通过。
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 「点击下载」的共用逻辑:**先换票据、再取文件**。
|
||||
*
|
||||
* 背景:一站式的静态文件服务已加鉴权,附件/发票不再直连(工作区根目录
|
||||
* `docs/adr/0007-attachment-download-via-platform-proxy.md`)。后端下发的地址是
|
||||
* **本平台的资源地址**——只有资源标识(kind + 订单号/项目编号/发票 id + 序号),
|
||||
* 既没有文件路径(路径由后端查,防越权),也没有票据。票据是**点击时**现取的
|
||||
* (ADR 0007 补记:现取的永远新鲜,不会出现"页面放久了点不动")。
|
||||
*
|
||||
* 抽出来的理由:标书附件、公告附件、电子发票三处都要走同一条链路,各写一份必然分叉
|
||||
* ——与 `orderStatus.ts`、`hjcCanActOnOrder` 同一动机。
|
||||
*/
|
||||
export function useAttachmentDownload() {
|
||||
/** 正在准备的文件标识(用于禁用按钮、显示"正在准备…"),同一时刻只允许一个 */
|
||||
const busyKey = ref('')
|
||||
const errorText = ref('')
|
||||
|
||||
/**
|
||||
* 从资源地址里取出取票所需的标识。
|
||||
*
|
||||
* 地址形态由后端固定生成,只有这几个参数;解析不出来就说明地址不是本平台的资源地址
|
||||
* (例如历史数据里的 OSS 直链),那种直接按普通链接打开即可。
|
||||
*/
|
||||
function resourceQueryOf(url: string): Record<string, string> | null {
|
||||
const query = String(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 target: Record<string, string> = { kind, index }
|
||||
for (const key of ['orderNo', 'projectNo', 'invoiceId']) {
|
||||
const value = params.get(key)
|
||||
if (value) target[key] = value
|
||||
}
|
||||
// 至少要有一个资源标识,否则后端也无从查起
|
||||
return target.orderNo || target.projectNo || target.invoiceId ? target : null
|
||||
}
|
||||
|
||||
/** 票据地址是相对后端的(`/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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取票并打开文件。
|
||||
*
|
||||
* @param file 后端下发的 `{name, url}`(url 是资源地址)
|
||||
* @param key 用于标识"哪一个正在准备"(同一列表里区分按钮)
|
||||
*/
|
||||
async function download(file: { name?: string; url?: string }, key?: string) {
|
||||
errorText.value = ''
|
||||
const url = String(file?.url || '')
|
||||
const query = resourceQueryOf(url)
|
||||
|
||||
// 不是本平台的资源地址(历史 OSS 直链):原样打开,没必要绕一圈
|
||||
if (!query) {
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.location.href = url
|
||||
return
|
||||
}
|
||||
errorText.value = '文件地址不可用,请刷新页面后重试'
|
||||
return
|
||||
}
|
||||
|
||||
const busy = key || url
|
||||
busyKey.value = busy
|
||||
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 {
|
||||
busyKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return { busyKey, errorText, download }
|
||||
}
|
||||
Reference in New Issue
Block a user