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:
2026-09-18 17:33:01 +08:00
parent 6c40165f86
commit 1ff4dfffb9
6 changed files with 230 additions and 71 deletions
+90
View File
@@ -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 }
}