Files
hjc-web/app/pages/tender/orders/index.vue
T
weicw1996 2e847b5e09 feat(hjc-web): 新建订单详情页,订单列表与详情支持下载已支付订单的标书
此前 PC 没有订单详情页,标书下载只有两句文案(`.scratch/hjc-web-order-detail/01`)。

- 新增 `app/pages/tender/orders/[id].vue`:订单状态、标书附件、待支付操作区、
  标书/支付/购买人信息;取数走 `useRequestFetch()`(裸 `$fetch` 在 SSR 不转发 Cookie,
  会把用户登出)
- `app/pages/tender/orders.vue` → `orders/index.vue`:Nuxt 里同名 file+目录会生成父子路由,
  详情页会被父页吞掉,必须让列表页是 `index.vue`
- 列表页每张卡片加「标书附件」(仅已支付)与「查看详情」,不整卡可点(卡内已有按钮与链接)
- 新增 `app/components/HjcTenderFiles.vue` 与代理 `server/api/tender/order-detail.get.ts`
- 附件地址来自后端按 `payStatus=1` 下发的 `tenderFiles`,前端不做权限判断、不自己拼地址

规格与验收:`.scratch/hjc-tender-download/`
2026-09-17 18:09:27 +08:00

209 lines
8.2 KiB
Vue
Raw 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=/tender/orders" 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 text-red-500">{{ error }}</div>
<template v-else>
<p v-if="notice" class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm text-gray-700">{{ notice }}</p>
<div v-if="orders.length" class="space-y-4">
<div v-for="o in orders" :key="o.id" class="rounded-lg border border-gray-200 p-5">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-gray-900">{{ o.projectName }}</p>
<p class="mt-1 text-sm text-gray-500">订单号{{ o.orderNo }} · 项目编号{{ o.projectNo }}</p>
</div>
<span class="rounded px-2 py-0.5 text-xs" :class="hjcOrderStatusClass(o)">{{ hjcOrderStatusLabel(o) }}</span>
</div>
<div class="mt-4 flex items-center justify-between text-sm">
<span class="text-gray-500">{{ fmtTime(o.createTime) }}</span>
<span class="text-blue-600 font-bold">¥{{ formatMoney(o.totalAmount) }}</span>
</div>
<!--
标书附件**只有已支付订单**才有这一块——`tenderFiles` 由后端按 payStatus=1
下发hjc-h5 的列表与详情同一口径)。未支付订单这里什么都不渲染
因为地址压根没下发不是前端藏起来的
-->
<div v-if="o.tenderFiles?.length" class="mt-4 border-t border-gray-100 pt-4">
<p class="mb-2 text-sm font-medium text-gray-700">标书附件</p>
<HjcTenderFiles :files="o.tenderFiles" />
</div>
<div class="mt-4 flex items-center justify-between border-t border-gray-100 pt-4">
<!--
详情入口****把整张卡片做成可点卡片里已经有取消订单」「去支付
和被撑开的一列附件链接嵌套点击的误触代价比省一次点击更高
-->
<NuxtLink
:to="`/tender/orders/${o.id}`"
class="text-sm text-blue-600 hover:underline"
>
查看详情
</NuxtLink>
<!--
待支付订单的操作区判定用 hjcCanActOnOrderpayStatus===0 && orderStatus!==2),
**不用** statusKey==='pending'——payStatus=2支付失败也会落进 pending 兜底
-->
<div v-if="hjcCanActOnOrder(o)" class="flex justify-end gap-3">
<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 && cancelTarget?.id === o.id"
@click="askCancel(o)"
>
取消订单
</button>
<NuxtLink
:to="`/tender/pay?orderNo=${encodeURIComponent(o.orderNo)}`"
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700"
>
去支付
</NuxtLink>
</div>
</div>
</div>
</div>
<div v-else-if="loaded" class="py-20 text-center text-gray-500">暂无订单</div>
<SiteLoading v-else />
</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 { hjcCanActOnOrder, hjcOrderStatusClass, hjcOrderStatusLabel } from '~/utils/orderStatus'
const { isLoggedIn, handleAuthCode } = useHjcAuth()
/**
* 必须用 useRequestFetch 取数,不能用裸 `$fetch`。
*
* 下面这句在 **SSR 期间** 就会执行:裸 `$fetch` 走的是 Nuxt 全局 ofetch,服务端渲染时
* **不会**把浏览器请求里的 Cookie 转发给站内 `/api` 代理(Nuxt 只在 `useRequestFetch`
* / `useFetch` 里做转发)。代理拿不到 `hjc_token` 就等于匿名调用,后端按契约返回
* `code=401``handleAuthCode` 据此判定「凭据失效」→ 在服务端清 Token →
* SSR 响应带上 `Set-Cookie: hjc_token=; Max-Age=0` 并跳登录页。
* 结果就是:刷新「我的订单」= 把自己登出(Cookie 被真正删掉,此后一直是未登录)。
*
* 客户端侧 `useRequestFetch()` 等价于 `globalThis.$fetch`,行为不变。
*/
const requestFetch = useRequestFetch()
const loggedIn = ref(false)
const orders = shallowRef<any[]>([])
const loaded = ref(false)
const error = ref('')
/** 操作结果提示(取消成功/已支付/未核对上),与 error 分开:它不是错误 */
const notice = ref('')
/** 正在确认取消的那张订单 + 弹窗开合 */
const cancelTarget = shallowRef<any | null>(null)
const cancelDialogOpen = ref(false)
const cancelling = ref(false)
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
function fmtTime(s?: string) {
return s ? s.slice(0, 19).replace('T', ' ') : '-'
}
/** 拉取(或重新拉取)订单列表。取消成功后走这里就地更新,不留整页 loading。 */
async function reload() {
try {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
const res: any = await requestFetch('/api/tender/my-orders')
const auth = await handleAuthCode(res, '/tender/orders')
if (auth.handled) {
// 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token
error.value = auth.message || ''
loggedIn.value = isLoggedIn()
return
}
if (res?.code === 0) {
orders.value = Array.isArray(res.data) ? res.data : []
error.value = ''
} else {
error.value = res?.message || '订单加载失败'
}
} catch (e: any) {
error.value = e?.data?.message || e?.message || '订单加载失败'
}
}
function askCancel(o: any) {
notice.value = ''
cancelTarget.value = o
cancelDialogOpen.value = true
}
async function confirmCancel() {
const o = cancelTarget.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, '/tender/orders')
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
cancelTarget.value = null
}
}
loggedIn.value = isLoggedIn()
if (loggedIn.value) {
await reload()
loaded.value = true
} else {
loaded.value = true
}
</script>