Files
zhiyin/zhiyin-app/src/pages/user/user.vue
T
yuzhiran f955ecc71d fix: token 过期时前端自动清除登录态并跳转登录
根因: 页面使用 uni.request() 直接请求,401 时只有静默 catch,
不清除本地 token,导致界面显示已登录但后端拒接。

修复:
- 新增 utils/auth.ts: clearAuth() + checkAuth() 统一处理 401
- member.vue: refreshState / startGravityPay / startPlanPay /
  pollPayResult / activatePlan 全部添加 401 检测
- user.vue: fetchUserInfo / loadMemberStatus / loadStats /
  preCreateShare 添加 401 检测
- interview.vue: startInterview / sendAnswer / speakAiText /
  startRecord 添加 401 检测
2026-07-02 12:52:43 +08:00

408 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<view class="page fade-in">
<!-- 个人中心 -->
<view class="header" v-if="isLoggedIn">
<view class="profile-section">
<image class="avatar" :src="userInfo.avatar || '/static/avatar-default.png'" mode="aspectFill" />
<view class="profile-info">
<text class="nickname">{{ userInfo.nickname || '未设置昵称' }}</text>
<text class="user-id">ID: {{ maskedId }}</text>
<view class="plan-badge">{{ userInfo.plan || '免费版' }}</view>
</view>
<text class="header-arrow"></text>
</view>
<view class="stats-bar">
<view class="stat">
<text class="stat-num">{{ stats.interviewCount || 0 }}</text>
<text class="stat-label">模拟面试</text>
</view>
<view class="stat-divider"></view>
<view class="stat">
<text class="stat-num">{{ stats.avgScore || '--' }}</text>
<text class="stat-label">平均得分</text>
</view>
<view class="stat-divider"></view>
<view class="stat">
<text class="stat-num">{{ stats.completedCount || 0 }}</text>
<text class="stat-label">已完成</text>
</view>
</view>
</view>
<view class="header header-guest" v-else @click="goLogin">
<view class="guest-box">
<view class="guest-avatar"><text class="guest-icon">👤</text></view>
<view class="guest-info">
<text class="guest-name">未登录 / 点击登录</text>
</view>
<text class="header-arrow"></text>
</view>
</view>
<!-- 引力值卡片代替原配额卡片 -->
<view class="gravity-card" v-if="isLoggedIn">
<view class="gravity-card-inner">
<view class="gravity-top-row">
<view class="gravity-header">
<text class="gravity-icon"></text>
<text class="gravity-label">我的引力值</text>
</view>
<text class="gravity-num">{{ memberInfo.gravity ?? 0 }}</text>
</view>
<text class="gravity-hint">每次面试消耗 5 引力值 · 分享好友贡献面经可获得更多</text>
<view class="gravity-actions">
<text class="gravity-btn share" @click="goSharePage">分享得引力值</text>
<text class="gravity-btn contribute" @click="goContributePage">贡献面经</text>
<text class="gravity-btn h5buy" @click="goH5Buy">购买引力值</text>
</view>
</view>
</view>
<!-- 菜单列表 -->
<view class="menu-area">
<view class="menu-group">
<view class="menu-item" @click="requireLogin(goCareer, '择业顾问')">
<view class="menu-icon-wrap wrap-teal"><text class="menu-icon">🧭</text></view>
<text class="menu-text">择业顾问</text>
<text class="menu-tag">NEW</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="requireLogin(goHistory, '面试记录')">
<view class="menu-icon-wrap wrap-blue"><text class="menu-icon">📋</text></view>
<text class="menu-text">面试记录</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="requireLogin(goReviewReview, '面试复盘')">
<view class="menu-icon-wrap wrap-orange"><text class="menu-icon">🎙</text></view>
<text class="menu-text">面试复盘</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goVip">
<view class="menu-icon-wrap wrap-purple"><text class="menu-icon">💎</text></view>
<text class="menu-text">会员中心</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="requireLogin(goResume, '我的简历')">
<view class="menu-icon-wrap wrap-green"><text class="menu-icon">📄</text></view>
<text class="menu-text">我的简历</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="requireLogin(goSharePage, '我的分享')">
<view class="menu-icon-wrap wrap-orange"><text class="menu-icon">📤</text></view>
<text class="menu-text">我的分享</text>
<text class="menu-arrow"></text>
</view>
</view>
<view class="menu-group">
<!-- #ifdef MP-WEIXIN -->
<view class="menu-item">
<button class="contact-btn-inner" open-type="contact">
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon">💬</text></view>
<text class="menu-text">联系客服</text>
<text class="menu-arrow"></text>
</button>
</view>
<!-- #endif -->
<view class="menu-item" @click="goAbout">
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon"></text></view>
<text class="menu-text">关于</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" v-if="isAdmin" @click="goAdmin">
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon"></text></view>
<text class="menu-text">管理后台</text>
<text class="menu-arrow"></text>
</view>
</view>
<view class="logout-wrap" v-if="isLoggedIn">
<button class="logout-btn" @click="doLogout">退出登录</button>
</view>
</view>
<!-- 引力值获取方式 -->
<view class="modal-overlay" v-if="showGetGravityModal" @click="showGetGravityModal = false">
<view class="modal-content" @click.stop>
<text class="modal-title">获取引力值</text>
<text class="modal-hint">引力值可用于面试简历优化下载等</text>
<!-- 分享得引力值 -->
<view class="gp-get-method" @click="goSharePage">
<view class="gp-method-icon-wrap"><text class="gp-method-icon">🎁</text></view>
<view class="gp-method-info">
<text class="gp-method-name">分享给好友</text>
<text class="gp-method-desc">每成功邀请一位好友注册可得 5 引力值上不封顶</text>
</view>
<text class="gp-method-arrow"></text>
</view>
<!-- 贡献面经 -->
<view class="gp-get-method" @click="goContributePage">
<view class="gp-method-icon-wrap"><text class="gp-method-icon">📝</text></view>
<view class="gp-method-info">
<text class="gp-method-name">贡献面经</text>
<text class="gp-method-desc">每发布一篇面经可获得 3 引力值助力他人也提升自己</text>
</view>
<text class="gp-method-arrow"></text>
</view>
<!-- 购买引力值 -->
<view class="gp-get-method" @click="goH5Buy">
<view class="gp-method-icon-wrap"><text class="gp-method-icon">🛒</text></view>
<view class="gp-method-info">
<text class="gp-method-name">购买引力值</text>
<text class="gp-method-desc">¥5 一份微信支付安全便捷</text>
</view>
<text class="gp-method-arrow"></text>
</view>
<text class="modal-close" @click="showGetGravityModal = false">关闭</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
// #ifdef MP-WEIXIN
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
// #endif
// #ifndef MP-WEIXIN
import { onShow } from '@dcloudio/uni-app'
// #endif
import { api } from '../../config'
import { checkAuth } from '../../utils/auth'
// #ifdef MP-WEIXIN
const shareCodeRef = ref('')
onShareAppMessage(() => {
const path = shareCodeRef.value
? `/pages/user/user?share=${shareCodeRef.value}`
: '/pages/user/user'
return { title: '职引 - 个人中心 | AI模拟面试', path }
})
onShareTimeline(() => ({ title: '职引 - 个人中心 | AI模拟面试' }))
// #endif
const userInfo = ref({})
const isAdmin = ref(false)
const stats = ref({ interviewCount: 0, avgScore: '--', completedCount: 0 })
const token = ref('')
const memberInfo = ref({ plan: 'free', planName: '免费版', remaining: 0, gravity: 0 })
const isLoggedIn = computed(() => !!token.value)
const maskedId = computed(() => {
const id = userInfo.value.id || ''
return id.length > 6 ? `****${id.slice(-6)}` : id || '--'
})
const refreshState = () => {
token.value = uni.getStorageSync('token') || ''
if (!token.value) return
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s) } catch(e) {}
loadStats()
loadMemberStatus()
checkAdmin()
fetchUserInfo()
// #ifdef MP-WEIXIN
preCreateShare()
// #endif
}
// #ifdef MP-WEIXIN
async function preCreateShare() {
if (shareCodeRef.value) return // already have one
try {
const res = await uni.request({
url: api('/share/create'), method: 'POST',
data: { type: 'app', title: '我在AI磁场·职引练习面试', description: 'AI模拟面试+简历优化,快来一起提升吧' },
header: { Authorization: `Bearer ${token.value}` },
})
if (res.statusCode < 200 || res.statusCode >= 300) {
checkAuth(res)
return
}
const data = res.data?.data || res.data
if (data.shareCode) {
shareCodeRef.value = data.shareCode
}
} catch(e) { /* silent */ }
}
// #endif
const fetchUserInfo = async () => {
try {
const res = await uni.request({ url: api('/user/info'), method: 'GET', header: { 'Authorization': `Bearer ${token.value}` } })
if (res.statusCode === 200 && res.data) {
userInfo.value = res.data
uni.setStorageSync('userInfo', JSON.stringify(res.data))
} else if (checkAuth(res)) {
return // token 过期,已清除并跳转登录
}
} catch(e) { /* silent */ }
}
onMounted(refreshState)
onShow(refreshState)
const loadMemberStatus = async () => {
if (!token.value) return
try {
const res = await uni.request({ url: api('/member/status'), method: 'GET', header: { 'Authorization': `Bearer ${token.value}` } })
if (res.statusCode >= 200 && res.statusCode < 300 && res.data) {
memberInfo.value = { plan: res.data.plan || 'free', planName: res.data.planName || '免费版', remaining: res.data.remaining ?? 0, gravity: res.data.gravity ?? 0 }
} else if (checkAuth(res)) {
return
}
} catch(e) { /* silent */ }
}
const loadStats = async () => {
try {
const res = await uni.request({ url: api('/interview/stats/mine'), method: 'GET', header: { 'Authorization': `Bearer ${token.value}` } })
if (res.statusCode === 200) {
stats.value = res.data
} else if (checkAuth(res)) {
return
}
} catch(e) { console.error(e) }
}
const requireLogin = (action, name) => {
if (isLoggedIn.value) { action(); return }
uni.showModal({
title: '请先登录',
content: `需要登录后才能使用${name}功能`,
confirmText: '去登录',
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) }
})
}
const checkAdmin = () => {
isAdmin.value = userInfo.value.role === 'admin'
}
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
// 引力值获取
const showGetGravityModal = ref(false)
const openBuyModal = () => { showGetGravityModal.value = true }
const goH5Buy = () => {
uni.navigateTo({ url: '/pages/member/member' })
}
const goCareer = () => uni.navigateTo({ url: '/pages/career/career' })
const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
const goReviewReview = () => uni.navigateTo({ url: '/pages/review/review' })
const goVip = () => uni.navigateTo({ url: '/pages/member/member' })
const goResume = () => uni.navigateTo({ url: '/pages/resume/resume' })
const goSharePage = () => uni.navigateTo({ url: '/pages/share/share' })
const goContributePage = () => uni.navigateTo({ url: '/pages/contribute/contribute' })
const goAdmin = () => uni.navigateTo({ url: '/pages/admin/admin' })
const goAbout = () => uni.navigateTo({ url: '/pages/about/about' })
const doLogout = () => {
uni.showModal({
title: '退出登录', content: '确定要退出登录吗?',
success: (r) => { if (r.confirm) { uni.removeStorageSync('token'); uni.removeStorageSync('userInfo'); token.value = '' } }
})
}
</script>
<style scoped>
.page { height: 100%; overflow-y: auto; background: var(--color-bg); }
.header {
background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-mid) 50%, var(--color-gradient-end) 100%);
padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; min-height: 90rpx;
}
.profile-section { display: flex; align-items: center; margin-bottom: 36rpx; }
.avatar { width: 104rpx; height: 104rpx; border-radius: 50%; margin-right: 24rpx; border: 3rpx solid rgba(255,255,255,0.4); flex-shrink: 0; }
.profile-info { flex: 1; display: flex; flex-direction: column; }
.nickname { font-size: 34rpx; font-weight: 700; color: #FFFFFF; }
.plan-badge { font-size: 20rpx; color: rgba(255,255,255,0.9); background: rgba(255,255,255,0.2); padding: 4rpx 14rpx; border-radius: 8rpx; align-self: flex-start; margin-top: 8rpx; }
.user-id { font-size: 20rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; }
.header-arrow { font-size: 36rpx; color: rgba(255,255,255,0.5); }
.stats-bar { display: flex; align-items: center; background: rgba(255,255,255,0.15); border-radius: var(--radius-lg); padding: 24rpx 0; }
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
.stat-num { font-size: 34rpx; font-weight: 700; color: #FFFFFF; }
.stat-label { font-size: 20rpx; color: rgba(255,255,255,0.7); margin-top: 6rpx; }
.stat-divider { width: 1rpx; height: 44rpx; background: rgba(255,255,255,0.2); }
.header-guest { padding: 36rpx 32rpx 72rpx; min-height: 90rpx; }
.guest-box { display: flex; align-items: center; }
.guest-avatar { width: 96rpx; height: 96rpx; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.guest-icon { font-size: 40rpx; }
.guest-info { flex: 1; }
.guest-name { font-size: 30rpx; font-weight: 600; color: #FFFFFF; }
/* 引力值卡片(代替原配额卡片) */
.gravity-card { margin: -48rpx 32rpx 16rpx; position: relative; z-index: 1; }
.gravity-card-inner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-xl);
padding: 28rpx 24rpx 24rpx;
box-shadow: 0 8rpx 32rpx rgba(102,126,234,0.25);
}
.gravity-top-row { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8rpx; }
.gravity-header { display: flex; align-items: center; gap: 8rpx; }
.gravity-icon { font-size: 32rpx; }
.gravity-label { font-size: 24rpx; color: rgba(255,255,255,0.8); font-weight: 500; }
.gravity-num { font-size: 48rpx; font-weight: 800; color: #FFFFFF; line-height: 1; }
.gravity-hint { font-size: 20rpx; color: rgba(255,255,255,0.6); margin-bottom: 20rpx; }
.gravity-actions { display: flex; gap: 16rpx; }
.gravity-btn {
flex: 1; text-align: center; padding: 18rpx 0; border-radius: var(--radius-md);
font-size: 26rpx; font-weight: 600; white-space: nowrap;
}
.gravity-btn.share { background: rgba(255,255,255,0.2); color: #FFFFFF; border: 2rpx solid rgba(255,255,255,0.3); }
.gravity-btn.h5buy { background: #FFFFFF; color: #667eea; }
.gravity-btn.contribute { background: rgba(255,255,255,0.15); color: #FFFFFF; border: 2rpx solid rgba(255,255,255,0.2); }
.gravity-btn:active { transform: scale(0.96); }
.menu-area { padding: 0 32rpx 32rpx; margin-top: 8rpx; }
.menu-group { background: #FFFFFF; border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 24rpx; box-shadow: var(--shadow-sm); }
.menu-item { display: flex; align-items: center; padding: 28rpx 32rpx; border-bottom: 1rpx solid var(--color-border); }
.contact-btn-inner { width: 100%; background: transparent; border: none; border-radius: 0; padding: 0; margin: 0; line-height: inherit; font-size: inherit; text-align: left; display: flex; align-items: center; min-height: auto; }
.contact-btn-inner::after { border: none; }
.menu-item:last-child { border-bottom: none; }
.menu-item:active { background: #F9FAFB; }
.menu-icon-wrap { width: 60rpx; height: 60rpx; border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.menu-icon { font-size: 28rpx; }
.menu-text { flex: 1; font-size: 28rpx; color: var(--color-text); font-weight: 500; }
.menu-tag { font-size: 18rpx; color: #fff; background: var(--color-primary); padding: 2rpx 12rpx; border-radius: 20rpx; margin-right: 12rpx; }
.menu-arrow { font-size: 32rpx; color: #D1D5DB; }
.wrap-blue { background: #EEF2FF; }
.wrap-purple { background: #F5F3FF; }
.wrap-green { background: #ECFDF5; }
.wrap-orange { background: #FFF7ED; }
.wrap-gray { background: #F3F4F6; }
.wrap-teal { background: #E6FFFA; }
.logout-wrap { margin-top: 8rpx; }
.logout-btn { background: #FFFFFF; color: var(--color-error); font-size: 28rpx; font-weight: 500; border-radius: var(--radius-md); height: 88rpx; line-height: 88rpx; border: 2rpx solid #FECACA; }
.logout-btn:active { background: #FEF2F2; transform: scale(0.96); }
/* 弹窗通用 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal-content { background: #FFF; border-radius: var(--radius-xl); padding: 40rpx 32rpx; width: 600rpx; max-height: 80vh; overflow-y: auto; display: flex; flex-direction: column; align-items: center; gap: 16rpx; }
.modal-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
.modal-hint { font-size: 22rpx; color: #6B7280; text-align: center; }
.modal-close { font-size: 24rpx; color: #9CA3AF; padding: 12rpx 24rpx; }
/* 获取引力值弹窗 - 分享/贡献方法 */
.gp-get-method {
width: 100%; display: flex; align-items: center; gap: 16rpx;
background: #F9FAFB; border-radius: var(--radius-md); padding: 20rpx;
border: 2rpx solid #E5E7EB; margin-top: 8rpx;
}
.gp-get-method:active { transform: scale(0.97); background: #F3F4F6; }
.gp-method-icon-wrap {
width: 64rpx; height: 64rpx; border-radius: var(--radius-md);
background: #EEF2FF; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.gp-method-icon { font-size: 32rpx; }
.gp-method-info { flex: 1; display: flex; flex-direction: column; gap: 4rpx; }
.gp-method-name { font-size: 26rpx; font-weight: 600; color: var(--color-text); }
.gp-method-desc { font-size: 20rpx; color: #6B7280; line-height: 1.4; }
.gp-method-arrow { font-size: 32rpx; color: #D1D5DB; }
</style>