9299485899
后端接口是端无关的,本轮 **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,其余按需再补。
253 lines
9.4 KiB
Vue
253 lines
9.4 KiB
Vue
<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>
|