From 1ff4dfffb9dd152fbecc468acb9ca2debfea1ad6 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Fri, 18 Sep 2026 17:33:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(hjc-web):=20=E8=AE=A2=E5=8D=95=E9=A1=B5?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=B9=B6=E4=B8=8B=E8=BD=BD=E7=94=B5=E5=AD=90?= =?UTF-8?q?=E5=8F=91=E7=A5=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发票由一站式平台开具后推给后端(官方网不开票,见工作区根目录 docs/adr/0006), 订单响应里新增 invoices;文件是本平台副本,下载走与附件同一条链路(点击时取票)。 - 抽出 composable useAttachmentDownload:把「先换票据、再取文件」的逻辑从 HjcTenderFiles 里提出来,附件与发票共用(各写一份必然分叉);同时把资源标识解析 扩到 orderNo/projectNo/invoiceId 三种。 - 新增 HjcInvoiceFiles.vue:发票号/类型/开票日期/金额 + 下载;作废(红冲)的也列出来 且仍可下载——它是留档凭证。 - HjcTenderFiles.vue 改用 composable,行为不变。 - 订单详情加发票区块,订单列表加「已开票」标记与发票列表。 - 不判"是否已支付":有没有发票取决于甲方开没开票,与订单当前状态无关。 校验:npm run build 通过。 --- app/components/HjcInvoiceFiles.vue | 84 ++++++++++++++++++++++ app/components/HjcTenderFiles.vue | 78 +++----------------- app/composables/useAttachmentDownload.ts | 90 ++++++++++++++++++++++++ app/pages/tender/orders/[id].vue | 10 +++ app/pages/tender/orders/index.vue | 11 ++- app/types/tender.ts | 28 ++++++++ 6 files changed, 230 insertions(+), 71 deletions(-) create mode 100644 app/components/HjcInvoiceFiles.vue create mode 100644 app/composables/useAttachmentDownload.ts diff --git a/app/components/HjcInvoiceFiles.vue b/app/components/HjcInvoiceFiles.vue new file mode 100644 index 0000000..cfbc618 --- /dev/null +++ b/app/components/HjcInvoiceFiles.vue @@ -0,0 +1,84 @@ + + + diff --git a/app/components/HjcTenderFiles.vue b/app/components/HjcTenderFiles.vue index 6713c9c..51a644f 100644 --- a/app/components/HjcTenderFiles.vue +++ b/app/components/HjcTenderFiles.vue @@ -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) }} @@ -35,10 +35,10 @@ @@ -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(() => (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 - } -} diff --git a/app/composables/useAttachmentDownload.ts b/app/composables/useAttachmentDownload.ts new file mode 100644 index 0000000..5700bd6 --- /dev/null +++ b/app/composables/useAttachmentDownload.ts @@ -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 | 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 = { 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 } +} diff --git a/app/pages/tender/orders/[id].vue b/app/pages/tender/orders/[id].vue index 465dfc0..74a7730 100644 --- a/app/pages/tender/orders/[id].vue +++ b/app/pages/tender/orders/[id].vue @@ -56,6 +56,16 @@

支付成功后可在此下载标书附件。

+ +
+

电子发票

+ +
+
- {{ hjcOrderStatusLabel(o) }} + + 已开票 + {{ hjcOrderStatusLabel(o) }} +
{{ fmtTime(o.createTime) }} @@ -39,6 +42,12 @@
+ +
+

电子发票

+ +
+