Files
weicw1996 1ff4dfffb9 feat(hjc-web): 订单页展示并下载电子发票
发票由一站式平台开具后推给后端(官方网不开票,见工作区根目录 docs/adr/0006),
订单响应里新增 invoices;文件是本平台副本,下载走与附件同一条链路(点击时取票)。

- 抽出 composable useAttachmentDownload:把「先换票据、再取文件」的逻辑从
  HjcTenderFiles 里提出来,附件与发票共用(各写一份必然分叉);同时把资源标识解析
  扩到 orderNo/projectNo/invoiceId 三种。
- 新增 HjcInvoiceFiles.vue:发票号/类型/开票日期/金额 + 下载;作废(红冲)的也列出来
  且仍可下载——它是留档凭证。
- HjcTenderFiles.vue 改用 composable,行为不变。
- 订单详情加发票区块,订单列表加「已开票」标记与发票列表。
- 不判"是否已支付":有没有发票取决于甲方开没开票,与订单当前状态无关。

校验:npm run build 通过。
2026-09-18 17:33:01 +08:00

300 lines
12 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="container mx-auto px-4 py-10">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">订单详情</h1>
<HjcUserBar />
</div>
<div v-if="!loggedIn" class="py-20 text-center">
<p class="text-gray-500">请先登录后查看订单</p>
<NuxtLink
:to="`/login?redirect=${loginRedirect}`"
class="mt-4 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
去登录
</NuxtLink>
</div>
<div v-else-if="error" class="py-20 text-center">
<p class="text-red-500">{{ error }}</p>
<NuxtLink
to="/tender/orders"
class="mt-6 inline-block rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
>
返回我的订单
</NuxtLink>
</div>
<SiteLoading v-else-if="!loaded" />
<template v-else-if="order">
<p v-if="notice" class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm text-gray-700">{{ notice }}</p>
<!-- 标题与状态 -->
<div class="rounded-lg border border-gray-200 p-5">
<div class="flex items-start justify-between gap-4">
<p class="font-semibold text-gray-900">{{ order.projectName }}</p>
<span class="shrink-0 rounded px-2 py-0.5 text-xs" :class="hjcOrderStatusClass(order)">
{{ hjcOrderStatusLabel(order) }}
</span>
</div>
<p class="mt-1 text-sm text-gray-500">订单号{{ order.orderNo }}</p>
</div>
<!--
标书附件**已支付订单**才有`tenderFiles` 由后端按 payStatus=1 下发)。
未支付的单这里给一句说明而不是空区块——用户要知道下载在什么时候出现」,
而不是以为平台没有这个功能
-->
<div v-if="isPaid" class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-3 font-bold text-gray-900">标书附件</h2>
<HjcTenderFiles v-if="order.tenderFiles?.length" :files="order.tenderFiles" />
<p v-else class="text-sm text-gray-500">该项目暂无标书附件如有疑问请联系平台客服</p>
</div>
<div v-else class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-2 font-bold text-gray-900">标书附件</h2>
<p class="text-sm text-gray-500">支付成功后可在此下载标书附件</p>
</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 -->
<div v-if="hjcCanActOnOrder(order)" class="mt-4 flex justify-end gap-3 rounded-lg border border-gray-200 p-5">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
:disabled="cancelling"
@click="askCancel"
>
{{ cancelling ? '取消中...' : '取消订单' }}
</button>
<NuxtLink
:to="`/tender/pay?orderNo=${encodeURIComponent(order.orderNo)}`"
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
>
去支付
</NuxtLink>
</div>
<!-- 标书信息 -->
<div class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-3 font-bold text-gray-900">标书信息</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-gray-500">项目编号</dt>
<dd class="text-right text-gray-900">{{ order.projectNo || '-' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">单价</dt>
<dd class="text-right text-gray-900">¥{{ formatMoney(order.unitPrice) }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">数量</dt>
<dd class="text-right text-gray-900">x{{ order.quantity ?? 1 }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">实付金额</dt>
<dd class="text-right font-bold text-blue-600">¥{{ formatMoney(order.totalAmount) }}</dd>
</div>
</dl>
</div>
<!-- 支付信息 -->
<div class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-3 font-bold text-gray-900">支付信息</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-gray-500">订单号</dt>
<dd class="break-all text-right text-gray-900">{{ order.orderNo }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">支付方式</dt>
<dd class="text-right text-gray-900">{{ payMethodLabel }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">支付状态</dt>
<dd class="text-right text-gray-900">{{ hjcOrderStatusLabel(order) }}</dd>
</div>
<div v-if="order.payTime" class="flex justify-between gap-4">
<dt class="text-gray-500">支付时间</dt>
<dd class="text-right text-gray-900">{{ fmtTime(order.payTime) }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">下单时间</dt>
<dd class="text-right text-gray-900">{{ fmtTime(order.createTime) }}</dd>
</div>
</dl>
</div>
<!-- 购买人信息 -->
<div class="mt-4 rounded-lg border border-gray-200 p-5">
<h2 class="mb-3 font-bold text-gray-900">购买人信息</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-gray-500">企业</dt>
<dd class="text-right text-gray-900">{{ order.enterpriseName || '-' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">联系人</dt>
<dd class="text-right text-gray-900">{{ order.contactName || '-' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">联系电话</dt>
<dd class="text-right text-gray-900">{{ order.contactPhone || '-' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-gray-500">联系邮箱</dt>
<dd class="break-all text-right text-gray-900">{{ order.contactEmail || '-' }}</dd>
</div>
</dl>
</div>
<NuxtLink to="/tender/orders" class="mt-6 inline-block text-sm text-blue-600 hover:underline">
返回我的订单
</NuxtLink>
</template>
<HjcConfirmDialog
:open="cancelDialogOpen"
title="确定取消该订单吗?"
content="取消后订单不可恢复,如需购买请重新下单。"
confirm-text="确定取消"
cancel-text="再想想"
loading-text="取消中..."
:loading="cancelling"
@confirm="confirmCancel"
@cancel="cancelDialogOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import type { HjcOrder } from '~/types/tender'
import { hjcCanActOnOrder, hjcOrderStatusClass, hjcOrderStatusLabel } from '~/utils/orderStatus'
/**
* 订单详情页(PC 端)。
*
* 此前 PC 没有这个页面:买家从列表只能看状态、点不了任何东西,「已支付后下载标书」在 PC
* 完全没有落点(`.scratch/hjc-web-order-detail/issues/01`)。本页的两个职责就是
* ①展示订单全貌 ②给**已支付**订单提供标书附件下载。
*
* 附件地址来自后端 `tenderFiles`(只有 payStatus=1 才下发),本页**不做权限判断**、
* 也不从项目字段拼地址——门槛在服务端。
*
* 取数必须用 `useRequestFetch()`,不能用裸 `$fetch`SSR 期间裸 `$fetch` 不转发浏览器
* Cookie,代理会变成匿名调用并拿到 code=401`handleAuthCode` 于是清掉 Token ——
* 刷新详情页 = 把自己登出(完整说明见 app/pages/tender/orders/index.vue:101-110)。
*/
const route = useRoute()
const { isLoggedIn, handleAuthCode } = useHjcAuth()
const requestFetch = useRequestFetch()
const orderId = computed(() => String(route.params.id || ''))
const loginRedirect = computed(() => encodeURIComponent(route.fullPath))
const loggedIn = ref(false)
const order = shallowRef<HjcOrder | null>(null)
const loaded = ref(false)
const error = ref('')
/** 操作结果提示(取消成功/已支付),与 error 分开:它不是错误 */
const notice = ref('')
const cancelDialogOpen = ref(false)
const cancelling = ref(false)
/** 已支付:标书附件是否下发的唯一依据(与后端 `isPaid` 同一口径,只看 payStatus */
const isPaid = computed(() => order.value?.payStatus === 1)
const payMethodLabel = computed(() => (order.value?.payMethod === 'ALIPAY' ? '支付宝' : '微信支付'))
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
function fmtTime(s?: string) {
return s ? s.slice(0, 19).replace('T', ' ') : '-'
}
async function reload() {
try {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
const res: any = await requestFetch(`/api/tender/order-detail?id=${encodeURIComponent(orderId.value)}`)
const auth = await handleAuthCode(res, route.fullPath)
if (auth.handled) {
// 401 已清凭据并跳登录页;403 只提示,不清 token
error.value = auth.message || ''
loggedIn.value = isLoggedIn()
return
}
if (res?.code === 0 && res.data) {
order.value = res.data as HjcOrder
error.value = ''
} else {
order.value = null
error.value = res?.message || '订单加载失败'
}
} catch (e: any) {
order.value = null
error.value = e?.data?.message || e?.message || '订单加载失败'
}
}
function askCancel() {
notice.value = ''
cancelDialogOpen.value = true
}
async function confirmCancel() {
const o = order.value
if (!o) return
cancelling.value = true
notice.value = ''
try {
const res: any = await $fetch('/api/tender/cancel', { method: 'POST', body: { orderNo: o.orderNo } })
const auth = await handleAuthCode(res, route.fullPath)
if (auth.handled) {
notice.value = auth.message || ''
return
}
if (res?.code !== 0) {
notice.value = res?.message || '取消失败,请稍后重试'
return
}
// 「其实已经付了」**不是错误**:后端同样返回 code=0,结论在结构化字段里。
// 按字段判定,绝不匹配 message 字符串(与列表页同一口径)。
const d = res.data || {}
if (d.cancelled) {
notice.value = d.verified === false
// 微信不可达时如实告知没核对上,而不是替它下结论
? '订单已取消(未能在微信侧核对,如你已付款请稍后刷新订单)'
: '订单已取消'
} else if (d.paid) {
notice.value = '该订单已支付成功,无需取消'
} else {
notice.value = res?.message || '取消未生效,请刷新后查看订单状态'
}
// 无论哪种结论都就地重取:状态可能已在服务端变化(含被自愈成已支付),
// 而详情页正是「支没支付、能不能下载」要显示对的地方。
await reload()
} catch (e: any) {
notice.value = e?.data?.message || e?.message || '取消失败,请稍后重试'
} finally {
cancelling.value = false
cancelDialogOpen.value = false
}
}
loggedIn.value = isLoggedIn()
if (loggedIn.value) {
await reload()
}
loaded.value = true
</script>