初始化:职引项目 v1.0
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- Top bar -->
|
||||
<view class="topbar">
|
||||
<view class="topbar-inner">
|
||||
<view class="back-btn" @click="confirmExit"><text class="back-arrow">‹</text></view>
|
||||
<view class="topbar-center">
|
||||
<view class="progress-track" v-if="interviewId">
|
||||
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
||||
</view>
|
||||
<text class="topbar-timer">⏱ {{ formatTime }}</text>
|
||||
</view>
|
||||
<view class="topbar-right"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Chat area -->
|
||||
<scroll-view class="chat-area" scroll-y :scroll-into-view="scrollToId" :scroll-with-animation="true">
|
||||
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="msg-row" :class="msg.role">
|
||||
<view class="msg-bubble" :class="msg.role">
|
||||
<text>{{ msg.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Typing indicator -->
|
||||
<view class="msg-row ai" v-if="aiLoading">
|
||||
<view class="typing">
|
||||
<view class="typing-dot"></view>
|
||||
<view class="typing-dot"></view>
|
||||
<view class="typing-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view id="msg-bottom" style="height: 16rpx;"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Input bar -->
|
||||
<view class="input-bar" v-if="!isComplete">
|
||||
<view class="input-box">
|
||||
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading" @confirm="sendAnswer" />
|
||||
</view>
|
||||
<view class="send-btn" :class="{ disabled: !inputText.trim() || aiLoading }" @click="sendAnswer">
|
||||
<text class="send-icon">➤</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Complete -->
|
||||
<view class="complete-bar" v-else>
|
||||
<button class="cta-btn" @click="goResult">查看面试报告</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { api } from '../../config'
|
||||
|
||||
const messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,准备好就开始吧!' }])
|
||||
const inputText = ref('')
|
||||
const aiLoading = ref(false)
|
||||
const interviewId = ref('')
|
||||
const answeredCount = ref(0)
|
||||
const isComplete = ref(false)
|
||||
const scrollToId = ref('')
|
||||
const position = ref('通用岗位')
|
||||
let timerSeconds = 0
|
||||
let timerInterval = null
|
||||
|
||||
const progressPercent = computed(() => Math.min((answeredCount.value / 5) * 100, 100))
|
||||
const formatTime = computed(() => {
|
||||
const m = Math.floor(timerSeconds / 60); const s = timerSeconds % 60
|
||||
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||||
})
|
||||
const token = computed(() => uni.getStorageSync('token') || '')
|
||||
|
||||
onLoad((options) => {
|
||||
if (options?.position) position.value = decodeURIComponent(options.position)
|
||||
})
|
||||
|
||||
onMounted(() => { timerInterval = setInterval(() => timerSeconds++, 1000); if (token.value) startInterview() })
|
||||
onBeforeUnmount(() => clearInterval(timerInterval))
|
||||
|
||||
const checkLogin = () => {
|
||||
if (!token.value) {
|
||||
uni.showModal({ title: '请先登录', content: '登录后即可开始 AI 模拟面试', confirmText: '去登录',
|
||||
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) } })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const startInterview = async () => {
|
||||
if (!checkLogin()) return
|
||||
aiLoading.value = true
|
||||
try {
|
||||
const res = await uni.request({ url: api('/interview/create'), method: 'POST',
|
||||
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' }, data: { position: position.value } })
|
||||
if (res.statusCode === 200 && res.data) {
|
||||
interviewId.value = res.data.id
|
||||
messages.value = res.data.messages || messages.value
|
||||
answeredCount.value = res.data.questionCount || 0
|
||||
}
|
||||
} catch { messages.value.push({ role: 'ai', content: '创建面试失败,请重试' }) }
|
||||
finally { aiLoading.value = false; scrollToBottom() }
|
||||
}
|
||||
|
||||
const sendAnswer = async () => {
|
||||
if (!inputText.value.trim() || aiLoading.value || isComplete.value) return
|
||||
if (!token.value) { checkLogin(); return }
|
||||
if (!interviewId.value) { await startInterview(); return }
|
||||
|
||||
const answer = inputText.value.trim()
|
||||
messages.value.push({ role: 'user', content: answer })
|
||||
inputText.value = ''; scrollToBottom()
|
||||
aiLoading.value = true
|
||||
try {
|
||||
const res = await uni.request({ url: api(`/interview/${interviewId.value}/answer`), method: 'POST',
|
||||
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' }, data: { answer } })
|
||||
if (res.statusCode === 200 && res.data?.messages) {
|
||||
messages.value.push(...res.data.messages)
|
||||
answeredCount.value = res.data.questionCount || answeredCount.value + 1
|
||||
}
|
||||
} catch { messages.value.push({ role: 'ai', content: '回答提交失败,请重试' }) }
|
||||
finally { aiLoading.value = false; scrollToBottom() }
|
||||
}
|
||||
|
||||
const goResult = () => uni.navigateTo({ url: `/pages/report/report?interviewId=${interviewId.value}` })
|
||||
const scrollToBottom = () => { nextTick(() => { scrollToId.value = ''; setTimeout(() => { scrollToId.value = 'msg-bottom' }, 100) }) }
|
||||
|
||||
const confirmExit = () => {
|
||||
uni.showModal({ title: '退出面试', content: interviewId.value ? '确定退出吗?当前进度将不会保存。' : '确定退出?',
|
||||
success: (r) => { if (r.confirm) uni.navigateBack() } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { height: 100vh; display: flex; flex-direction: column; background: #F8F9FC; }
|
||||
|
||||
/* ===== Top Bar ===== */
|
||||
.topbar {
|
||||
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||||
padding-top: 20rpx; flex-shrink: 0;
|
||||
}
|
||||
.topbar-inner {
|
||||
display: flex; align-items: center; padding: 16rpx 24rpx 20rpx; gap: 16rpx;
|
||||
}
|
||||
.back-btn {
|
||||
width: 60rpx; height: 60rpx; background: rgba(255,255,255,0.15);
|
||||
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.back-arrow { font-size: 36rpx; color: #FFFFFF; font-weight: 300; line-height: 1; }
|
||||
.topbar-center { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.progress-track { height: 6rpx; background: rgba(255,255,255,0.2); border-radius: 3rpx; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: #FFFFFF; border-radius: 3rpx; transition: width 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
|
||||
.topbar-timer { font-size: 22rpx; color: rgba(255,255,255,0.8); font-variant-numeric: tabular-nums; }
|
||||
.topbar-right { width: 60rpx; flex-shrink: 0; }
|
||||
|
||||
/* ===== Chat ===== */
|
||||
.chat-area { flex: 1; padding: 24rpx 20rpx; overflow-y: auto; }
|
||||
.msg-row { display: flex; margin-bottom: 24rpx; }
|
||||
.msg-row.ai { justify-content: flex-start; }
|
||||
.msg-row.user { justify-content: flex-end; }
|
||||
|
||||
.msg-bubble { max-width: 560rpx; padding: 20rpx 24rpx; line-height: 1.7; font-size: 26rpx; }
|
||||
.msg-bubble.ai {
|
||||
background: #FFFFFF; color: var(--color-text);
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
|
||||
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
|
||||
}
|
||||
.msg-bubble.user {
|
||||
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||||
color: #FFFFFF;
|
||||
border-radius: var(--radius-lg) 0 var(--radius-lg) var(--radius-lg);
|
||||
}
|
||||
|
||||
/* Typing */
|
||||
.typing {
|
||||
background: #FFFFFF; padding: 20rpx 28rpx;
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
|
||||
display: flex; gap: 8rpx; align-items: center;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
|
||||
}
|
||||
.typing-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #D1D5DB; animation: blink 1.4s infinite; }
|
||||
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes blink { 0%,80%,100% { opacity: 0.3 } 40% { opacity: 1 } }
|
||||
|
||||
/* ===== Input ===== */
|
||||
.input-bar {
|
||||
background: #FFFFFF; padding: 16rpx 20rpx;
|
||||
padding-bottom: calc(16rpx + var(--safe-bottom));
|
||||
display: flex; align-items: flex-end; gap: 12rpx;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.04); flex-shrink: 0;
|
||||
}
|
||||
.input-box { flex: 1; background: var(--color-bg); border-radius: var(--radius-md); padding: 12rpx 20rpx; }
|
||||
.input-area { width: 100%; font-size: 26rpx; color: var(--color-text); max-height: 160rpx; line-height: 1.5; }
|
||||
.send-btn {
|
||||
width: 80rpx; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||||
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.send-btn:active { transform: scale(0.9); }
|
||||
.send-btn.disabled { background: var(--color-border); }
|
||||
.send-icon { font-size: 32rpx; color: #FFFFFF; transform: translateY(2rpx); }
|
||||
|
||||
/* Complete */
|
||||
.complete-bar { background: #FFFFFF; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + var(--safe-bottom)); }
|
||||
.cta-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; border: none; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user