Files
hjc-web/app/pages/tender/favorites.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

253 lines
9.5 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/favorites" 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="items.length" class="space-y-4">
<div v-for="item in items" :key="item.projectId" class="rounded-lg border border-gray-200 p-5">
<div class="flex items-start justify-between gap-4">
<button type="button" class="text-left font-semibold text-gray-900 hover:text-blue-600" @click="goDetail(item)">
{{ item.projectName || '项目已失效' }}
</button>
<!--
徽标只渲染后端算好的 saleState / purchased**前端不自己判定**
一期决策 17口径只有一份)。已购与状态徽标可以同时出现
已结束是自然到期中性灰),「已下架是平台撤下),两者感受不同
-->
<div class="flex shrink-0 items-center gap-2">
<span v-if="item.saleState === 'ended'" class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500">已结束</span>
<span v-else-if="item.saleState === 'removed'" class="rounded bg-red-50 px-2 py-0.5 text-xs text-red-600">已下架</span>
<span v-if="item.purchased" class="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-600">已购买</span>
</div>
</div>
<p class="mt-1 text-sm text-gray-500">项目编号{{ item.projectNo || '-' }}</p>
<div class="mt-4 flex items-center justify-between border-t border-gray-100 pt-4 text-sm">
<span class="text-gray-500">收藏于 {{ fmtTime(item.createTime) }}</span>
<div class="flex items-center gap-4">
<span v-if="hasPrice(item)" class="font-bold text-blue-600">¥{{ formatMoney(item.tenderPrice) }}</span>
<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="removing && removeTarget?.projectId === item.projectId"
@click="askRemove(item)"
>
取消收藏
</button>
</div>
</div>
</div>
<div v-if="items.length < count" class="pt-2 text-center">
<button
type="button"
class="rounded-md border border-gray-300 px-6 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
:disabled="loadingMore"
@click="loadMore"
>
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
<div v-else-if="loaded" class="py-20 text-center text-gray-500">暂无收藏</div>
<SiteLoading v-else />
</template>
<HjcConfirmDialog
:open="removeDialogOpen"
title="确定取消收藏吗?"
content="取消后可在项目详情页重新收藏。"
confirm-text="取消收藏"
cancel-text="再想想"
loading-text="处理中..."
:loading="removing"
@confirm="confirmRemove"
@cancel="closeRemoveDialog"
/>
</div>
</template>
<script setup lang="ts">
import type { HjcFavorite } from '~/types/tender'
const { isLoggedIn, handleAuthCode } = useHjcAuth()
/**
* 必须用 useRequestFetch 取数,不能用裸 `$fetch`。
*
* 下面这句在 **SSR 期间** 就会执行:裸 `$fetch` 走 Nuxt 全局 ofetch,服务端渲染时
* **不会**把浏览器请求里的 Cookie 转发给站内 `/api` 代理。代理拿不到 `hjc_token`
* 就等于匿名调用,后端按契约返回 `code=401``handleAuthCode` 据此判定「凭据失效」
* → 在服务端清 Token → 结果是**刷新「我的收藏」= 把自己登出**。
* (同一段坑的完整说明见 app/pages/tender/orders/index.vue:101-110。)
*
* 客户端侧 `useRequestFetch()` 等价于 `globalThis.$fetch`,行为不变。
*/
const requestFetch = useRequestFetch()
/** 每页条数,与标书中心列表(TenderList.vue)保持一致 */
const PAGE_SIZE = 12
const loggedIn = ref(false)
const items = shallowRef<HjcFavorite[]>([])
const count = ref(0)
const page = ref(1)
const loaded = ref(false)
const loadingMore = ref(false)
const error = ref('')
/** 操作结果提示(取消成功 / 失效项目提示),与 error 分开:它不是错误 */
const notice = ref('')
/** 正在确认取消的那一条 + 弹窗开合 */
const removeTarget = shallowRef<HjcFavorite | null>(null)
const removeDialogOpen = ref(false)
const removing = ref(false)
function formatMoney(n?: number | string) {
return Number(n ?? 0).toFixed(2)
}
function hasPrice(item: HjcFavorite) {
return item.tenderPrice !== null && item.tenderPrice !== undefined
}
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/favorites?page=1&limit=${PAGE_SIZE}`)
const auth = await handleAuthCode(res, '/tender/favorites')
if (auth.handled) {
// 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token
error.value = auth.message || ''
loggedIn.value = isLoggedIn()
return
}
if (res?.code === 0) {
items.value = Array.isArray(res.data?.list) ? res.data.list : []
count.value = Number(res.data?.count ?? 0)
page.value = 1
error.value = ''
} else {
error.value = res?.message || '收藏加载失败'
}
} catch (e: any) {
error.value = e?.data?.message || e?.message || '收藏加载失败'
}
}
/** 追加下一页 */
async function loadMore() {
if (loadingMore.value) return
loadingMore.value = true
notice.value = ''
try {
const next = page.value + 1
const res: any = await requestFetch(`/api/tender/favorites?page=${next}&limit=${PAGE_SIZE}`)
const auth = await handleAuthCode(res, '/tender/favorites')
if (auth.handled) {
error.value = auth.message || ''
return
}
if (res?.code === 0) {
const more: HjcFavorite[] = Array.isArray(res.data?.list) ? res.data.list : []
items.value = items.value.concat(more)
count.value = Number(res.data?.count ?? count.value)
page.value = next
} else {
notice.value = res?.message || '加载更多失败,请稍后重试'
}
} catch (e: any) {
notice.value = e?.data?.message || e?.message || '加载更多失败,请稍后重试'
} finally {
loadingMore.value = false
}
}
/**
* 进详情。失效项目**不进详情**,只给一句提示(一期决策 8)——
* 用户收藏了却「凭空消失」是最难解释的体验,所以条目留着、能力收窄。
*
* 用 `item.projectId`**不是** `item.id`:后者是收藏行 id,拿去查项目会得到「项目不存在」。
*/
function goDetail(item: HjcFavorite) {
if (item.saleState !== 'onsale') {
notice.value = item.saleState === 'removed' ? '该项目已下架' : '该项目已结束'
return
}
return navigateTo(`/tender/${item.projectId}`)
}
function askRemove(item: HjcFavorite) {
notice.value = ''
removeTarget.value = item
removeDialogOpen.value = true
}
function closeRemoveDialog() {
removeDialogOpen.value = false
removeTarget.value = null
}
/**
* 取消收藏。**列表里要二次确认**(一期决策 10):
* 列表是密集小按钮的误触高发区,而取消之后「它去哪了」用户很难自己还原。
* 详情页的取消不确认(那边是明确意图)。
*/
async function confirmRemove() {
const item = removeTarget.value
if (!item) return
removing.value = true
notice.value = ''
try {
const res: any = await requestFetch(`/api/tender/favorites?projectId=${item.projectId}`, {
method: 'DELETE'
})
const auth = await handleAuthCode(res, '/tender/favorites')
if (auth.handled) {
notice.value = auth.message || ''
return
}
if (res?.code !== 0) {
notice.value = res?.message || '取消失败,请稍后重试'
return
}
// 就地移除,不整页重拉:重拉会把滚动位置弹回顶部
items.value = items.value.filter((x) => x.projectId !== item.projectId)
count.value = Math.max(0, count.value - 1)
notice.value = '已取消收藏'
if (!items.value.length && count.value > 0) {
// 本页空了但后面还有 → 补一页,避免留下一片空白
await reload()
}
} catch (e: any) {
notice.value = e?.data?.message || e?.message || '取消失败,请稍后重试'
} finally {
removing.value = false
removeDialogOpen.value = false
removeTarget.value = null
}
}
loggedIn.value = isLoggedIn()
if (loggedIn.value) {
await reload()
loaded.value = true
} else {
loaded.value = true
}
</script>