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
This commit is contained in:
wlt
2026-06-16 18:32:25 +08:00
parent 96c367e0f8
commit 4cd889c081
16 changed files with 1771 additions and 80 deletions
+1
View File
@@ -109,6 +109,7 @@ export const API_ENDPOINTS = {
RECORDS: '/share/records',
VISITORS: '/share/visitors',
},
REVIEW: { UPLOAD: "/interview-review", TEXT: "/interview-review/text", LIST: "/interview-review/list", DETAIL: (id: string) => `/interview-review/${id}`, DELETE: (id: string) => `/interview-review/${id}`, },
} as const
const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn'
+1
View File
@@ -18,6 +18,7 @@
{ "path": "pages/agreement/agreement", "style": { "navigationBarTitleText": "用户协议" } },
{ "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } },
{ "path": "pages/share/share", "style": { "navigationBarTitleText": "我的分享" } }
{"path": "pages/review/review", "style": {"navigationBarTitleText": "面试复盘"}},
],
"tabBar": {
"color": "#999999",
+1
View File
@@ -54,6 +54,7 @@
<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>
+693
View File
@@ -0,0 +1,693 @@
<template>
<view class="page">
<!-- ====== Mode 1: List View ====== -->
<template v-if="mode === 'list'">
<view class="hero">
<text class="hero-title">面试复盘</text>
<text class="hero-sub">上传录音AI 帮你复盘面试表现</text>
</view>
<view class="stats-bar card" v-if="listData.items.length > 0">
<view class="stat">
<text class="stat-val">{{ listData.total }}</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="list" v-if="listData.items.length > 0">
<view
class="record-card card"
v-for="item in listData.items"
:key="item._id"
@click="viewDetail(item._id)"
>
<view class="record-top">
<view class="record-icon">
{{ item.status === 'completed' ? (item.analysis?.overallScore >= 80 ? '🌟' : '📋') : '⏳' }}
</view>
<view class="record-body">
<view class="record-name">{{ item.position }}</view>
<text class="record-meta">
{{ formatDate(item.createdAt) }}
<template v-if="item.company"> · {{ item.company }}</template>
<template v-if="item.status === 'processing'"> · 分析中...</template>
<template v-if="item.status === 'failed'"> · 分析失败</template>
</text>
</view>
<view class="record-score" v-if="item.status === 'completed'" :class="scoreLevel(item.analysis?.overallScore)">
{{ item.analysis?.overallScore || '--' }}
</view>
<view v-else class="record-score pending">{{ item.status === 'processing' ? '...' : '!' }}</view>
</view>
</view>
<view class="load-more" v-if="hasMore" @click="loadMore">加载更多</view>
</view>
<view class="empty" v-else-if="!loading">
<text class="empty-icon">🎙</text>
<text class="empty-title">暂无复盘记录</text>
<text class="empty-desc">面试后上传录音AI 帮你分析表现</text>
</view>
<view class="fixed-bottom">
<button class="btn-primary" @click="mode = 'upload'">
<text class="btn-icon">+</text> 上传录音复盘
</button>
</view>
</template>
<!-- ====== Mode 2: Upload View ====== -->
<template v-if="mode === 'upload'">
<view class="upload-page">
<view class="upload-header">
<text class="upload-back" @click="mode = 'list'"> 返回</text>
<text class="upload-title">上传面试录音</text>
<view style="width:60rpx"></view>
</view>
<view class="form-card card">
<view class="form-group">
<text class="form-label">面试岗位 <text class="required">*</text></text>
<input class="form-input" v-model="form.position" placeholder="例如:前端开发工程师" />
</view>
<view class="form-group">
<text class="form-label">面试公司</text>
<input class="form-input" v-model="form.company" placeholder="选填" />
</view>
<view class="form-group">
<text class="form-label">上传方式</text>
<view class="tab-bar">
<view class="tab-item" :class="{ active: uploadMode === 'audio' }" @click="uploadMode = 'audio'">录音文件</view>
<view class="tab-item" :class="{ active: uploadMode === 'text' }" @click="uploadMode = 'text'">粘贴文本</view>
</view>
</view>
<!-- Audio upload mode -->
<view v-if="uploadMode === 'audio'" class="audio-upload-area">
<view class="upload-box" @click="chooseFile">
<text class="upload-icon">📁</text>
<text class="upload-text" v-if="!form.file">点击选择录音文件</text>
<text class="upload-text" v-else>{{ form.file.name }}</text>
<text class="upload-hint">支持 mp3/m4a/wav最大 50MB</text>
</view>
</view>
<!-- Text paste mode -->
<view v-else class="text-upload-area">
<textarea
class="text-input"
v-model="form.text"
placeholder="将面试录音转写为文字后粘贴在这里...&#10;&#10;示例:&#10;面试官:请介绍一下你自己&#10;候选人:我毕业于..."
:maxlength="10000"
/>
<text class="char-count">{{ form.text.length }}/10000</text>
</view>
</view>
<view class="submit-area">
<button class="btn-primary" @click="submitReview" :disabled="submitting">
{{ submitting ? '提交中...' : '开始分析' }}
</button>
</view>
</view>
</template>
<!-- ====== Mode 3: Processing View ====== -->
<template v-if="mode === 'processing'">
<view class="processing-page">
<view class="processing-card">
<view class="spinner"></view>
<text class="processing-title">AI 分析中</text>
<text class="processing-desc">正在对面试录音进行深度分析请稍候...</text>
<view class="process-steps">
<view class="step" :class="{ done: processStep >= 1 }">
<text class="step-num">{{ processStep >= 1 ? '✓' : '1' }}</text>
<text class="step-label">文本转录</text>
</view>
<view class="step" :class="{ done: processStep >= 2 }">
<text class="step-num">{{ processStep >= 2 ? '✓' : '2' }}</text>
<text class="step-label">语音分析</text>
</view>
<view class="step" :class="{ done: processStep >= 3 }">
<text class="step-num">{{ processStep >= 3 ? '✓' : '3' }}</text>
<text class="step-label">AI 评估</text>
</view>
</view>
<text class="processing-hint">请勿离开此页面</text>
</view>
</view>
</template>
<!-- ====== Mode 4: Report View ====== -->
<template v-if="mode === 'report' && reportData">
<view class="report-page">
<view class="report-header">
<text class="report-back" @click="mode = 'list'"> 返回列表</text>
<text class="report-title">复盘报告</text>
<view style="width:60rpx"></view>
</view>
<view class="body">
<view class="score-card">
<view class="score-circle" :class="scoreLevel(reportData.analysis.overallScore)">
<text class="score-num">{{ reportData.analysis.overallScore }}</text>
<text class="score-label">总分</text>
</view>
</view>
<view class="info-row">
<text class="info-label">面试岗位</text>
<text class="info-value">{{ reportData.position }}</text>
</view>
<view class="info-row" v-if="reportData.company">
<text class="info-label">面试公司</text>
<text class="info-value">{{ reportData.company }}</text>
</view>
<view class="info-row">
<text class="info-label">分析时间</text>
<text class="info-value">{{ formatDate(reportData.createdAt) }}</text>
</view>
<view class="info-row" v-if="reportData.speechAnalysis">
<text class="info-label">回答总时长</text>
<text class="info-value">{{ reportData.speechAnalysis.totalDuration }}</text>
</view>
<!-- 四维能力评估 -->
<view class="section" v-if="reportData.analysis.dimensions">
<view class="section-title">四维能力评估</view>
<view class="dim-grid">
<view class="dim-item" v-for="dim in dimDefs" :key="dim.key">
<view class="dim-header">
<text class="dim-name">{{ dim.label }}</text>
<text class="dim-score">{{ getDimValue(dim.key) }}</text>
</view>
<view class="dim-bar-bg">
<view class="dim-bar-fill" :style="{ width: getDimValue(dim.key) + '%', background: dim.color }"></view>
</view>
</view>
</view>
</view>
<!-- 语音分析 -->
<view class="section" v-if="reportData.speechAnalysis">
<view class="section-title">语音表达分析</view>
<view class="speech-card">
<view class="speech-row">
<text class="speech-label">语气词评分</text>
<text class="speech-value" :class="scoreLevel(reportData.speechAnalysis.fillerScore)">
{{ reportData.speechAnalysis.fillerScore }}
</text>
</view>
<view class="speech-row">
<text class="speech-label">语气词密度</text>
<text class="speech-value">{{ reportData.speechAnalysis.fillerDensity }}%</text>
</view>
<view class="speech-row">
<text class="speech-label">语速</text>
<text class="speech-value">{{ reportData.speechAnalysis.pace }}</text>
</view>
<view class="filler-tags" v-if="reportData.speechAnalysis.fillerWords.length > 0">
<text class="filler-tag" v-for="fw in reportData.speechAnalysis.fillerWords" :key="fw.word">
"{{ fw.word }}" × {{ fw.count }}
</text>
</view>
</view>
</view>
<!-- 逐题分析 -->
<view class="section" v-if="reportData.analysis.questionBreakdown && reportData.analysis.questionBreakdown.length > 0">
<view class="section-title">逐题分析</view>
<view class="qa-list">
<view class="qa-item" v-for="(qa, idx) in reportData.analysis.questionBreakdown" :key="idx">
<view class="qa-header">
<text class="qa-num">Q{{ idx + 1 }}</text>
<text class="qa-score" :class="scoreLevel(qa.score)">{{ qa.score }}</text>
</view>
<text class="qa-question">{{ qa.question }}</text>
<text class="qa-answer-label">你的回答</text>
<text class="qa-answer">{{ qa.answer }}</text>
<view class="qa-comment" v-if="qa.comment">
<text class="qa-comment-icon">💡</text>
<text class="qa-comment-text">{{ qa.comment }}</text>
</view>
<view class="qa-suggest" v-if="qa.suggestedAnswer">
<text class="qa-suggest-icon">参考思路</text>
<text class="qa-suggest-text">{{ qa.suggestedAnswer }}</text>
</view>
</view>
</view>
</view>
<!-- 改进建议 -->
<view class="section" v-if="reportData.analysis.strengths.length > 0 || reportData.analysis.weaknesses.length > 0">
<view class="section-title">改进建议</view>
<view class="sugg-block" v-if="reportData.analysis.strengths.length > 0">
<text class="sugg-block-title sugg-good"> 表现亮点</text>
<text class="sugg-item" v-for="(s, i) in reportData.analysis.strengths" :key="'str-' + i">{{ s }}</text>
</view>
<view class="sugg-block" v-if="reportData.analysis.weaknesses.length > 0">
<text class="sugg-block-title sugg-warn"> 待改进</text>
<text class="sugg-item" v-for="(w, i) in reportData.analysis.weaknesses" :key="'weak-' + i">{{ w }}</text>
</view>
<view class="sugg-block" v-if="reportData.analysis.suggestions.length > 0">
<text class="sugg-block-title sugg-info">💡 具体建议</text>
<text class="sugg-item" v-for="(s, i) in reportData.analysis.suggestions" :key="'sug-' + i">{{ s }}</text>
</view>
</view>
<view class="actions">
<button class="btn-primary" @click="goInterview">再去模拟面试练练</button>
<button class="btn-outline" @click="mode = 'list'">返回列表</button>
</view>
</view>
</view>
</template>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { api } from '../../config'
const mode = ref('list')
const loading = ref(false)
const submitting = ref(false)
const listData = ref({ items: [], total: 0, page: 1, limit: 20 })
const processStep = ref(0)
const reportData = ref(null)
const pollTimer = ref(null)
const reviewId = ref('')
const form = ref({ position: '', company: '', file: null, text: '' })
const uploadMode = ref('audio')
const dimDefs = [
{ key: 'logic', label: '逻辑思维', color: '#6366F1' },
{ key: 'expression', label: '表达能力', color: '#10B981' },
{ key: 'professionalism', label: '专业度', color: '#F59E0B' },
{ key: 'stability', label: '稳定性', color: '#EF4444' },
]
const avgScore = computed(() => {
const scored = listData.value.items.filter(i => i.status === 'completed' && i.analysis?.overallScore)
if (scored.length === 0) return '--'
return Math.round(scored.reduce((s, i) => s + (i.analysis?.overallScore || 0), 0) / scored.length)
})
const completedCount = computed(() => {
return listData.value.items.filter(i => i.status === 'completed').length
})
const hasMore = computed(() => {
return listData.value.page < Math.ceil(listData.value.total / listData.value.limit)
})
onMounted(() => { fetchList() })
onShow(() => {
if (mode.value === 'list') fetchList()
})
async function fetchList(page = 1) {
const token = uni.getStorageSync('token') || ''
if (!token) return
loading.value = true
try {
const res = await uni.request({
url: api(`/interview-review/list?page=${page}&limit=20`),
method: 'GET',
header: { 'Authorization': `Bearer ${token}` },
})
if (res.statusCode === 200) {
const data = res.data
if (page === 1) {
listData.value = data
} else {
listData.value = {
...data,
items: [...listData.value.items, ...data.items],
}
}
}
} catch(e) { console.error(e) }
finally { loading.value = false }
}
function loadMore() {
const next = listData.value.page + 1
fetchList(next)
listData.value.page = next
}
function chooseFile() {
uni.chooseMessageFile ? uni.chooseMessageFile({
count: 1,
type: 'file',
extension: ['mp3', 'm4a', 'wav', 'aac', 'ogg'],
success: (r) => {
if (r.tempFiles && r.tempFiles[0]) {
form.value.file = r.tempFiles[0]
}
},
}) : uni.showToast({ title: '当前平台不支持文件选择', icon: 'none' })
}
async function submitReview() {
if (!form.value.position.trim()) {
uni.showToast({ title: '请填写面试岗位', icon: 'none' })
return
}
if (uploadMode.value === 'audio' && !form.value.file) {
uni.showToast({ title: '请选择录音文件', icon: 'none' })
return
}
if (uploadMode.value === 'text' && !form.value.text.trim()) {
uni.showToast({ title: '请粘贴面试转录文本', icon: 'none' })
return
}
submitting.value = true
const token = uni.getStorageSync('token') || ''
try {
if (uploadMode.value === 'audio') {
const res = await uni.uploadFile({
url: api('/interview-review'),
filePath: form.value.file.path || form.value.file.tempFilePath,
name: 'file',
formData: {
position: form.value.position.trim(),
company: form.value.company.trim(),
},
header: { 'Authorization': `Bearer ${token}` },
})
if (res.statusCode === 201 || res.statusCode === 200) {
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
reviewId.value = data.id
startPolling(data.id)
mode.value = 'processing'
animateSteps()
} else {
const err = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
uni.showToast({ title: err.message || '上传失败', icon: 'none' })
}
} else {
// Text submission
const res = await uni.request({
url: api('/interview-review/text'),
method: 'POST',
data: {
position: form.value.position.trim(),
company: form.value.company.trim(),
text: form.value.text.trim(),
},
header: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
if (res.statusCode === 201 || res.statusCode === 200) {
reviewId.value = res.data.id
startPolling(res.data.id)
mode.value = 'processing'
animateSteps()
} else {
uni.showToast({ title: res.data?.message || '提交失败', icon: 'none' })
}
}
} catch(e) {
uni.showToast({ title: e.message || '网络错误', icon: 'none' })
}
finally { submitting.value = false }
}
function animateSteps() {
processStep.value = 1
setTimeout(() => { processStep.value = 2 }, 3000)
setTimeout(() => { processStep.value = 3 }, 6000)
}
function startPolling(id) {
let attempts = 0
const maxAttempts = 60 // 5 minutes max
pollTimer.value = setInterval(async () => {
attempts++
if (attempts > maxAttempts) {
clearInterval(pollTimer.value)
uni.showToast({ title: '分析超时,请稍后再查', icon: 'none' })
mode.value = 'list'
return
}
try {
const token = uni.getStorageSync('token') || ''
const res = await uni.request({
url: api(`/interview-review/${id}`),
method: 'GET',
header: { 'Authorization': `Bearer ${token}` },
})
if (res.statusCode === 200) {
const data = res.data
if (data.status === 'completed') {
clearInterval(pollTimer.value)
reportData.value = data
mode.value = 'report'
} else if (data.status === 'failed') {
clearInterval(pollTimer.value)
uni.showToast({ title: '分析失败,请重新上传', icon: 'none' })
mode.value = 'list'
}
// status === 'processing': continue polling
}
} catch(e) { console.error(e) }
}, 5000)
}
function viewDetail(id) {
const token = uni.getStorageSync('token') || ''
uni.request({
url: api(`/interview-review/${id}`),
method: 'GET',
header: { 'Authorization': `Bearer ${token}` },
success: (res) => {
if (res.statusCode === 200) {
if (res.data.status === 'completed') {
reportData.value = res.data
mode.value = 'report'
} else if (res.data.status === 'processing') {
reviewId.value = id
startPolling(id)
mode.value = 'processing'
} else {
uni.showToast({ title: '分析失败', icon: 'none' })
}
}
},
fail: () => { uni.showToast({ title: '加载失败', icon: 'none' }) },
})
}
function getDimValue(key) {
return Math.min(100, Math.max(0, Math.round(reportData.value?.analysis?.dimensions?.[key] || 0)))
}
function 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')}`
}
const scoreLevel = (s) => {
if (!s && s !== 0) return 'pending'
if (s >= 80) return 'good'
if (s >= 60) return 'medium'
return 'poor'
}
function goInterview() {
uni.switchTab({ url: '/pages/index/index' })
}
onUnmounted(() => {
if (pollTimer.value) clearInterval(pollTimer.value)
})
</script>
<style scoped>
.page { background: #F3F4F6; min-height: 100vh; }
.hero {
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #6366F1 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: 16rpx; background: #FFF; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6rpx; }
.stat-val { font-size: 36rpx; font-weight: 700; color: #4F46E5; }
.stat-lbl { font-size: 20rpx; color: #9CA3AF; }
.stat-sep { width: 1rpx; height: 40rpx; background: #E5E7EB; }
.list { padding: 20rpx 32rpx 160rpx; }
.record-card { background: #FFF; padding: 24rpx 28rpx; margin-bottom: 16rpx; border-radius: 16rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
.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: #111827; }
.record-meta { font-size: 20rpx; color: #9CA3AF; 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: #059669; }
.record-score.medium { color: #D97706; }
.record-score.poor { color: #DC2626; }
.record-score.pending { color: #9CA3AF; }
.empty { display: flex; flex-direction: column; align-items: center; padding: 120rpx 32rpx 200rpx; }
.empty-icon { font-size: 80rpx; margin-bottom: 20rpx; }
.empty-title { font-size: 28rpx; font-weight: 600; color: #111827; }
.empty-desc { font-size: 22rpx; color: #9CA3AF; margin-top: 8rpx; margin-bottom: 36rpx; }
.fixed-bottom { position: fixed; bottom: 0; left: 0; right: 0; padding: 20rpx 32rpx 40rpx; background: linear-gradient(transparent, #F3F4F6 30rpx); }
.btn-primary { background: linear-gradient(135deg, #4F46E5, #7C3AED); color: #FFF; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 8rpx; }
.btn-primary:active { opacity: 0.85; }
.btn-icon { font-size: 36rpx; font-weight: 400; }
/* Upload Page */
.upload-page { background: #F3F4F6; min-height: 100vh; }
.upload-header { display: flex; align-items: center; justify-content: space-between; padding: 24rpx 32rpx; background: #FFF; }
.upload-back { font-size: 28rpx; color: #4F46E5; }
.upload-title { font-size: 32rpx; font-weight: 600; color: #111827; }
.form-card { background: #FFF; border-radius: 16rpx; margin: 24rpx 32rpx; padding: 32rpx; }
.form-group { margin-bottom: 28rpx; }
.form-label { font-size: 26rpx; font-weight: 600; color: #374151; display: block; margin-bottom: 12rpx; }
.required { color: #DC2626; }
.form-input { width: 100%; height: 72rpx; border: 2rpx solid #E5E7EB; border-radius: 12rpx; padding: 0 20rpx; font-size: 26rpx; box-sizing: border-box; }
.tab-bar { display: flex; gap: 0; background: #F3F4F6; border-radius: 12rpx; overflow: hidden; }
.tab-item { flex: 1; text-align: center; padding: 18rpx 0; font-size: 24rpx; color: #6B7280; }
.tab-item.active { background: #4F46E5; color: #FFF; font-weight: 600; }
.audio-upload-area { margin-top: 8rpx; }
.upload-box { border: 2rpx dashed #D1D5DB; border-radius: 16rpx; padding: 48rpx 32rpx; display: flex; flex-direction: column; align-items: center; gap: 12rpx; }
.upload-icon { font-size: 60rpx; }
.upload-text { font-size: 26rpx; color: #374151; }
.upload-hint { font-size: 20rpx; color: #9CA3AF; }
.text-upload-area { margin-top: 8rpx; }
.text-input { width: 100%; height: 360rpx; border: 2rpx solid #E5E7EB; border-radius: 12rpx; padding: 20rpx; font-size: 24rpx; line-height: 1.6; box-sizing: border-box; }
.char-count { font-size: 20rpx; color: #9CA3AF; text-align: right; display: block; margin-top: 8rpx; }
.submit-area { padding: 0 32rpx 48rpx; }
/* Processing Page */
.processing-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 32rpx; }
.processing-card { background: #FFF; border-radius: 24rpx; padding: 64rpx 48rpx; text-align: center; box-shadow: 0 8rpx 40rpx rgba(0,0,0,0.08); width: 100%; max-width: 500rpx; }
.spinner { width: 80rpx; height: 80rpx; border: 6rpx solid #E5E7EB; border-top-color: #4F46E5; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 32rpx; }
@keyframes spin { to { transform: rotate(360deg); } }
.processing-title { font-size: 32rpx; font-weight: 700; color: #111827; margin-bottom: 12rpx; }
.processing-desc { font-size: 24rpx; color: #6B7280; margin-bottom: 40rpx; }
.process-steps { display: flex; gap: 16rpx; justify-content: center; margin-bottom: 24rpx; }
.step { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
.step-num { width: 48rpx; height: 48rpx; border-radius: 50%; background: #F3F4F6; color: #9CA3AF; font-size: 22rpx; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.step.done .step-num { background: #4F46E5; color: #FFF; }
.step-label { font-size: 20rpx; color: #9CA3AF; }
.step.done .step-label { color: #4F46E5; }
.processing-hint { font-size: 20rpx; color: #D1D5DB; }
/* Report Page */
.report-page { background: #F3F4F6; min-height: 100vh; }
.report-header { display: flex; align-items: center; justify-content: space-between; padding: 24rpx 32rpx; background: #FFF; }
.report-back { font-size: 28rpx; color: #4F46E5; }
.report-title { font-size: 32rpx; font-weight: 600; color: #111827; }
.body { padding: 0 32rpx 48rpx; }
.score-card { display: flex; justify-content: center; margin: -20rpx 0 30rpx; }
.score-circle {
width: 180rpx; height: 180rpx; border-radius: 50%;
background: #FFF; display: flex; flex-direction: column;
align-items: center; justify-content: center;
box-shadow: 0 8rpx 30rpx rgba(79,70,229,0.2);
}
.score-circle.good { box-shadow: 0 8rpx 30rpx rgba(5,150,105,0.2); }
.score-circle.medium { box-shadow: 0 8rpx 30rpx rgba(217,119,6,0.2); }
.score-circle.poor { box-shadow: 0 8rpx 30rpx rgba(220,38,38,0.2); }
.score-num { font-size: 56rpx; font-weight: 700; color: #4F46E5; line-height: 1.2; }
.good .score-num { color: #059669; }
.medium .score-num { color: #D97706; }
.poor .score-num { color: #DC2626; }
.score-label { font-size: 20rpx; color: #9CA3AF; margin-top: 4rpx; }
.info-row { display: flex; justify-content: space-between; padding: 20rpx 0; border-bottom: 1rpx solid #E5E7EB; font-size: 24rpx; }
.info-label { color: #6B7280; }
.info-value { color: #111827; font-weight: 500; }
.section { background: #FFF; border-radius: 20rpx; padding: 28rpx; margin-top: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
.section-title { font-size: 28rpx; font-weight: 600; color: #111827; margin-bottom: 16rpx; }
.dim-grid { display: flex; flex-direction: column; gap: 20rpx; }
.dim-header { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
.dim-name { font-size: 24rpx; color: #374151; }
.dim-score { font-size: 24rpx; color: #4F46E5; font-weight: 600; }
.dim-bar-bg { height: 20rpx; background: #F3F4F6; border-radius: 10rpx; overflow: hidden; }
.dim-bar-fill { height: 100%; border-radius: 10rpx; }
.speech-card { display: flex; flex-direction: column; gap: 16rpx; }
.speech-row { display: flex; justify-content: space-between; align-items: center; }
.speech-label { font-size: 24rpx; color: #374151; }
.speech-value { font-size: 24rpx; font-weight: 600; color: #111827; }
.speech-value.good { color: #059669; }
.speech-value.medium { color: #D97706; }
.speech-value.poor { color: #DC2626; }
.filler-tags { display: flex; flex-wrap: wrap; gap: 8rpx; }
.filler-tag { background: #FEF3C7; color: #92400E; padding: 6rpx 16rpx; border-radius: 20rpx; font-size: 20rpx; }
.qa-list { display: flex; flex-direction: column; gap: 24rpx; }
.qa-item { padding: 20rpx; background: #F9FAFB; border-radius: 12rpx; }
.qa-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.qa-num { font-size: 22rpx; font-weight: 700; color: #4F46E5; background: #EEF2FF; padding: 4rpx 14rpx; border-radius: 8rpx; }
.qa-score { font-size: 24rpx; font-weight: 700; }
.qa-score.good { color: #059669; }
.qa-score.medium { color: #D97706; }
.qa-score.poor { color: #DC2626; }
.qa-score.pending { color: #9CA3AF; }
.qa-question { font-size: 24rpx; font-weight: 600; color: #111827; margin-bottom: 8rpx; display: block; }
.qa-answer-label { font-size: 20rpx; color: #6B7280; display: block; margin-top: 8rpx; }
.qa-answer { font-size: 22rpx; color: #374151; line-height: 1.6; display: block; margin-top: 4rpx; white-space: pre-wrap; }
.qa-comment { background: #EEF2FF; padding: 12rpx; border-radius: 8rpx; margin-top: 12rpx; display: flex; gap: 8rpx; }
.qa-comment-icon { font-size: 22rpx; }
.qa-comment-text { font-size: 22rpx; color: #4338CA; line-height: 1.5; }
.qa-suggest { background: #FEF3C7; padding: 12rpx; border-radius: 8rpx; margin-top: 8rpx; }
.qa-suggest-icon { font-size: 22rpx; font-weight: 600; color: #92400E; }
.qa-suggest-text { font-size: 22rpx; color: #78350F; line-height: 1.5; }
.sugg-block { margin-bottom: 20rpx; }
.sugg-block-title { font-size: 24rpx; font-weight: 600; display: block; margin-bottom: 8rpx; }
.sugg-good { color: #059669; }
.sugg-warn { color: #D97706; }
.sugg-info { color: #4F46E5; }
.sugg-item { display: block; font-size: 22rpx; color: #374151; line-height: 1.8; padding-left: 20rpx; }
.sugg-item::before { content: '• '; color: #D1D5DB; }
.actions { display: flex; flex-direction: column; gap: 16rpx; margin-top: 32rpx; }
.btn-outline { background: #FFF; color: #4F46E5; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; border: 2rpx solid #4F46E5; }
.btn-outline:active { background: #F5F3FF; }
.load-more { text-align: center; padding: 24rpx; color: #4F46E5; font-size: 24rpx; }
</style>
+7 -1
View File
@@ -46,6 +46,11 @@
<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>
@@ -138,7 +143,8 @@ const checkAdmin = () => {
}
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
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' })
+9 -1
View File
@@ -22,7 +22,6 @@ async function request<T = any>(url: string, method: string = 'POST', data?: any
}
}
export const apiService = {
user: {
sendCode: (phone: string) => request(API_ENDPOINTS.USER.SEND_CODE, 'POST', { phone }),
login: (phone: string, code: string) => request(API_ENDPOINTS.USER.LOGIN, 'POST', { phone, code }),
@@ -90,6 +89,15 @@ export const apiService = {
records: () => request(API_ENDPOINTS.SHARE.RECORDS, 'GET', undefined, true),
visitors: () => request(API_ENDPOINTS.SHARE.VISITORS, 'GET', undefined, true),
},
review: {
list: (page = 1, limit = 20) =>
request(`${API_ENDPOINTS.REVIEW.LIST}?page=${page}&limit=${limit}`, "GET", undefined, true),
detail: (id: string) => request(API_ENDPOINTS.REVIEW.DETAIL(id), "GET", undefined, true),
delete: (id: string) => request(API_ENDPOINTS.REVIEW.DELETE(id), "DELETE", undefined, true),
submitText: (position: string, text: string, company?: string) =>
request(API_ENDPOINTS.REVIEW.TEXT, "POST", { position, text, company: company || "" }, true),
},
}
export default apiService