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,其余按需再补。
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
<template v-if="loggedIn">
|
<template v-if="loggedIn">
|
||||||
<span class="text-gray-500">{{ displayName }}</span>
|
<span class="text-gray-500">{{ displayName }}</span>
|
||||||
<NuxtLink to="/tender/orders" class="text-blue-600 hover:underline">我的订单</NuxtLink>
|
<NuxtLink to="/tender/orders" class="text-blue-600 hover:underline">我的订单</NuxtLink>
|
||||||
|
<NuxtLink to="/tender/favorites" class="text-blue-600 hover:underline">我的收藏</NuxtLink>
|
||||||
<NuxtLink to="/tender/qualification" class="text-blue-600 hover:underline">企业资质</NuxtLink>
|
<NuxtLink to="/tender/qualification" class="text-blue-600 hover:underline">企业资质</NuxtLink>
|
||||||
<NuxtLink to="/change-password" class="text-blue-600 hover:underline">修改密码</NuxtLink>
|
<NuxtLink to="/change-password" class="text-blue-600 hover:underline">修改密码</NuxtLink>
|
||||||
<button class="text-gray-500 hover:text-red-600" @click="doLogout">退出</button>
|
<button class="text-gray-500 hover:text-red-600" @click="doLogout">退出</button>
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<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>
|
||||||
@@ -60,8 +60,33 @@
|
|||||||
<p>售卖方式:{{ sellingMethodText }}</p>
|
<p>售卖方式:{{ sellingMethodText }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
收藏:**不需要企业资质**(资质只卡购买),所以这里不看 authStatus / canBuy。
|
||||||
|
用内联 SVG 而不是图标库:这个模板是纯 Tailwind 手写的,没有引入 a-* 组件。
|
||||||
|
-->
|
||||||
<button
|
<button
|
||||||
class="mt-6 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
type="button"
|
||||||
|
class="mt-6 flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 py-2.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="favBusy"
|
||||||
|
@click="toggleFavorite"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:class="favorited ? 'text-red-500' : 'text-gray-400'"
|
||||||
|
:fill="favorited ? 'currentColor' : 'none'"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 20.5 4.6 13.4a4.6 4.6 0 0 1 0-6.6 4.9 4.9 0 0 1 6.9 0l.5.5.5-.5a4.9 4.9 0 0 1 6.9 0 4.6 4.6 0 0 1 0 6.6Z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ favorited ? '已收藏' : '收藏' }}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="favError" class="mt-2 text-center text-xs text-red-500">{{ favError }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mt-3 w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
:disabled="!buyable"
|
:disabled="!buyable"
|
||||||
@click="goBuy"
|
@click="goBuy"
|
||||||
>
|
>
|
||||||
@@ -84,6 +109,26 @@ const id = route.params.id as string
|
|||||||
const tender = shallowRef<Tender | null>(null)
|
const tender = shallowRef<Tender | null>(null)
|
||||||
const notFound = ref(false)
|
const notFound = ref(false)
|
||||||
|
|
||||||
|
/** 是否已收藏:由详情接口**内联**返回(未登录恒 false),不额外发请求 */
|
||||||
|
const favorited = ref(false)
|
||||||
|
const favBusy = ref(false)
|
||||||
|
const favError = ref('')
|
||||||
|
/** 本次挂载是否已处理过「登录回跳补收藏」——保证只补一次 */
|
||||||
|
const favPendingHandled = ref(false)
|
||||||
|
|
||||||
|
const { isLoggedIn, handleAuthCode } = useHjcAuth()
|
||||||
|
/**
|
||||||
|
* 必须用 useRequestFetch 取数,不能用裸 `$fetch`。
|
||||||
|
*
|
||||||
|
* 详情响应里内联了 `favorited`,而站内代理只有在 **Cookie 被转发** 时才认得出登录态:
|
||||||
|
* Nuxt 只在 `useRequestFetch` / `useFetch` 里做转发,裸 `$fetch` 在 SSR 期间不转发 Cookie,
|
||||||
|
* 代理就是匿名调用,后端按契约返回 `favorited = false` —— 已收藏的项目首屏会显示成「未收藏」。
|
||||||
|
* (同一个坑的另一种表现见 app/pages/tender/orders.vue:77-88:那次是刷新把自己登出。)
|
||||||
|
*
|
||||||
|
* 客户端侧 `useRequestFetch()` 等价于 `globalThis.$fetch`,行为不变。
|
||||||
|
*/
|
||||||
|
const requestFetch = useRequestFetch()
|
||||||
|
|
||||||
const buyable = computed(() => {
|
const buyable = computed(() => {
|
||||||
if (!tender.value) return false
|
if (!tender.value) return false
|
||||||
if (tender.value.status !== undefined && tender.value.status !== 1) return false
|
if (tender.value.status !== undefined && tender.value.status !== 1) return false
|
||||||
@@ -116,11 +161,82 @@ function goBuy() {
|
|||||||
return navigateTo(`/buy?id=${tender.value.id}`)
|
return navigateTo(`/buy?id=${tender.value.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 补收藏:登录回跳后静默补一次,失败不打扰(用户自己再点一次即可) */
|
||||||
|
async function addFavoriteQuietly() {
|
||||||
|
try {
|
||||||
|
const res: any = await requestFetch('/api/tender/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { projectId: Number(id) }
|
||||||
|
})
|
||||||
|
if (res?.code === 0) {
|
||||||
|
favorited.value = !!res.data?.favorited
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 静默:补收藏失败就退化成「未收藏」
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收藏 / 取消收藏。
|
||||||
|
*
|
||||||
|
* **不判资质**:未认证 / 审核中 / 已驳回的企业都能收藏(资质只卡购买,
|
||||||
|
* 右侧「立即购买」仍然照旧拦)。**也不弹二次确认**:详情页点「已收藏」是明确意图,
|
||||||
|
* 弹窗只是摩擦——列表页的取消才弹确认(见 app/pages/tender/favorites.vue)。
|
||||||
|
*/
|
||||||
|
async function toggleFavorite() {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
// 与 h5 一致:跳登录并带回跳地址,登录成功后自动补上这次收藏
|
||||||
|
return navigateTo(`/login?redirect=${encodeURIComponent(`/tender/${id}?favorite=1`)}`)
|
||||||
|
}
|
||||||
|
if (favBusy.value) return
|
||||||
|
favBusy.value = true
|
||||||
|
favError.value = ''
|
||||||
|
try {
|
||||||
|
const res: any = favorited.value
|
||||||
|
? await requestFetch(`/api/tender/favorites?projectId=${id}`, { method: 'DELETE' })
|
||||||
|
: await requestFetch('/api/tender/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { projectId: Number(id) }
|
||||||
|
})
|
||||||
|
const auth = await handleAuthCode(res, `/tender/${id}`)
|
||||||
|
if (auth.handled) return
|
||||||
|
if (res?.code === 0) {
|
||||||
|
// 以后端返回的状态为准:不做乐观更新,失败时状态不乱
|
||||||
|
favorited.value = !!res.data?.favorited
|
||||||
|
} else {
|
||||||
|
favError.value = res?.message || '操作失败,请稍后重试'
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
favError.value = e?.data?.message || e?.message || '操作失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
favBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await $fetch(`/api/tender/detail?id=${id}`)
|
const res: any = await requestFetch(`/api/tender/detail?id=${id}`)
|
||||||
tender.value = res && res.id ? res : null
|
tender.value = res && res.id ? res : null
|
||||||
|
favorited.value = !!res?.favorited
|
||||||
if (!res) notFound.value = true
|
if (!res) notFound.value = true
|
||||||
} catch {
|
} catch {
|
||||||
notFound.value = true
|
notFound.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未登录点收藏 → 登录 → 回跳带上 `favorite=1`,这里补一次。
|
||||||
|
*
|
||||||
|
* 三条约束:
|
||||||
|
* 1. **只在客户端做**(onMounted):SSR 期间发写请求是服务端副作用,且可能被执行两次;
|
||||||
|
* 2. **不改写 URL 去掉 `favorite=1`**:详情页壳 `app/pages/tender/[id].vue` 用
|
||||||
|
* `:key="route.fullPath"`,改 query 会让模板组件整体重挂载并重新取数,
|
||||||
|
* 那次重取会与刚发出的收藏请求赛跑;组件内的标记做「每次挂载只补一次」就够了;
|
||||||
|
* 3. 失败**静默**退化成「未收藏」,让用户自己再点一次,不弹二次打扰(与 h5 一致)。
|
||||||
|
*/
|
||||||
|
onMounted(async () => {
|
||||||
|
if (favPendingHandled.value) return
|
||||||
|
if (route.query.favorite !== '1') return
|
||||||
|
favPendingHandled.value = true
|
||||||
|
if (!isLoggedIn()) return
|
||||||
|
await addFavoriteQuietly()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -46,6 +46,38 @@ export interface Tender {
|
|||||||
* 口径与「人数」字面有偏差(同一企业买 2 单计 2),这是明确拍定的取值。
|
* 口径与「人数」字面有偏差(同一企业买 2 单计 2),这是明确拍定的取值。
|
||||||
*/
|
*/
|
||||||
buyerCount?: number
|
buyerCount?: number
|
||||||
|
/**
|
||||||
|
* 当前登录企业是否已收藏本项目(后端**内联**在详情响应里的非表字段)。
|
||||||
|
* 未登录恒为 `false` —— 详情是公开接口,不因为没登录而报错。
|
||||||
|
*/
|
||||||
|
favorited?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「我的收藏」单条(对应 mp-java HjcFavoriteVo) */
|
||||||
|
export interface HjcFavorite {
|
||||||
|
/** 收藏行 id(排查用,不是项目 id) */
|
||||||
|
id: number
|
||||||
|
/** 标书项目 id(进详情要用它,不是 id) */
|
||||||
|
projectId: number
|
||||||
|
/** 收藏时间 */
|
||||||
|
createTime?: string
|
||||||
|
/** 项目已被物理删除时为 null */
|
||||||
|
projectName?: string
|
||||||
|
projectNo?: string
|
||||||
|
category?: string
|
||||||
|
tenderPrice?: number
|
||||||
|
deadlineTime?: string
|
||||||
|
offsaleTime?: string
|
||||||
|
/**
|
||||||
|
* 售卖状态,由**后端**算好(口径只有一份):`onsale` 在售 / `ended` 已结束 / `removed` 已下架。
|
||||||
|
* 前端只渲染文案,不要自己比 status 或算时间。
|
||||||
|
*/
|
||||||
|
saleState?: 'onsale' | 'ended' | 'removed'
|
||||||
|
/**
|
||||||
|
* 本企业是否已购买该项目(已付款订单口径,与详情页「购买人数」同一口径)。
|
||||||
|
* 已购**不会**从收藏夹移除——收藏是存档,不是购物车。
|
||||||
|
*/
|
||||||
|
purchased?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分页返回结构 */
|
/** 分页返回结构 */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { $fetch } from 'ofetch'
|
import { $fetch } from 'ofetch'
|
||||||
import { createError, defineEventHandler, getRouterParam } from 'h3'
|
import { createError, defineEventHandler, getRouterParam, getHeader, getCookie } from 'h3'
|
||||||
import { useRuntimeConfig } from '#imports'
|
import { useRuntimeConfig } from '#imports'
|
||||||
import { getTenantFromContext } from '../../utils/tenant'
|
import { getTenantFromContext } from '../../utils/tenant'
|
||||||
|
|
||||||
@@ -7,12 +7,23 @@ import { getTenantFromContext } from '../../utils/tenant'
|
|||||||
* 标书项目详情
|
* 标书项目详情
|
||||||
* GET /api/tender/detail?id=1 (或 /api/tender/{id})
|
* GET /api/tender/detail?id=1 (或 /api/tender/{id})
|
||||||
* 代理到 mp-api(mp-java) hjc 标书项目详情,返回扁平化项目对象;不存在则 404。
|
* 代理到 mp-api(mp-java) hjc 标书项目详情,返回扁平化项目对象;不存在则 404。
|
||||||
|
*
|
||||||
|
* 返回体**整体**返回后端 data(不逐字段挑选):后端新增的 `favorited`(是否已收藏)
|
||||||
|
* 正是靠这一点透传过来的,改成字段白名单就会把它丢掉。
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const ctx = getTenantFromContext(event, config)
|
const ctx = getTenantFromContext(event, config)
|
||||||
const id = getRouterParam(event, 'id') || (getQuery(event) as any)?.id
|
const id = getRouterParam(event, 'id') || (getQuery(event) as any)?.id
|
||||||
const modulesApiBase = config.public.modulesApiBase as string
|
const modulesApiBase = config.public.modulesApiBase as string
|
||||||
|
/**
|
||||||
|
* 转发登录态。详情接口本身是**公开**的(匿名也返回 code=0),但它的返回体里内联了一个
|
||||||
|
* `favorited`:匿名调用时后端按契约返回 `false`,页面就会把已收藏的项目显示成「未收藏」。
|
||||||
|
*
|
||||||
|
* 未登录时没有 cookie → 不带这个头 → 行为与改动前**完全一致**(向后兼容)。
|
||||||
|
*/
|
||||||
|
const cookieToken = getCookie(event, 'hjc_token')
|
||||||
|
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw createError({ statusCode: 400, statusMessage: '缺少 id' })
|
throw createError({ statusCode: 400, statusMessage: '缺少 id' })
|
||||||
@@ -21,7 +32,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
try {
|
try {
|
||||||
const res = await $fetch(`/hjc/bid-project/${id}`, {
|
const res = await $fetch(`/hjc/bid-project/${id}`, {
|
||||||
baseURL: modulesApiBase,
|
baseURL: modulesApiBase,
|
||||||
headers: { TenantId: ctx.tenantId }
|
headers: {
|
||||||
|
TenantId: ctx.tenantId,
|
||||||
|
...(auth ? { Authorization: auth } : {})
|
||||||
|
}
|
||||||
}) as any
|
}) as any
|
||||||
|
|
||||||
const data = res && res.data !== undefined ? res.data : res
|
const data = res && res.data !== undefined ? res.data : res
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { $fetch } from 'ofetch'
|
||||||
|
import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3'
|
||||||
|
import { useRuntimeConfig } from '#imports'
|
||||||
|
import { getTenantFromContext } from '../../utils/tenant'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消收藏(幂等)
|
||||||
|
* DELETE /api/tender/favorites?projectId=123
|
||||||
|
* 代理到 mp-api /api/hjc/project-favorite/{projectId};需登录态。
|
||||||
|
*
|
||||||
|
* 约定:原样透传 ApiResult{code,message,data}。后端**恒返回 code=0**
|
||||||
|
* (本来就没收藏也算成功),结论在 data 里:`{ projectId, favorited: false }`。
|
||||||
|
*
|
||||||
|
* projectId 走查询串而不是请求体:DELETE 带 body 在部分中间层会被丢掉,
|
||||||
|
* 而它本来就是个标识符,放查询串更自然。
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const ctx = getTenantFromContext(event, config)
|
||||||
|
const projectId = (getQuery(event) as any)?.projectId
|
||||||
|
const cookieToken = getCookie(event, 'hjc_token')
|
||||||
|
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: '缺少 projectId' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch(`/hjc/project-favorite/${projectId}`, {
|
||||||
|
baseURL: config.public.modulesApiBase,
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
TenantId: ctx.tenantId,
|
||||||
|
...(auth ? { Authorization: auth } : {})
|
||||||
|
},
|
||||||
|
query: { TenantId: ctx.tenantId }
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||||
|
statusMessage: error?.statusMessage || 'Failed to remove favorite'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { $fetch } from 'ofetch'
|
||||||
|
import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3'
|
||||||
|
import { useRuntimeConfig } from '#imports'
|
||||||
|
import { getTenantFromContext } from '../../utils/tenant'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的收藏(标书项目)
|
||||||
|
* GET /api/tender/favorites?page=1&limit=12
|
||||||
|
* 代理到 mp-api /api/hjc/project-favorite/page;需登录态。
|
||||||
|
*
|
||||||
|
* 约定:原样透传 ApiResult{code,message,data}。HTTP 状态码恒为 200,
|
||||||
|
* 「未登录/token 失效」是 code=401、「无权限」是 code=403,前端必须按 code 判定;
|
||||||
|
* 故这里**不能**只取 data,否则登录态失败会被误当成「没有收藏」。
|
||||||
|
*
|
||||||
|
* 刻意**不转发**客户端的 sort/order:后端固定按收藏时间倒序,且会丢弃客户端排序
|
||||||
|
* (这个查询是连表,放行排序有歧义列的风险)。少转发一个参数就少一处「看起来能排序」的错觉。
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const ctx = getTenantFromContext(event, config)
|
||||||
|
const cookieToken = getCookie(event, 'hjc_token')
|
||||||
|
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||||
|
const q = getQuery(event)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch('/hjc/project-favorite/page', {
|
||||||
|
baseURL: config.public.modulesApiBase,
|
||||||
|
headers: {
|
||||||
|
TenantId: ctx.tenantId,
|
||||||
|
...(auth ? { Authorization: auth } : {})
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
TenantId: ctx.tenantId,
|
||||||
|
page: q.page,
|
||||||
|
limit: q.limit
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||||
|
statusMessage: error?.statusMessage || 'Failed to fetch my favorites'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { $fetch } from 'ofetch'
|
||||||
|
import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
|
||||||
|
import { useRuntimeConfig } from '#imports'
|
||||||
|
import { getTenantFromContext } from '../../utils/tenant'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收藏一个标书项目(幂等)
|
||||||
|
* POST /api/tender/favorites { projectId }
|
||||||
|
* 代理到 mp-api /api/hjc/project-favorite;需登录态。
|
||||||
|
*
|
||||||
|
* 约定:原样透传 ApiResult{code,message,data},由前端按 body.code 判定登录态与业务失败。
|
||||||
|
* 后端**恒返回 code=0**(重复收藏也是成功),结论在 data 里:`{ projectId, favorited }`。
|
||||||
|
* 收藏**不需要企业资质**,所以这里也没有资质相关的判定。
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const ctx = getTenantFromContext(event, config)
|
||||||
|
const body = await readBody(event)
|
||||||
|
const cookieToken = getCookie(event, 'hjc_token')
|
||||||
|
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch('/hjc/project-favorite', {
|
||||||
|
baseURL: config.public.modulesApiBase,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
TenantId: ctx.tenantId,
|
||||||
|
...(auth ? { Authorization: auth } : {})
|
||||||
|
},
|
||||||
|
query: { TenantId: ctx.tenantId },
|
||||||
|
body
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||||
|
statusMessage: error?.statusMessage || 'Failed to add favorite'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user