Files
zhiyin/zhiyin-app/src/pages/progress/progress.vue
T
2026-06-16 13:18:36 +08:00

319 lines
13 KiB
Vue
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.
<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>