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
+84
View File
@@ -0,0 +1,84 @@
<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"
>
<span class="shrink-0" :class="file.status === 'CANCELLED' ? 'text-gray-400' : '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="M4 3h16v18l-8-4-8 4z" />
</svg>
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-gray-900">
发票号 {{ file.invoiceNo || '-' }}
<span v-if="file.status === 'CANCELLED'" class="ml-2 text-xs text-gray-500">已作废</span>
</p>
<p class="mt-0.5 truncate text-xs text-gray-500">
{{ typeLabel(file.invoiceType) }}
<template v-if="file.invoiceDate"> · {{ file.invoiceDate }}</template>
<template v-if="file.amount !== undefined && file.amount !== null">
· ¥{{ Number(file.amount).toFixed(2) }}
</template>
</p>
</div>
<button
type="button"
class="shrink-0 text-sm text-blue-600 hover:underline disabled:opacity-60"
:disabled="busyKey === file.url"
@click="download(file, file.url)"
>
{{ busyKey === file.url ? '正在准备…' : '下载' }}
</button>
</li>
</ul>
<p v-if="errorText" class="mt-2 text-sm text-red-500">{{ errorText }}</p>
</template>
<script setup lang="ts">
import type { HjcInvoiceFile } from '~/types/tender'
/**
* 电子发票列表(订单详情页用)。
*
* 发票是**一站式平台开具后推给我们的**(官方网不开票,见工作区根目录
* `docs/adr/0006-invoice-issued-by-one-stop-platform.md`),文件留的是**本平台副本**,
* 所以下载链路与标书附件一致:点击时先换一次性票据(`useAttachmentDownload`)。
*
* 作废(红冲)的发票**照样列出来**且仍可下载:它是留档凭证,用户与客服都要能看到原件。
*/
const props = withDefaults(
defineProps<{
files?: HjcInvoiceFile[] | null
}>(),
{
files: () => []
}
)
const files = computed<HjcInvoiceFile[]>(() =>
(props.files || []).filter((f) => !!f && typeof f.url === 'string' && f.url.length > 0)
)
const { busyKey, errorText, download } = useAttachmentDownload()
/** 发票类型:后端给的是 VAT_NORMAL / VAT_SPECIAL,界面上说人话 */
function typeLabel(type?: string) {
if (type === 'VAT_SPECIAL') return '电子专用发票'
if (type === 'VAT_NORMAL') return '电子普通发票'
return '电子发票'
}
</script>
+8 -70
View File
@@ -26,8 +26,8 @@
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)"
:disabled="busyKey === file.url"
@click="download(file, file.url)"
>
{{ nameOf(file) }}
</button>
@@ -35,10 +35,10 @@
<button
type="button"
class="shrink-0 text-sm text-blue-600 hover:underline disabled:opacity-60"
:disabled="busyIndex === idx"
@click="openFile(file, idx)"
:disabled="busyKey === file.url"
@click="download(file, file.url)"
>
{{ busyIndex === idx ? '正在准备…' : '下载' }}
{{ busyKey === file.url ? '正在准备…' : '下载' }}
</button>
</li>
</ul>
@@ -56,13 +56,8 @@ import type { HjcTenderFile } from '~/types/tender'
* (标书附件仅 `payStatus === 1` 的订单下发、公告附件随项目上下架),
* 所以这里不做任何权限判断,也不从项目字段里自己拼地址。
*
* **下载为什么要绕一圈**:一站式的静态文件服务已加鉴权,附件不再直连
* 工作区根目录 `docs/adr/0007-attachment-download-via-platform-proxy.md`)。
* 下发的地址是**本平台资源地址**,形如
* `/api/hjc/attachment?kind=tender&orderNo=xxx&index=0` —— 只有资源标识,
* 既没有文件路径(路径由后端查,防越权),也没有票据。**点击时**才用这里的
* 标识去换一张一次性短时票据,再拿票据去后端取文件(ADR 0007 补记:现取的
* 票据永远新鲜,不会出现"页面放久了点不动")。
* 「点击下载」走 `useAttachmentDownload`:先换一张一次性票据、再取文件
* 为什么要绕这一圈见该 composable 的注释)。
*/
const props = withDefaults(
defineProps<{
@@ -78,66 +73,9 @@ const files = computed<HjcTenderFile[]>(() =>
(props.files || []).filter((f) => !!f && typeof f.url === 'string' && f.url.length > 0)
)
/** 正在取票的下标(同一时刻只允许一个,避免连点重复取票) */
const busyIndex = ref(-1)
const errorText = ref('')
const { busyKey, errorText, download } = useAttachmentDownload()
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>