fix(hjc-web): 修复刷新后登录态丢一半,以及顶栏企业名取错字段

- useHjcAuth:登录用户信息补持久化到 hjc_user cookie。原先用 useState,只活在内存与
  本次 SSR payload 里,刷新后归 null,顶栏企业名退化成占位的「企业」,登录态看着像丢了
  一半;H5 端把用户信息存本地存储,PC 端用同名 cookie 对齐。它**只用于展示**,鉴权一律
  以 JWT 为准(后端不读用户信息)。顺带抽出 setCredentials() 统一写入 token + 展示信息。
- HjcUserBar:loggedIn 改 computed —— 原来 ref(isLoggedIn()) 只在挂载那一刻求值一次,
  之后别处清凭据(401 处理、另一页签退出)本组件不跟着变;企业名改取 username 而非
  nickname —— 核心实例建号时会把 nickname 无条件覆盖成打码手机号,拿它当企业名是错的,
  username 才是「企业名称(登录账号)」。
- login:验证码图 object-cover 改 object-contain,避免被裁掉。
- tender/orders:改用 useRequestFetch()。SSR 期间裸 $fetch 不会把浏览器 Cookie 转发给
  站内 /api 代理,代理会变成匿名调用并返回 code=401,从而被 401 分支误判为「凭据失效」
  ——刷新页面等于把用户登出。
