319 lines
13 KiB
Vue
319 lines
13 KiB
Vue
<template>
|
||
<view class="page fade-in">
|
||
<view class="hero">
|
||
<text class="hero-title">进步轨迹</text>
|
||
<text class="hero-sub">每次面试都在变强 💪</text>
|
||
</view>
|
||
|
||
<!-- 概览卡片 -->
|
||
<view class="overview card">
|
||
<view class="ov-row">
|
||
<view class="ov-item">
|
||
<text class="ov-num">{{ stats.completedInterviews || 0 }}</text>
|
||
<text class="ov-label">完成面试</text>
|
||
</view>
|
||
<view class="ov-divider"></view>
|
||
<view class="ov-item">
|
||
<text class="ov-num accent">{{ stats.avgScore || 0 }}</text>
|
||
<text class="ov-label">平均分</text>
|
||
</view>
|
||
<view class="ov-divider"></view>
|
||
<view class="ov-item">
|
||
<text class="ov-num streak">{{ stats.streak || 0 }}</text>
|
||
<text class="ov-label">连击🔥</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 四维能力雷达图 -->
|
||
<view class="section">
|
||
<view class="section-header">
|
||
<text class="section-title">能力维度</text>
|
||
</view>
|
||
<view class="radar-card card">
|
||
<view class="radar-grid">
|
||
<view class="dim-item" v-for="dim in dimensions" :key="dim.key">
|
||
<view class="dim-bar-bg">
|
||
<view
|
||
class="dim-bar-fill"
|
||
:style="{ width: dim.value + '%', background: dim.color }"
|
||
></view>
|
||
</view>
|
||
<view class="dim-info">
|
||
<text class="dim-name">{{ dim.label }}</text>
|
||
<text class="dim-score">{{ dim.value }}分</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 技能缺口分析 -->
|
||
<view class="section" v-if="skillsGap">
|
||
<view class="section-header">
|
||
<text class="section-title">技能缺口</text>
|
||
<text class="section-desc">{{ skillsGap.assessment }}</text>
|
||
</view>
|
||
<view class="gap-card card">
|
||
<view class="gap-item" v-for="g in skillsGap.gaps" :key="g.dimension">
|
||
<view class="gap-header">
|
||
<text class="gap-name">{{ g.dimension }}</text>
|
||
<text class="gap-badge" :class="g.level === '严重不足' ? 'badge-danger' : g.level === '需提升' ? 'badge-warn' : 'badge-ok'">{{ g.level }}</text>
|
||
</view>
|
||
<view class="gap-bar-bg">
|
||
<view class="gap-bar-fill" :style="{ width: (g.currentScore / g.targetScore * 100) + '%' }"></view>
|
||
<view class="gap-target" :style="{ left: '100%' }">▎</view>
|
||
</view>
|
||
<view class="gap-info">
|
||
<text class="gap-score">当前 {{ g.currentScore }}分</text>
|
||
<text class="gap-target-text">目标 {{ g.targetScore }}分</text>
|
||
<text class="gap-diff" v-if="g.gap > 0">差 {{ g.gap }}分</text>
|
||
</view>
|
||
</view>
|
||
<view class="gap-suggestions" v-if="skillsGap.suggestions.length">
|
||
<text class="gap-suggest-title">提升建议</text>
|
||
<view class="suggest-item" v-for="s in skillsGap.suggestions" :key="s.dimension">
|
||
<text class="suggest-dim">{{ s.dimension }}:</text>
|
||
<text class="suggest-tip">{{ s.tip }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 打卡日历 -->
|
||
<view class="section">
|
||
<view class="section-header">
|
||
<text class="section-title">打卡记录</text>
|
||
<text class="section-desc">连续 {{ stats.streak || 0 }} 天</text>
|
||
</view>
|
||
<view class="streak-card card">
|
||
<view class="streak-grid">
|
||
<view
|
||
v-for="(day, idx) in weekDays"
|
||
:key="idx"
|
||
class="streak-day"
|
||
:class="{ active: day.done, today: day.isToday }"
|
||
>
|
||
<view class="day-dot" v-if="day.done">✓</view>
|
||
<view class="day-dot empty" v-else>·</view>
|
||
<text class="day-label">{{ day.label }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="streak-motivation" v-if="stats.streak >= 3">
|
||
<text>🔥 连续 {{ stats.streak }} 天模拟面试!继续保持!</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 最近面试 -->
|
||
<view class="section">
|
||
<view class="section-header">
|
||
<text class="section-title">最近面试</text>
|
||
</view>
|
||
<view class="recent-list" v-if="progress.interviews && progress.interviews.length > 0">
|
||
<view class="recent-item card" v-for="item in progress.interviews" :key="item.id" @click="viewReport(item.id)">
|
||
<view class="recent-left">
|
||
<text class="recent-pos">{{ item.position }}</text>
|
||
<text class="recent-date">{{ formatDate(item.date) }}</text>
|
||
</view>
|
||
<view class="recent-right">
|
||
<text class="recent-score" :class="scoreClass(item.totalScore)">{{ item.totalScore }}分</text>
|
||
<text class="recent-arrow">›</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="empty" v-else>
|
||
<text class="empty-icon">🎯</text>
|
||
<text class="empty-text">还没有面试记录,快去模拟一场吧</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="bottom-spacer"></view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted } from 'vue'
|
||
import { onShow } from '@dcloudio/uni-app'
|
||
import { api } from '../../config'
|
||
|
||
const stats = ref({ completedInterviews: 0, avgScore: 0, streak: 0 })
|
||
const progress = ref({ dimensions: {}, interviews: [], recentScores: [] })
|
||
const skillsGap = ref(null)
|
||
const dimensions = ref([
|
||
{ key: 'logic', label: '逻辑思维', value: 0, color: 'linear-gradient(90deg, #6366F1, #818CF8)' },
|
||
{ key: 'expression', label: '表达能力', value: 0, color: 'linear-gradient(90deg, #10B981, #34D399)' },
|
||
{ key: 'professionalism', label: '专业度', value: 0, color: 'linear-gradient(90deg, #F59E0B, #FBBF24)' },
|
||
{ key: 'stability', label: '稳定性', value: 0, color: 'linear-gradient(90deg, #EF4444, #F87171)' },
|
||
])
|
||
|
||
// 最近7天打卡
|
||
const weekDays = ref([])
|
||
|
||
const token = () => uni.getStorageSync('token') || ''
|
||
|
||
onMounted(async () => {
|
||
const t = token()
|
||
if (!t) return
|
||
|
||
await loadProgressData()
|
||
})
|
||
|
||
onShow(async () => {
|
||
if (token()) await loadProgressData()
|
||
})
|
||
|
||
async function loadProgressData() {
|
||
const t = token()
|
||
if (!t) return
|
||
try {
|
||
// Load progress
|
||
const res = await uni.request({
|
||
url: api('/progress'), method: 'GET',
|
||
header: { 'Authorization': `Bearer ${t}` }
|
||
})
|
||
if (res.statusCode === 200) {
|
||
const d = res.data
|
||
progress.value = d
|
||
dimensions.value = dimensions.value.map(dim => ({
|
||
...dim,
|
||
value: d.dimensions?.[dim.key] || 0,
|
||
}))
|
||
}
|
||
} catch (e) { console.error(e) }
|
||
|
||
try {
|
||
const gapRes = await uni.request({
|
||
url: api('/analyze/skills-gap'), method: 'POST',
|
||
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
|
||
data: {},
|
||
})
|
||
if (gapRes.statusCode === 200) skillsGap.value = gapRes.data
|
||
} catch (e) { /* skills gap not available */ }
|
||
|
||
try {
|
||
// Load stats
|
||
const sres = await uni.request({
|
||
url: api('/progress/stats'), method: 'GET',
|
||
header: { 'Authorization': `Bearer ${t}` }
|
||
})
|
||
if (sres.statusCode === 200) {
|
||
stats.value = sres.data
|
||
}
|
||
} catch (e) { console.error(e) }
|
||
|
||
buildWeekDays()
|
||
}
|
||
|
||
function buildWeekDays() {
|
||
const days = ['日', '一', '二', '三', '四', '五', '六']
|
||
const today = new Date()
|
||
const arr = []
|
||
const checkinDates = (progress.value.checkins || []).map((c) => {
|
||
const d = new Date(c.date || c.createdAt)
|
||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
|
||
})
|
||
for (let i = 6; i >= 0; i--) {
|
||
const d = new Date(today)
|
||
d.setDate(d.getDate() - i)
|
||
const key = `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
|
||
arr.push({
|
||
label: days[d.getDay()],
|
||
isToday: i === 0,
|
||
done: checkinDates.includes(key),
|
||
})
|
||
}
|
||
weekDays.value = arr
|
||
}
|
||
|
||
const formatDate = (d) => {
|
||
if (!d) return ''
|
||
const date = new Date(d)
|
||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||
}
|
||
const scoreClass = (s) => s >= 80 ? 'score-high' : s >= 60 ? 'score-mid' : 'score-low'
|
||
const viewReport = (id) => uni.navigateTo({ url: `/pages/report/report?interviewId=${id}` })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page { min-height: 100vh; background: var(--color-bg); }
|
||
.hero {
|
||
background: linear-gradient(135deg, #6366F1, #8B5CF6, #A78BFA);
|
||
padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx;
|
||
}
|
||
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; display: block; }
|
||
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
|
||
|
||
.overview { margin: -40rpx 32rpx 0; border-radius: var(--radius-xl); padding: 32rpx; }
|
||
.ov-row { display: flex; align-items: center; justify-content: space-around; }
|
||
.ov-item { display: flex; flex-direction: column; align-items: center; }
|
||
.ov-num { font-size: 48rpx; font-weight: 800; color: var(--color-primary); }
|
||
.ov-num.accent { color: #10B981; }
|
||
.ov-num.streak { color: #F59E0B; }
|
||
.ov-label { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 4rpx; }
|
||
.ov-divider { width: 1rpx; height: 60rpx; background: var(--color-border); }
|
||
|
||
.section { padding: 32rpx 32rpx 0; }
|
||
.section-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; }
|
||
.section-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
||
.section-desc { font-size: 22rpx; color: var(--color-text-tertiary); }
|
||
|
||
.radar-card { padding: 32rpx; border-radius: var(--radius-xl); }
|
||
.radar-grid { display: flex; flex-direction: column; gap: 24rpx; }
|
||
.dim-item { display: flex; flex-direction: column; gap: 8rpx; }
|
||
.dim-info { display: flex; justify-content: space-between; }
|
||
.dim-name { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||
.dim-score { font-size: 24rpx; font-weight: 700; color: var(--color-primary); }
|
||
.dim-bar-bg { height: 16rpx; background: #F3F4F6; border-radius: 8rpx; overflow: hidden; }
|
||
.dim-bar-fill { height: 100%; border-radius: 8rpx; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); }
|
||
|
||
.gap-card { padding: 32rpx; border-radius: var(--radius-xl); }
|
||
.gap-item { margin-bottom: 24rpx; }
|
||
.gap-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
|
||
.gap-name { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||
.gap-badge { font-size: 20rpx; font-weight: 600; padding: 2rpx 12rpx; border-radius: 20rpx; }
|
||
.badge-danger { background: #FEE2E2; color: #DC2626; }
|
||
.badge-warn { background: #FEF3C7; color: #D97706; }
|
||
.badge-ok { background: #D1FAE5; color: #059669; }
|
||
.gap-bar-bg { height: 20rpx; background: #F3F4F6; border-radius: 10rpx; position: relative; overflow: visible; }
|
||
.gap-bar-fill { height: 100%; border-radius: 10rpx; background: linear-gradient(90deg, #EF4444, #F59E0B, #10B981); transition: width 0.8s ease; }
|
||
.gap-target { position: absolute; top: -4rpx; font-size: 28rpx; color: #6366F1; }
|
||
.gap-info { display: flex; gap: 16rpx; margin-top: 6rpx; font-size: 20rpx; color: var(--color-text-tertiary); }
|
||
.gap-diff { color: #EF4444; font-weight: 600; }
|
||
.gap-suggestions { margin-top: 24rpx; padding-top: 20rpx; border-top: 1rpx solid var(--color-border); }
|
||
.gap-suggest-title { font-size: 24rpx; font-weight: 700; color: var(--color-text); margin-bottom: 12rpx; display: block; }
|
||
.suggest-item { font-size: 22rpx; color: var(--color-text-secondary); line-height: 1.6; margin-bottom: 8rpx; }
|
||
.suggest-dim { font-weight: 600; color: var(--color-primary); }
|
||
|
||
.streak-card { padding: 24rpx; border-radius: var(--radius-xl); }
|
||
.streak-grid { display: flex; justify-content: space-around; }
|
||
.streak-day { display: flex; flex-direction: column; align-items: center; gap: 6rpx; }
|
||
.streak-day.today .day-label { color: var(--color-primary); font-weight: 700; }
|
||
.day-dot {
|
||
width: 48rpx; height: 48rpx; border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 24rpx; font-weight: 700;
|
||
}
|
||
.day-dot:not(.empty) { background: linear-gradient(135deg, #6366F1, #A78BFA); color: #FFF; }
|
||
.day-dot.empty { background: #F3F4F6; color: #D1D5DB; }
|
||
.day-label { font-size: 20rpx; color: var(--color-text-secondary); }
|
||
.streak-motivation { margin-top: 20rpx; text-align: center; padding: 16rpx; background: #FEF3C7; border-radius: var(--radius-md); }
|
||
.streak-motivation text { font-size: 24rpx; color: #92400E; font-weight: 600; }
|
||
|
||
.recent-list { display: flex; flex-direction: column; gap: 12rpx; }
|
||
.recent-item { padding: 24rpx; border-radius: var(--radius-lg); display: flex; justify-content: space-between; align-items: center; }
|
||
.recent-left { display: flex; flex-direction: column; gap: 4rpx; }
|
||
.recent-pos { font-size: 26rpx; font-weight: 600; color: var(--color-text); }
|
||
.recent-date { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||
.recent-right { display: flex; align-items: center; gap: 4rpx; }
|
||
.recent-score { font-size: 28rpx; font-weight: 700; }
|
||
.score-high { color: #10B981; }
|
||
.score-mid { color: #F59E0B; }
|
||
.score-low { color: #EF4444; }
|
||
.recent-arrow { font-size: 32rpx; color: #D1D5DB; }
|
||
|
||
.empty { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
|
||
.empty-icon { font-size: 64rpx; }
|
||
.empty-text { font-size: 24rpx; color: var(--color-text-tertiary); margin-top: 16rpx; }
|
||
.bottom-spacer { height: 40rpx; }
|
||
</style> |