Files
zhiyin/zhiyin-app/src/pages/history/history.vue
T
wlt 4cd889c081 feat: interview review module with whisper.cpp ASR + AI analysis + frontend page
New backend module 'interview-review' provides:
- Audio upload (50MB limit, MP3/M4A/WAV/AAC/OGG/MP4/WebM)
- Text transcript submission
- whisper.cpp local ASR integration (tiny + base models)
- AI analysis (4-dimension scoring: logic/expression/professionalism/stability)
- Speech analysis (filler words detection, pace, duration)
- Async processing pipeline with status polling
- Graceful fallback to mock ASR when whisper unavailable

New frontend page 'pages/review/review.vue' with 3 modes:
- List mode: review history with status indicators
- Upload mode: audio file upload or text paste
- Report mode: score radar, dimension bars, analysis details

Docs updated: PROJECT-STATUS.md v4.4, FEATURE-LIST.md v4.2, ROADMAP.md v4.2
2026-06-16 18:32:25 +08:00

160 lines
8.1 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.
<template>
<view class="page fade-in">
<view class="hero">
<text class="hero-title">面试记录</text>
<text class="hero-sub">回顾你的成长轨迹</text>
</view>
<view class="stats-bar card">
<view class="stat">
<text class="stat-val">{{ interviewList.length }}</text>
<text class="stat-lbl">总次数</text>
</view>
<view class="stat-sep"></view>
<view class="stat">
<text class="stat-val">{{ avgScore }}</text>
<text class="stat-lbl">平均分</text>
</view>
<view class="stat-sep"></view>
<view class="stat">
<text class="stat-val">{{ completedCount }}</text>
<text class="stat-lbl">已完成</text>
</view>
</view>
<view class="filter-wrap">
<view class="filter-inner">
<view class="filter-tab" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</view>
<view class="filter-tab" :class="{ active: filter === 'completed' }" @click="filter = 'completed'">已完成</view>
<view class="filter-tab" :class="{ active: filter === 'analyzing' }" @click="filter = 'analyzing'">进行中</view>
</view>
</view>
<view class="list" v-if="filteredList.length > 0">
<view class="record-card card" v-for="(item, idx) in filteredList" :key="idx">
<view class="record-top" @click="goDetail(item)">
<view class="record-icon">{{ item.score >= 80 ? '🌟' : item.score > 0 ? '📋' : '💬' }}</view>
<view class="record-body">
<view class="record-name">{{ item.position }}</view>
<text class="record-meta">{{ item.time }} · {{ item.duration }}</text>
</view>
<view class="record-score" :class="scoreLevel(item.score)">
{{ item.score ? item.score : '--' }}
</view>
</view>
<view class="record-actions" v-if="item.score > 0">
<text class="rec-action" @click="goContribute(item)">💡 贡献面经</text>
</view>
</view>
</view>
<view class="loading-tip" v-if="loading">加载中...</view>
<view class="empty" v-else>
<text class="empty-icon">{{ filter !== 'all' ? '🔍' : '📭' }}</text>
<text class="empty-title">{{ emptyTitle }}</text>
<text class="empty-desc">{{ emptyDesc }}</text>
<button class="empty-btn btn-gradient" @click="goInterview" v-if="filter === 'all'">开始第一次面试</button>
<button class="empty-btn" @click="goReviewRecording" style="margin-top:12rpx;background:#FFF;color:#4F46E5;border:2rpx solid #4F46E5;border-radius:var(--radius-round);padding:18rpx 48rpx;font-size:26rpx">🎙 录音复盘</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { api } from '../../config'
const filter = ref('all')
const interviewList = ref([])
const loading = ref(true)
const completedCount = computed(() => interviewList.value.filter(i => i.score > 0).length)
const avgScore = computed(() => {
const scored = interviewList.value.filter(i => i.score > 0)
if (scored.length === 0) return '--'
return Math.round(scored.reduce((s, i) => s + i.score, 0) / scored.length)
})
const filteredList = computed(() => {
if (filter.value === 'all') return interviewList.value
if (filter.value === 'completed') return interviewList.value.filter(i => i.score > 0)
return interviewList.value.filter(i => i.score === 0)
})
const emptyTitle = computed(() => {
if (filter.value === 'all') return '暂无面试记录'
if (filter.value === 'completed') return '暂无已完成面试'
return '暂无进行中面试'
})
const emptyDesc = computed(() => {
if (filter.value === 'all') return '完成你的第一场模拟面试吧'
if (filter.value === 'completed') return '继续面试练习'
return '所有面试都已评价完成'
})
const formatDate = (d) => {
if (!d) return '--'
const date = new Date(d)
return `${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`
}
onMounted(async () => {
const token = uni.getStorageSync('token') || ''
if (!token) { loading.value = false; return }
try {
const res = await uni.request({ url: api('/interview/list/all'), method: 'GET', header: { 'Authorization': `Bearer ${token}` } })
if (res.statusCode === 200 && Array.isArray(res.data)) {
interviewList.value = res.data.map(i => ({
position: i.position || '通用岗位', time: formatDate(i.createdAt || i.time),
score: i.totalScore || 0, duration: `${i.questionCount || 0}`, id: i.id,
}))
}
} catch(e) { console.error(e) }
finally { loading.value = false }
})
const scoreLevel = (s) => { if (!s) return 'pending'; if (s >= 80) return 'good'; if (s >= 60) return 'medium'; return 'poor' }
const goDetail = (item) => { if (item.id) uni.navigateTo({ url: `/pages/report/report?interviewId=${item.id}` }) }
const goContribute = (item) => {
uni.navigateTo({ url: `/pages/contribute/contribute?interviewId=${item.id}&position=${encodeURIComponent(item.position)}` })
}
const goInterview = () => uni.navigateTo({ url: '/pages/interview/interview' })
</script>
<style scoped>
.page { height: 100%; overflow-y: auto; background: var(--color-bg); }
.hero { 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; }
.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; }
.stats-bar { display: flex; align-items: center; padding: 24rpx; margin: -40rpx 32rpx 0; position: relative; z-index: 1; border-radius: var(--radius-lg); }
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6rpx; }
.stat-val { font-size: 36rpx; font-weight: 700; color: var(--color-primary); }
.stat-lbl { font-size: 20rpx; color: var(--color-text-tertiary); }
.stat-sep { width: 1rpx; height: 40rpx; background: var(--color-border); }
.filter-wrap { padding: 24rpx 32rpx 0; }
.filter-inner { display: flex; gap: 8rpx; background: #FFF; border-radius: var(--radius-md); padding: 6rpx; }
.filter-tab { flex: 1; text-align: center; font-size: 24rpx; color: var(--color-text-secondary); padding: 14rpx 0; border-radius: var(--radius-sm); }
.filter-tab.active { background: var(--color-primary); color: #FFF; font-weight: 600; }
.list { padding: 20rpx 32rpx 48rpx; }
.record-card { padding: 24rpx 28rpx; margin-bottom: 16rpx; border-radius: var(--radius-lg); }
.record-top { display: flex; align-items: center; gap: 16rpx; }
.record-icon { font-size: 40rpx; width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.record-body { flex: 1; min-width: 0; }
.record-name { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.record-meta { font-size: 20rpx; color: var(--color-text-tertiary); margin-top: 6rpx; display: block; }
.record-score { font-size: 28rpx; font-weight: 700; width: 72rpx; text-align: right; flex-shrink: 0; }
.record-score.good { color: var(--color-success); }
.record-score.medium { color: var(--color-warning); }
.record-score.poor { color: var(--color-error); }
.record-score.pending { color: var(--color-text-tertiary); }
.record-actions { margin-top: 12rpx; padding-top: 12rpx; border-top: 1rpx solid var(--color-border); display: flex; gap: 20rpx; }
.rec-action { font-size: 22rpx; color: var(--color-primary); font-weight: 500; }
.empty { display: flex; flex-direction: column; align-items: center; padding: 120rpx 32rpx 0; }
.empty-icon { font-size: 80rpx; margin-bottom: 20rpx; }
.empty-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
.empty-desc { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 8rpx; margin-bottom: 36rpx; }
.empty-btn { padding: 18rpx 48rpx; border-radius: var(--radius-round); font-size: 26rpx; }
.loading-tip { text-align: center; padding: 80rpx; font-size: 24rpx; color: var(--color-text-tertiary); }
</style>