Files
hjc-web/app/pages/tender/favorites.vue
T
weicw1996 9299485899 feat(hjc-web): 标书详情加「收藏」按钮,「我的收藏」页与顶栏入口
后端接口是端无关的,本轮 **mp-java 零改动**,纯前端 + Nitro 代理。

- 新增 favorites.get/post/delete 三个站内代理,原样透传 ApiResult
  (HTTP 恒 200,按 body.code 判登录态与业务失败)。delete 用查询串传 projectId。
- detail.get.ts 补发 Authorization:详情接口内联了 favorited,而它此前是匿名调用,
  已收藏的项目会显示成「未收藏」。未登录时不带这个头,行为与改动前完全一致
  (返回体仍整体返回 data,favorited 正是靠这一点透传的)。
- 「我的收藏」做成**模板无关的公共路由** app/pages/tender/favorites.vue(10 套模板共用),
  顶栏 HjcUserBar 在「我的订单」后加入口。列表按收藏时间倒序 + 加载更多;
  只渲染后端算好的 saleState/purchased(前端不再判一遍);失效项目**保留条目**、
  点击只提示不进详情、仍可取消;取消收藏弹二次确认(复用 HjcConfirmDialog)。
  取数用 useRequestFetch —— 裸 $fetch 在 SSR 不转发 cookie,会被代理当成匿名调用,
  拿到 401 后 handleAuthCode 会清凭据跳登录,等于刷新页面把自己登出。
- template-07 详情页加收藏按钮(heart SVG 两态,不引图标库),未登录点它跳登录、
  回跳带 favorite=1 后自动补一次收藏(只在客户端 onMounted 做,不改写 URL——
  详情壳是 :key="route.fullPath",改 query 会重挂载并与收藏请求赛跑)。
  取数同样改用 useRequestFetch,否则首屏 favorited 恒为 false。
- 只改 template-07:10 套模板里只有它有 TenderDetail.vue,其余按需再补。
2026-09-17 17:37:10 +08:00

253 lines
9.4 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.vue:77-88。)
*
* 客户端侧 `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>