This commit is contained in:
2026-09-14 19:01:31 +08:00
parent d0c2dd17cb
commit 32098b69ac
4 changed files with 64 additions and 10 deletions
+16 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="flex items-center gap-4 text-sm"> <div class="flex items-center gap-4 text-sm">
<template v-if="loggedIn"> <template v-if="loggedIn">
<span class="text-gray-500">{{ user?.nickname || user?.username || '企业' }}</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/qualification" class="text-blue-600 hover:underline">企业资质</NuxtLink> <NuxtLink to="/tender/qualification" 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>
@@ -15,7 +15,21 @@
<script setup lang="ts"> <script setup lang="ts">
const { isLoggedIn, user, logout } = useHjcAuth() const { isLoggedIn, user, logout } = useHjcAuth()
const loggedIn = ref(isLoggedIn())
/**
* 写成 computed 而不是 `ref(isLoggedIn())`:后者只在组件挂载那一刻求值一次,
* 之后别处清掉凭据(如 401 处理、另一个页签退出)本组件不会跟着变。
*/
const loggedIn = computed(() => isLoggedIn())
/**
* 显示企业名称,取 `username` 而非 `nickname`。
*
* 核心实例建号时会把 `nickname` **无条件覆盖**成打码手机号(`155****2748`,见
* `.scratch/hjc-auth/spec.md` 的 Q16 = G-a),拿它当企业名显示是错的;
* 而 `username` 就是「企业名称(登录账号)」,注册后不可变更,也是 `hjc_enterprise.name` 的值。
*/
const displayName = computed(() => user.value?.username || user.value?.nickname || '企业')
async function doLogout() { async function doLogout() {
// logout() 会先通知后端(无服务端会话),再清本地凭据 // logout() 会先通知后端(无服务端会话),再清本地凭据
+33 -6
View File
@@ -12,22 +12,46 @@ import type { HjcUser } from '~/types/tender'
export const HJC_CODE_UNAUTHORIZED = 401 export const HJC_CODE_UNAUTHORIZED = 401
export const HJC_CODE_FORBIDDEN = 403 export const HJC_CODE_FORBIDDEN = 403
/** 登录凭据有效期(秒),token 与随行的展示信息同寿命 */
const AUTH_MAX_AGE = 60 * 60 * 24 * 7
/** 登录态(token 存 cookieSSR 安全;服务器端代理从 cookie 读取并转发 Authorization */ /** 登录态(token 存 cookieSSR 安全;服务器端代理从 cookie 读取并转发 Authorization */
export function useHjcAuth() { export function useHjcAuth() {
const token = useCookie<string | null>('hjc_token', { const token = useCookie<string | null>('hjc_token', {
maxAge: 60 * 60 * 24 * 7, maxAge: AUTH_MAX_AGE,
sameSite: 'lax' sameSite: 'lax'
}) })
const user = useState<HjcUser | null>('hjc-user', () => null) /**
* 展示用登录用户信息,与 token 同寿命地持久化。
*
* `useState` 只活在内存与本次 SSR payload 里:刷新页面后 `hjc-user` 归 null
* 顶栏的企业名会从登录后的名称退化成占位的「企业」——登录态看着就像丢了一半。
* H5 端把用户信息存本地存储(`hjc_user`),PC 端用同名 cookie 对齐这一做法。
* 它**只用于展示**,鉴权一律以 JWT 为准(后端不读用户信息)。
*/
const userCookie = useCookie<HjcUser | null>('hjc_user', {
maxAge: AUTH_MAX_AGE,
sameSite: 'lax',
default: () => null
})
const user = useState<HjcUser | null>('hjc-user', () => userCookie.value ?? null)
function isLoggedIn() { function isLoggedIn() {
return !!token.value return !!token.value
} }
/** 写入本次登录/注册拿到的凭据(token + 展示信息),内存与 cookie 一起写 */
function setCredentials(accessToken: string, loginUser: HjcUser | null) {
token.value = accessToken
user.value = loginUser
userCookie.value = loginUser
}
/** 清本地凭据(token 是无状态 JWT,后端无会话,清本地即退出) */ /** 清本地凭据(token 是无状态 JWT,后端无会话,清本地即退出) */
function clearCredentials() { function clearCredentials() {
token.value = null token.value = null
user.value = null user.value = null
userCookie.value = null
} }
/** /**
@@ -44,6 +68,11 @@ export function useHjcAuth() {
): Promise<{ handled: boolean; message?: string }> { ): Promise<{ handled: boolean; message?: string }> {
const code = Number(res?.code) const code = Number(res?.code)
if (code === HJC_CODE_UNAUTHORIZED) { if (code === HJC_CODE_UNAUTHORIZED) {
// 注意:这一分支会写 cookie(服务端渲染时即 SSR 响应里的 Set-Cookie 清除)。
// 因此凡是**服务端渲染期间**发出的、需要登录态的站内请求,必须用
// `useRequestFetch()` 而不是裸 `$fetch`——裸 $fetch 不会把浏览器 Cookie 转发给
// 站内 /api 代理,代理会变成匿名调用并拿到 code=401,从而被这里误判为
// 「凭据失效」:刷新页面 = 把用户登出。参见 app/pages/tender/orders.vue。
clearCredentials() clearCredentials()
await navigateTo(`/login?redirect=${encodeURIComponent(redirect)}`) await navigateTo(`/login?redirect=${encodeURIComponent(redirect)}`)
return { handled: true } return { handled: true }
@@ -103,8 +132,7 @@ export function useHjcAuth() {
}) })
// mp-api 统一返回 ApiResult{code,message,data}code===0 成功 // mp-api 统一返回 ApiResult{code,message,data}code===0 成功
if (res?.code === 0 && res.data?.access_token) { if (res?.code === 0 && res.data?.access_token) {
token.value = res.data.access_token setCredentials(res.data.access_token, res.data.user || null)
user.value = res.data.user || null
return { ok: true, user: res.data.user } return { ok: true, user: res.data.user }
} }
return { ok: false, message: res?.message || '登录失败' } return { ok: false, message: res?.message || '登录失败' }
@@ -124,8 +152,7 @@ export function useHjcAuth() {
body: payload body: payload
}) })
if (res?.code === 0 && res.data?.access_token) { if (res?.code === 0 && res.data?.access_token) {
token.value = res.data.access_token setCredentials(res.data.access_token, res.data.user || null)
user.value = res.data.user || null
return { ok: true, user: res.data.user } return { ok: true, user: res.data.user }
} }
return { ok: false, message: res?.message || '注册失败' } return { ok: false, message: res?.message || '注册失败' }
+1 -1
View File
@@ -25,7 +25,7 @@
:src="captchaImage" :src="captchaImage"
alt="图形验证码" alt="图形验证码"
title="看不清?点击换一张" title="看不清?点击换一张"
class="h-[38px] w-28 shrink-0 cursor-pointer rounded-md border border-gray-300 object-cover" class="h-[38px] w-28 shrink-0 cursor-pointer rounded-md border border-gray-300 object-contain"
@click="refreshCaptcha" @click="refreshCaptcha"
/> />
<button <button
+14 -1
View File
@@ -35,6 +35,19 @@
<script setup lang="ts"> <script setup lang="ts">
const { isLoggedIn, handleAuthCode } = useHjcAuth() 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 loggedIn = ref(false)
const orders = shallowRef<any[]>([]) const orders = shallowRef<any[]>([])
const loaded = ref(false) const loaded = ref(false)
@@ -58,7 +71,7 @@ function payStatusClass(s?: number) {
loggedIn.value = isLoggedIn() loggedIn.value = isLoggedIn()
if (loggedIn.value) { if (loggedIn.value) {
// 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定) // 代理原样透传 ApiResult:按 body.code 判定(HTTP 状态码恒为 200,不可用于判定)
const res: any = await $fetch('/api/tender/my-orders') const res: any = await requestFetch('/api/tender/my-orders')
const auth = await handleAuthCode(res, '/tender/orders') const auth = await handleAuthCode(res, '/tender/orders')
if (auth.handled) { if (auth.handled) {
// 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token // 401 已清凭据并跳登录页;403 只提示「没有访问权限」,不清 token