f955ecc71d
根因: 页面使用 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 检测
408 lines
18 KiB
Vue
408 lines
18 KiB
Vue
<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>
|