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,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>
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="min-w-0 flex-1 truncate text-left text-sm text-blue-600 hover:underline disabled:opacity-60"
|
class="min-w-0 flex-1 truncate text-left text-sm text-blue-600 hover:underline disabled:opacity-60"
|
||||||
:title="nameOf(file)"
|
:title="nameOf(file)"
|
||||||
:disabled="busyIndex === idx"
|
:disabled="busyKey === file.url"
|
||||||
@click="openFile(file, idx)"
|
@click="download(file, file.url)"
|
||||||
>
|
>
|
||||||
{{ nameOf(file) }}
|
{{ nameOf(file) }}
|
||||||
</button>
|
</button>
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 text-sm text-blue-600 hover:underline disabled:opacity-60"
|
class="shrink-0 text-sm text-blue-600 hover:underline disabled:opacity-60"
|
||||||
:disabled="busyIndex === idx"
|
:disabled="busyKey === file.url"
|
||||||
@click="openFile(file, idx)"
|
@click="download(file, file.url)"
|
||||||
>
|
>
|
||||||
{{ busyIndex === idx ? '正在准备…' : '下载' }}
|
{{ busyKey === file.url ? '正在准备…' : '下载' }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -56,13 +56,8 @@ import type { HjcTenderFile } from '~/types/tender'
|
|||||||
* (标书附件仅 `payStatus === 1` 的订单下发、公告附件随项目上下架),
|
* (标书附件仅 `payStatus === 1` 的订单下发、公告附件随项目上下架),
|
||||||
* 所以这里不做任何权限判断,也不从项目字段里自己拼地址。
|
* 所以这里不做任何权限判断,也不从项目字段里自己拼地址。
|
||||||
*
|
*
|
||||||
* **下载为什么要绕一圈**:一站式的静态文件服务已加鉴权,附件不再直连
|
* 「点击下载」走 `useAttachmentDownload`:先换一张一次性票据、再取文件
|
||||||
* (工作区根目录 `docs/adr/0007-attachment-download-via-platform-proxy.md`)。
|
* (为什么要绕这一圈见该 composable 的注释)。
|
||||||
* 下发的地址是**本平台资源地址**,形如
|
|
||||||
* `/api/hjc/attachment?kind=tender&orderNo=xxx&index=0` —— 只有资源标识,
|
|
||||||
* 既没有文件路径(路径由后端查,防越权),也没有票据。**点击时**才用这里的
|
|
||||||
* 标识去换一张一次性短时票据,再拿票据去后端取文件(ADR 0007 补记:现取的
|
|
||||||
* 票据永远新鲜,不会出现"页面放久了点不动")。
|
|
||||||
*/
|
*/
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -78,66 +73,9 @@ 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 { busyKey, errorText, download } = useAttachmentDownload()
|
||||||
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,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 }
|
||||||
|
}
|
||||||
@@ -56,6 +56,16 @@
|
|||||||
<p class="text-sm text-gray-500">支付成功后可在此下载标书附件。</p>
|
<p class="text-sm text-gray-500">支付成功后可在此下载标书附件。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
电子发票:**有才有**——甲方开票后推给平台,平台留副本再下发(ADR 0006)。
|
||||||
|
所以这里不判"是否已支付":没开票就是没有这条记录,跟付款状态无关;
|
||||||
|
作废(红冲)的也列出来且仍可下载(留档凭证)。
|
||||||
|
-->
|
||||||
|
<div v-if="order.invoices?.length" class="mt-4 rounded-lg border border-gray-200 p-5">
|
||||||
|
<h2 class="mb-3 font-bold text-gray-900">电子发票</h2>
|
||||||
|
<HjcInvoiceFiles :files="order.invoices" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 待支付订单的操作区(与列表页同一判定:hjcCanActOnOrder) -->
|
<!-- 待支付订单的操作区(与列表页同一判定:hjcCanActOnOrder) -->
|
||||||
<div v-if="hjcCanActOnOrder(order)" class="mt-4 flex justify-end gap-3 rounded-lg border border-gray-200 p-5">
|
<div v-if="hjcCanActOnOrder(order)" class="mt-4 flex justify-end gap-3 rounded-lg border border-gray-200 p-5">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -22,7 +22,10 @@
|
|||||||
<p class="font-semibold text-gray-900">{{ o.projectName }}</p>
|
<p class="font-semibold text-gray-900">{{ o.projectName }}</p>
|
||||||
<p class="mt-1 text-sm text-gray-500">订单号:{{ o.orderNo }} · 项目编号:{{ o.projectNo }}</p>
|
<p class="mt-1 text-sm text-gray-500">订单号:{{ o.orderNo }} · 项目编号:{{ o.projectNo }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="rounded px-2 py-0.5 text-xs" :class="hjcOrderStatusClass(o)">{{ hjcOrderStatusLabel(o) }}</span>
|
<span class="flex shrink-0 items-center gap-2">
|
||||||
|
<span v-if="o.invoices?.length" class="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-600">已开票</span>
|
||||||
|
<span class="rounded px-2 py-0.5 text-xs" :class="hjcOrderStatusClass(o)">{{ hjcOrderStatusLabel(o) }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex items-center justify-between text-sm">
|
<div class="mt-4 flex items-center justify-between text-sm">
|
||||||
<span class="text-gray-500">{{ fmtTime(o.createTime) }}</span>
|
<span class="text-gray-500">{{ fmtTime(o.createTime) }}</span>
|
||||||
@@ -39,6 +42,12 @@
|
|||||||
<HjcTenderFiles :files="o.tenderFiles" />
|
<HjcTenderFiles :files="o.tenderFiles" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 电子发票:有才有(甲方开票后推送过来),作废的也列出来 -->
|
||||||
|
<div v-if="o.invoices?.length" class="mt-4 border-t border-gray-100 pt-4">
|
||||||
|
<p class="mb-2 text-sm font-medium text-gray-700">电子发票</p>
|
||||||
|
<HjcInvoiceFiles :files="o.invoices" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between border-t border-gray-100 pt-4">
|
<div class="mt-4 flex items-center justify-between border-t border-gray-100 pt-4">
|
||||||
<!--
|
<!--
|
||||||
详情入口。**不**把整张卡片做成可点:卡片里已经有「取消订单」「去支付」
|
详情入口。**不**把整张卡片做成可点:卡片里已经有「取消订单」「去支付」
|
||||||
|
|||||||
@@ -136,6 +136,34 @@ export interface HjcOrder {
|
|||||||
createTime?: string
|
createTime?: string
|
||||||
/** 仅已支付订单由后端下发 */
|
/** 仅已支付订单由后端下发 */
|
||||||
tenderFiles?: HjcTenderFile[] | null
|
tenderFiles?: HjcTenderFile[] | null
|
||||||
|
/**
|
||||||
|
* 电子发票:**有才有**(甲方开票后推送过来才会有这条记录)。
|
||||||
|
* 作废(红冲)的也在列表里(仍可下载原件),靠 `status` 区分。
|
||||||
|
*/
|
||||||
|
invoices?: HjcInvoiceFile[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电子发票(对应后端 `HjcInvoiceVo`)。
|
||||||
|
*
|
||||||
|
* 由**一站式平台开具后推给我们的**(官方网不开票,见 ADR 0006);文件留的是本平台副本,
|
||||||
|
* 下载地址同样是资源地址——点击时先换票据(见 `useAttachmentDownload`)。
|
||||||
|
*/
|
||||||
|
export interface HjcInvoiceFile {
|
||||||
|
/** 发票号码 */
|
||||||
|
invoiceNo?: string
|
||||||
|
/** VAT_NORMAL 电子普票 / VAT_SPECIAL 电子专票 */
|
||||||
|
invoiceType?: string
|
||||||
|
/** 开票日期 yyyy-MM-dd */
|
||||||
|
invoiceDate?: string
|
||||||
|
/** 发票金额(含税) */
|
||||||
|
amount?: number
|
||||||
|
/** ISSUED 已开票 / CANCELLED 已作废(红冲) */
|
||||||
|
status?: string
|
||||||
|
/** 展示名(下载时用作文件名) */
|
||||||
|
name?: string
|
||||||
|
/** 可下载地址(本平台资源地址,点击时先换票据) */
|
||||||
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录用户(对应后端 common.system.User 关键字段) */
|
/** 登录用户(对应后端 common.system.User 关键字段) */
|
||||||
|
|||||||
Reference in New Issue
Block a user