feat: AI 择业顾问 MVP — 专业分析 + 岗位匹配 + 多轮对话
- backend: career-advice module with analyze/chat/positions endpoints - frontend: career.vue page with profile form, AI advice, recommendation cards - config/api/pages/user.vue: full integration into existing flow - docs: PROJECT-STATUS v4.5, FEATURE-LIST v4.3, ROADMAP v4.3 - AGENTS.md: updated module count and career link paths
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 输入表单 -->
|
||||
<view v-if="step === 'input'" class="input-wrap">
|
||||
<view class="hero">
|
||||
<text class="hero-icon">🧭</text>
|
||||
<text class="hero-title">择业顾问</text>
|
||||
<text class="hero-desc">AI 帮你分析专业前景,规划职业方向</text>
|
||||
</view>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-group">
|
||||
<text class="form-label">你的专业 <text class="required">*</text></text>
|
||||
<input class="form-input" v-model="profile.major" placeholder="例如:计算机科学与技术" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">年级</text>
|
||||
<picker :range="grades" @change="e => profile.grade = grades[e.detail.value]">
|
||||
<view class="form-input select-trigger">{{ profile.grade || '请选择年级' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">兴趣/擅长方向</text>
|
||||
<input class="form-input" v-model="profile.interests" placeholder="例如:后端开发、数据分析" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">成绩/GPA</text>
|
||||
<input class="form-input" v-model="profile.gpa" placeholder="例如:3.5/4.0 或专业前 20%" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">你的困惑或目标</text>
|
||||
<textarea class="form-textarea" v-model="profile.goal" placeholder="例如:不知道该考研还是直接就业,对AI行业很感兴趣但不知道从何入手..." />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="submit-btn" :disabled="!profile.major.trim() || loading" @click="doAnalyze">
|
||||
<text v-if="!loading">开始分析</text>
|
||||
<text v-else>AI 分析中...</text>
|
||||
</button>
|
||||
<text v-if="error" class="error-text">{{ error }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 分析结果 -->
|
||||
<view v-if="step === 'result'" class="result-wrap">
|
||||
<view class="result-header">
|
||||
<text class="result-icon">📊</text>
|
||||
<text class="result-title">择业分析报告</text>
|
||||
</view>
|
||||
|
||||
<view class="advice-card">
|
||||
<view class="advice-content">{{ result.reply }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result.careerPaths && result.careerPaths.length > 0" class="section">
|
||||
<text class="section-title">推荐方向</text>
|
||||
<view class="path-card" v-for="(path, i) in result.careerPaths" :key="i"
|
||||
@click="goInterview(path.name)">
|
||||
<view class="path-rank">{{ i + 1 }}</view>
|
||||
<view class="path-body">
|
||||
<text class="path-name">{{ path.name }}</text>
|
||||
<text class="path-reason">{{ path.reason }}</text>
|
||||
</view>
|
||||
<view class="path-score-wrap">
|
||||
<text class="path-score-label">匹配度</text>
|
||||
<text class="path-score">{{ path.matchScore }}%</text>
|
||||
<text v-if="path.salary" class="path-salary">{{ path.salary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="path-hint">点击卡片可进入该岗位的模拟面试</view>
|
||||
</view>
|
||||
|
||||
<view class="action-bar">
|
||||
<button class="action-btn chat-btn" @click="step = 'chat'; chatMsg = ''">继续咨询</button>
|
||||
<button class="action-btn retry-btn" @click="step = 'input'; result = null">重新分析</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 继续对话 -->
|
||||
<view v-if="step === 'chat'" class="chat-wrap">
|
||||
<view class="chat-header">
|
||||
<text class="chat-back" @click="step = 'result'">‹ 返回</text>
|
||||
<text class="chat-title">继续咨询</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="chat-msgs" scroll-y :scroll-into-view="'msg-' + (chatHistory.length - 1)">
|
||||
<view v-for="(msg, i) in chatHistory" :key="i" :id="'msg-' + i"
|
||||
:class="'msg-bubble ' + (msg.role === 'user' ? 'msg-user' : 'msg-ai')">
|
||||
<text class="msg-text">{{ msg.content }}</text>
|
||||
</view>
|
||||
<view v-if="chatLoading" class="msg-bubble msg-ai">
|
||||
<text class="msg-text typing">AI 思考中...</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="chat-input-bar">
|
||||
<input class="chat-input" v-model="chatMsg" placeholder="输入你的问题..." @confirm="doChat" />
|
||||
<button class="chat-send" @click="doChat" :disabled="!chatMsg.trim() || chatLoading">发送</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { api } from '../../config'
|
||||
|
||||
const grades = ['大一', '大二', '大三', '大四', '研一', '研二', '研三', '已毕业']
|
||||
|
||||
const step = ref('input')
|
||||
const loading = ref(false)
|
||||
const chatLoading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref(null)
|
||||
const chatMsg = ref('')
|
||||
const chatHistory = ref([])
|
||||
|
||||
const profile = reactive({
|
||||
major: '',
|
||||
grade: '',
|
||||
interests: '',
|
||||
gpa: '',
|
||||
goal: '',
|
||||
})
|
||||
|
||||
const token = ref('')
|
||||
onShow(() => {
|
||||
token.value = uni.getStorageSync('token') || ''
|
||||
if (!token.value) {
|
||||
uni.showModal({
|
||||
title: '请先登录',
|
||||
content: '需要登录后才能使用择业顾问功能',
|
||||
confirmText: '去登录',
|
||||
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const doAnalyze = async () => {
|
||||
if (!profile.major.trim()) {
|
||||
error.value = '请填写你的专业'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api('/career-advice/analyze'),
|
||||
method: 'POST',
|
||||
data: { ...profile },
|
||||
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.statusCode === 200) {
|
||||
if (res.data.error) {
|
||||
error.value = res.data.error
|
||||
return
|
||||
}
|
||||
result.value = res.data
|
||||
step.value = 'result'
|
||||
chatHistory.value = [
|
||||
{ role: 'user', content: `我是${profile.major}专业${profile.grade ? '的' + profile.grade : ''}学生,想了解职业发展方向` },
|
||||
{ role: 'assistant', content: res.data.reply },
|
||||
]
|
||||
} else {
|
||||
error.value = (res.data && res.data.message) || '分析失败,请稍后重试'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = '网络错误,请检查网络连接'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const doChat = async () => {
|
||||
if (!chatMsg.value.trim() || chatLoading.value) return
|
||||
const msg = chatMsg.value.trim()
|
||||
chatMsg.value = ''
|
||||
chatHistory.value.push({ role: 'user', content: msg })
|
||||
chatLoading.value = true
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api('/career-advice/chat'),
|
||||
method: 'POST',
|
||||
data: { message: msg, history: chatHistory.value.slice(0, -1) },
|
||||
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.statusCode === 200) {
|
||||
chatHistory.value.push({ role: 'assistant', content: res.data.reply || (res.data.error || '') })
|
||||
} else {
|
||||
chatHistory.value.push({ role: 'assistant', content: '回复失败,请稍后重试' })
|
||||
}
|
||||
} catch {
|
||||
chatHistory.value.push({ role: 'assistant', content: '网络错误,请检查网络连接' })
|
||||
} finally {
|
||||
chatLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goInterview = (position) => {
|
||||
uni.navigateTo({ url: `/pages/interview/interview?position=${encodeURIComponent(position)}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; background: var(--color-bg); padding-bottom: 40rpx; }
|
||||
|
||||
/* ===== Input ===== */
|
||||
.input-wrap { padding: 0 32rpx; }
|
||||
.hero { display: flex; flex-direction: column; align-items: center; padding: 48rpx 0 32rpx; }
|
||||
.hero-icon { font-size: 72rpx; }
|
||||
.hero-title { font-size: 40rpx; font-weight: 700; color: var(--color-text); margin-top: 16rpx; }
|
||||
.hero-desc { font-size: 26rpx; color: var(--color-secondary); margin-top: 8rpx; }
|
||||
|
||||
.form-card { background: #fff; border-radius: var(--radius-lg); padding: 32rpx; box-shadow: var(--shadow-sm); }
|
||||
.form-group { margin-bottom: 28rpx; }
|
||||
.form-group:last-child { margin-bottom: 0; }
|
||||
.form-label { font-size: 26rpx; font-weight: 600; color: var(--color-text); display: block; margin-bottom: 12rpx; }
|
||||
.required { color: var(--color-error); }
|
||||
.form-input { width: 100%; height: 80rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-md); padding: 0 24rpx; font-size: 26rpx; color: var(--color-text); box-sizing: border-box; background: #FAFBFC; }
|
||||
.select-trigger { display: flex; align-items: center; }
|
||||
.form-textarea { width: 100%; height: 160rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-md); padding: 20rpx 24rpx; font-size: 26rpx; color: var(--color-text); box-sizing: border-box; background: #FAFBFC; resize: none; }
|
||||
|
||||
.submit-btn { width: 100%; height: 88rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #fff; font-size: 30rpx; font-weight: 600; border-radius: var(--radius-lg); margin-top: 32rpx; display: flex; align-items: center; justify-content: center; border: none; }
|
||||
.submit-btn:active { transform: scale(0.98); opacity: 0.9; }
|
||||
.submit-btn[disabled] { opacity: 0.5; }
|
||||
.error-text { display: block; text-align: center; color: var(--color-error); font-size: 24rpx; margin-top: 16rpx; }
|
||||
|
||||
/* ===== Result ===== */
|
||||
.result-wrap { padding: 0 32rpx; }
|
||||
.result-header { display: flex; flex-direction: column; align-items: center; padding: 32rpx 0; }
|
||||
.result-icon { font-size: 56rpx; }
|
||||
.result-title { font-size: 34rpx; font-weight: 700; color: var(--color-text); margin-top: 12rpx; }
|
||||
|
||||
.advice-card { background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%); border-radius: var(--radius-lg); padding: 32rpx; margin-bottom: 24rpx; }
|
||||
.advice-content { font-size: 26rpx; color: #374151; line-height: 1.8; white-space: pre-wrap; }
|
||||
|
||||
.section { margin-bottom: 24rpx; }
|
||||
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); display: block; margin-bottom: 16rpx; }
|
||||
|
||||
.path-card { display: flex; align-items: center; background: #fff; border-radius: var(--radius-lg); padding: 24rpx; margin-bottom: 16rpx; box-shadow: var(--shadow-sm); }
|
||||
.path-card:active { transform: scale(0.98); background: #F9FAFB; }
|
||||
.path-rank { width: 48rpx; height: 48rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #fff; font-size: 24rpx; font-weight: 700; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-right: 16rpx; }
|
||||
.path-body { flex: 1; }
|
||||
.path-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); display: block; }
|
||||
.path-reason { font-size: 22rpx; color: var(--color-secondary); margin-top: 4rpx; display: block; line-height: 1.4; }
|
||||
.path-score-wrap { display: flex; flex-direction: column; align-items: center; margin-left: 12rpx; flex-shrink: 0; }
|
||||
.path-score-label { font-size: 20rpx; color: var(--color-secondary); }
|
||||
.path-score { font-size: 32rpx; font-weight: 700; color: var(--color-primary); }
|
||||
.path-salary { font-size: 20rpx; color: var(--color-success); margin-top: 2rpx; }
|
||||
.path-hint { font-size: 22rpx; color: var(--color-secondary); text-align: center; margin-top: -8rpx; margin-bottom: 16rpx; }
|
||||
|
||||
.action-bar { display: flex; gap: 16rpx; }
|
||||
.action-btn { flex: 1; height: 80rpx; font-size: 28rpx; font-weight: 600; border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; }
|
||||
.chat-btn { background: var(--color-primary); color: #fff; }
|
||||
.retry-btn { background: #fff; color: var(--color-text); border: 2rpx solid var(--color-border); }
|
||||
|
||||
/* ===== Chat ===== */
|
||||
.chat-wrap { display: flex; flex-direction: column; height: 100vh; }
|
||||
.chat-header { display: flex; align-items: center; padding: 24rpx 32rpx; background: #fff; border-bottom: 1rpx solid var(--color-border); }
|
||||
.chat-back { font-size: 28rpx; color: var(--color-primary); margin-right: 24rpx; }
|
||||
.chat-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); flex: 1; }
|
||||
|
||||
.chat-msgs { flex: 1; padding: 24rpx 32rpx; overflow-y: auto; }
|
||||
.msg-bubble { max-width: 80%; margin-bottom: 20rpx; padding: 20rpx 24rpx; border-radius: var(--radius-md); font-size: 26rpx; line-height: 1.6; }
|
||||
.msg-user { background: var(--color-primary); color: #fff; align-self: flex-end; margin-left: auto; border-radius: var(--radius-md) var(--radius-md) 4rpx var(--radius-md); }
|
||||
.msg-ai { background: #fff; color: var(--color-text); box-shadow: var(--shadow-sm); border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4rpx; }
|
||||
.typing { color: var(--color-secondary); }
|
||||
|
||||
.chat-input-bar { display: flex; align-items: center; padding: 16rpx 32rpx; background: #fff; border-top: 1rpx solid var(--color-border); gap: 16rpx; }
|
||||
.chat-input { flex: 1; height: 72rpx; border: 2rpx solid var(--color-border); border-radius: 36rpx; padding: 0 24rpx; font-size: 26rpx; }
|
||||
.chat-send { height: 72rpx; padding: 0 32rpx; background: var(--color-primary); color: #fff; font-size: 26rpx; font-weight: 600; border-radius: 36rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.chat-send[disabled] { opacity: 0.5; }
|
||||
</style>
|
||||
+207
-200
@@ -1,206 +1,213 @@
|
||||
<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>
|
||||
<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="menu-area">
|
||||
<view class="menu-group">
|
||||
<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>
|
||||
<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>
|
||||
<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="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(goShare, '我的分享')">
|
||||
<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">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { api } from '../../config'
|
||||
|
||||
const userInfo = ref({})
|
||||
const isAdmin = ref(false)
|
||||
const stats = ref({ interviewCount: 0, avgScore: '--', completedCount: 0 })
|
||||
const token = ref('')
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
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()
|
||||
checkAdmin()
|
||||
// Fetch fresh user info from API to update stale cache (e.g. credits changed after interview)
|
||||
fetchUserInfo()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
} catch(e) { /* silent - cached data is fallback */ }
|
||||
}
|
||||
|
||||
onMounted(refreshState)
|
||||
onShow(refreshState)
|
||||
|
||||
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
|
||||
} 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' })
|
||||
<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(goShare, '我的分享')">
|
||||
<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">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { api } from '../../config'
|
||||
|
||||
const userInfo = ref({})
|
||||
const isAdmin = ref(false)
|
||||
const stats = ref({ interviewCount: 0, avgScore: '--', completedCount: 0 })
|
||||
const token = ref('')
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
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()
|
||||
checkAdmin()
|
||||
fetchUserInfo()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
} catch(e) { /* silent */ }
|
||||
}
|
||||
|
||||
onMounted(refreshState)
|
||||
onShow(refreshState)
|
||||
|
||||
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
|
||||
} 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 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 goShare = () => uni.navigateTo({ url: '/pages/share/share' })
|
||||
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; }
|
||||
.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; }
|
||||
.guest-hint { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 4rpx; }
|
||||
|
||||
.menu-area { padding: 0 32rpx 32rpx; margin-top: -40rpx; }
|
||||
.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); }
|
||||
.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-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; }
|
||||
.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); }
|
||||
</style>
|
||||
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 goShare = () => uni.navigateTo({ url: '/pages/share/share' })
|
||||
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; }
|
||||
.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; }
|
||||
|
||||
.menu-area { padding: 0 32rpx 32rpx; margin-top: -40rpx; }
|
||||
.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); }
|
||||
.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); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user