Files
hjc-web/app/composables/useHjcAuth.ts
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

187 lines
7.0 KiB
TypeScript
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.
import type { HjcUser } from '~/types/tender'
/**
* 后端 ApiResult.code 的登录态语义。
*
* <b>HTTP 状态码恒为 200</b>,失败一律靠 body 的 code 判定:
* - 0 成功
* - 401 未登录或 token 失效 → 清本地凭据并跳登录页
* - 403 已登录但无权限(message「没有访问权限」)→ 只提示,<b>不清 token、不跳登录页</b>
* - 其他非 0 → 业务失败,展示 message
*/
export const HJC_CODE_UNAUTHORIZED = 401
export const HJC_CODE_FORBIDDEN = 403
/** 登录凭据有效期(秒),token 与随行的展示信息同寿命 */
const AUTH_MAX_AGE = 60 * 60 * 24 * 7
/** 登录态(token 存 cookieSSR 安全;服务器端代理从 cookie 读取并转发 Authorization */
export function useHjcAuth() {
const token = useCookie<string | null>('hjc_token', {
maxAge: AUTH_MAX_AGE,
sameSite: 'lax'
})
/**
* 展示用登录用户信息,与 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() {
return !!token.value
}
/** 写入本次登录/注册拿到的凭据(token + 展示信息),内存与 cookie 一起写 */
function setCredentials(accessToken: string, loginUser: HjcUser | null) {
token.value = accessToken
user.value = loginUser
userCookie.value = loginUser
}
/** 清本地凭据(token 是无状态 JWT,后端无会话,清本地即退出) */
function clearCredentials() {
token.value = null
user.value = null
userCookie.value = null
}
/**
* 统一处理 ApiResult 的登录态失败:**只看 body.code,不看 HTTP 状态码**。
*
* @param res 服务器代理透传的 ApiResult
* @param redirect 401 跳登录页时带上,便于登录后回跳
* @returns handled=true 表示已按登录态问题处理完(401 已跳转 / 403 带提示文案);
* handled=false 表示不是登录态问题,交调用方按业务失败处理
*/
async function handleAuthCode(
res: any,
redirect = '/'
): Promise<{ handled: boolean; message?: string }> {
const code = Number(res?.code)
if (code === HJC_CODE_UNAUTHORIZED) {
// 注意:这一分支会写 cookie(服务端渲染时即 SSR 响应里的 Set-Cookie 清除)。
// 因此凡是**服务端渲染期间**发出的、需要登录态的站内请求,必须用
// `useRequestFetch()` 而不是裸 `$fetch`——裸 $fetch 不会把浏览器 Cookie 转发给
// 站内 /api 代理,代理会变成匿名调用并拿到 code=401,从而被这里误判为
// 「凭据失效」:刷新页面 = 把用户登出。参见 app/pages/tender/orders/index.vue。
clearCredentials()
await navigateTo(`/login?redirect=${encodeURIComponent(redirect)}`)
return { handled: true }
}
if (code === HJC_CODE_FORBIDDEN) {
// 已登录但无权限:不清 token、不跳登录页,仅提示后端 message
return { handled: true, message: res?.message || '没有访问权限' }
}
return { handled: false }
}
/**
* 拉取登录用图形验证码。
* 后端只返回图片(data:image/png;base64,...),不下发答案,需用户肉眼识别后输入。
*/
async function fetchCaptcha(): Promise<{ ok: boolean; image?: string; message?: string }> {
try {
// 带时间戳,避免浏览器/中间层缓存旧验证码图片
const res: any = await $fetch('/api/tender/captcha', { query: { t: Date.now() } })
if (res?.code === 0 && res.data?.image) {
return { ok: true, image: res.data.image }
}
return { ok: false, message: res?.message || '验证码获取失败' }
} catch (e: any) {
return { ok: false, message: e?.data?.statusMessage || e?.data?.message || e?.message || '验证码获取失败' }
}
}
/**
* 发送注册短信验证码。
* @param phone 经办人手机号(即账号手机号),后端据此发码
*/
async function sendSms(phone: string): Promise<{ ok: boolean; message?: string }> {
try {
const res: any = await $fetch('/api/tender/sms', {
method: 'POST',
body: { phone }
})
if (res?.code === 0) {
return { ok: true, message: res?.message || '验证码已发送' }
}
return { ok: false, message: res?.message || '验证码发送失败' }
} catch (e: any) {
return { ok: false, message: e?.data?.statusMessage || e?.data?.message || e?.message || '验证码发送失败' }
}
}
/**
* 企业登录
* @param code 图形验证码(必填,后端先校验非空)
*/
async function login(enterpriseName: string, password: string, code: string) {
try {
const res: any = await $fetch('/api/tender/login', {
method: 'POST',
body: { enterpriseName, password, code }
})
// mp-api 统一返回 ApiResult{code,message,data}code===0 成功
if (res?.code === 0 && res.data?.access_token) {
setCredentials(res.data.access_token, res.data.user || null)
return { ok: true, user: res.data.user }
}
return { ok: false, message: res?.message || '登录失败' }
} catch (e: any) {
return { ok: false, message: e?.data?.statusMessage || e?.message || '登录失败' }
}
}
/**
* 企业注册(一站式)
* @param payload 注册字段 + `code`(短信验证码,发给经办人手机号)
*/
async function register(payload: Record<string, any>) {
try {
const res: any = await $fetch('/api/tender/register', {
method: 'POST',
body: payload
})
if (res?.code === 0 && res.data?.access_token) {
setCredentials(res.data.access_token, res.data.user || null)
return { ok: true, user: res.data.user }
}
return { ok: false, message: res?.message || '注册失败' }
} catch (e: any) {
return { ok: false, message: e?.data?.statusMessage || e?.message || '注册失败' }
}
}
/** 退出登录:先通知后端(无服务端会话,仅为契约完整),再清本地凭据 */
async function logout() {
try {
await $fetch('/api/tender/logout', { method: 'POST' })
} catch {
// 退出接口失败不阻断本地退出:token 是无状态 JWT,清本地凭据即已登出
}
clearCredentials()
}
return {
token,
user,
isLoggedIn,
clearCredentials,
handleAuthCode,
fetchCaptcha,
sendSms,
login,
register,
logout
}
}