Files
zhiyin/zhiyin-app/src/pages/interview/interview.vue
T
yuzhiran 8ee27fdd32 feat: refactor member to pay-per-use gravity purchase; mv webview to clipboard+browser
- member.vue: rewrite from subscription plans (free/growth/sprint) to
  H5-only pay-per-use gravity purchase with quantity selector + QR code
- user.vue: gravity card replacing quota card, add share/contribute/H5-buy
  entry points, plus gravity acquisition modal (share/contribute/buy)
- share.vue: layout fix (flex column), smarter copyLink with cached URL,
  WeChat timeline hint instead of open-type
- share.controller.ts: add GET /:shareCode redirect route (IP record + 302)
- interview.vue: guest mode fix, H5 buy modal, clipboard copy instead of
  webview for mini-program
- App.vue: handleH5UrlParams for ?token=&buy=gravity auto-login
- composables/useGravityPurchase.ts: reusable gravity purchase composable
- remove webview.vue (no longer used), replace with clipboard+browser flow
- AGENTS.md: sync all above changes, fix duplicate numbering
2026-06-20 20:49:15 +08:00

518 lines
21 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">
<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="topbar-pos-row">
<text class="topbar-position">{{ position || 'AI面试' }}</text>
<text class="topbar-status">面试中</text>
</view>
<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">
<text class="avatar-toggle" @click="avatarMode = !avatarMode">
{{ avatarMode ? '💬' : '👤' }}
</text>
</view>
</view>
</view>
<!-- Avatar mode -->
<view class="avatar-section" v-if="avatarMode">
<digital-human
ref="dhRef"
:text="aiSpeechText"
:audio-url="aiAudioUrl"
:amplitude-data="aiAmplitudeData"
:position="position"
:auto-play="true"
@speaking-start="onAvatarSpeaking"
@speaking-end="onAvatarSilent"
/>
</view>
<!-- Chat area (both modes) -->
<scroll-view class="chat-area" scroll-y :scroll-into-view="scrollToId" :scroll-with-animation="true" :class="{ 'chat-compact': avatarMode }">
<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>
<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>
<view class="input-bar" v-if="!isComplete">
<view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord" @mousedown="startRecord" @mouseup="stopRecord" @mouseleave="stopRecord">
<text class="mic-icon">🎤</text>
</view>
<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() && !isRecording) || aiLoading }" @click="sendAnswer">
<text class="send-icon">{{ isRecording ? '◉' : '➤' }}</text>
</view>
</view>
<!-- 岗位选择弹窗 -->
<view class="modal-overlay" v-if="showPositionPicker" @click="showPositionPicker = false">
<view class="modal-content" @click.stop>
<text class="modal-title">选择面试岗位</text>
<view class="pos-list">
<view class="pos-option" v-for="(pos, idx) in positions" :key="idx" @click="selectPosition(pos)">
<text class="pos-name">{{ pos.name }}</text>
<text class="pos-arrow"></text>
</view>
<view class="pos-option" v-if="positions.length === 0 && !positionsLoading">
<text class="pos-name disabled">暂无可用岗位</text>
</view>
<view class="pos-option" v-if="positionsLoading">
<text class="pos-name disabled">加载中...</text>
</view>
</view>
<text class="modal-close" @click="showPositionPicker = false">取消</text>
</view>
</view>
<view class="disclaimer-bar" v-if="!isComplete">AI 生成内容仅供参考请核实重要信息</view>
<view class="complete-bar" v-else>
<button class="cta-btn" @click="goResult">查看面试报告</button>
<button class="buy-btn" v-if="completedReason === 'noCredits'" @click="goH5Buy">引力值不足官网购买 </button>
</view>
<!-- 官网购买弹窗 -->
<view class="modal-overlay" v-if="showH5BuyModal" @click="showH5BuyModal = false">
<view class="modal-content" @click.stop>
<text class="modal-title">引力值不足</text>
<text class="modal-hint">您的引力值不足请补充后继续面试每次面试消耗 5 引力值</text>
<view class="purchase-options">
<view class="purchase-option" @click="goH5BuyAndClose">
<text class="purchase-name">官网购买引力值</text>
<text class="purchase-price">前往网页版充值</text>
<text class="purchase-desc">打开官网 H5 页面支持多种支付方式</text>
</view>
</view>
<text class="modal-close" @click="showH5BuyModal = false">取消</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { api, API_ENDPOINTS } from '../../config'
import DigitalHuman from '../../components/digital-human.vue'
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 completedReason = ref('')
const scrollToId = ref('')
const position = ref('')
const avatarMode = ref(true)
const showPositionPicker = ref(false)
const showH5BuyModal = ref(false)
const positions = ref([])
const positionsLoading = ref(false)
const aiSpeechText = ref('')
const aiAudioUrl = ref('')
const aiAmplitudeData = ref([])
const isSpeaking = ref(false)
const dhRef = ref(null)
const isRecording = ref(false)
let recorder = null
let timerSeconds = 0
let timerInterval = null
let MAX_QUESTIONS = 10
const progressPercent = computed(() => Math.min((answeredCount.value / MAX_QUESTIONS) * 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 = () => uni.getStorageSync('token') || ''
onLoad((options) => {
if (options?.position) {
const pos = decodeURIComponent(options.position)
position.value = pos
messages.value = [{ role: 'ai', content: `你好!我是你的专属 ${pos} 面试官,准备好了就开始吧!` }]
}
})
/** 加载热门岗位列表 */
const loadPositions = async () => {
positionsLoading.value = true
try {
const res = await uni.request({ url: api('/positions/hot'), method: 'GET' })
if (res.statusCode >= 200 && res.statusCode < 300 && Array.isArray(res.data)) {
positions.value = res.data
} else if (res.data?.data && Array.isArray(res.data.data)) {
positions.value = res.data.data
}
} catch (e) { console.error('加载岗位列表失败', e) }
finally { positionsLoading.value = false }
}
/** 用户选择岗位后开始面试 */
const selectPosition = (pos) => {
position.value = pos.name
showPositionPicker.value = false
messages.value = [{ role: 'ai', content: `你好!我是你的专属 ${pos.name} 面试官,准备好了就开始吧!` }]
startInterview()
}
onMounted(() => {
timerInterval = setInterval(() => timerSeconds++, 1000)
if (!position.value) {
// 未传入岗位,展示选择弹窗(无论是否登录)
loadPositions()
showPositionPicker.value = true
} else if (token()) {
startInterview()
}
})
onBeforeUnmount(() => {
clearInterval(timerInterval)
})
const checkLogin = () => {
if (!token()) {
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()}`, '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
if (res.data.totalQuestions) MAX_QUESTIONS = res.data.totalQuestions
// Speak first question in avatar mode
if (avatarMode.value && res.data.messages?.length) {
const last = res.data.messages[res.data.messages.length - 1]
if (last?.role === 'ai') await speakAiText(last.content)
}
} else if (res.statusCode === 403) {
const errMsg = res.data?.message || '面试次数已用完'
messages.value.push({ role: 'ai', content: errMsg + ' 👉 购买后可继续面试' })
isComplete.value = true
completedReason.value = 'noCredits'
} else {
const msg = res.data?.message || '创建面试失败'
messages.value.push({ role: 'ai', content: msg })
}
} 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()) { checkLogin(); return }
if (!interviewId.value) {
await startInterview()
if (!interviewId.value) return // creation failed, don't discard answer
}
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()}`, 'Content-Type': 'application/json' },
data: avatarMode.value ? { answer, avatar: true } : { answer },
})
if (res.statusCode === 200 && res.data?.messages) {
const aiMsg = res.data.messages.find(m => m.role === 'ai')
// Only push AI messages from response to avoid duplicating the user message already added above
const newAiMessages = res.data.messages.filter(m => m.role === 'ai')
if (newAiMessages.length > 0) messages.value.push(...newAiMessages)
if (avatarMode.value && aiMsg) {
await speakAiText(aiMsg.content, res.data.ttsHash, res.data.ttsAmplitude)
}
answeredCount.value = res.data.questionCount || answeredCount.value + 1
if (res.data.totalQuestions) MAX_QUESTIONS = res.data.totalQuestions
} else if (res.statusCode === 403) {
const errMsg = res.data?.message || '面试次数已用完'
messages.value.push({ role: 'ai', content: errMsg + ' 👉 购买后可继续面试' })
isComplete.value = true
completedReason.value = 'noCredits'
} else {
messages.value.push({ role: 'ai', content: res.data?.message || '回答提交失败' })
}
} catch {
messages.value.push({ role: 'ai', content: '回答提交失败,请重试' })
} finally {
aiLoading.value = false
scrollToBottom()
}
}
async function speakAiText(text, ttsHash, ttsAmplitude) {
aiSpeechText.value = text
aiAmplitudeData.value = ttsAmplitude || []
if (ttsHash) {
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(ttsHash))
} else {
try {
const synthRes = await uni.request({
url: api('/tts/synthesize'), method: 'POST',
header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token()}` },
data: { text },
})
if (synthRes.statusCode === 200 && synthRes.data?.hash) {
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(synthRes.data.hash))
aiAmplitudeData.value = synthRes.data?.amplitudeData || []
}
} catch {}
}
}
function onAvatarSpeaking() {
isSpeaking.value = true
}
function onAvatarSilent() {
isSpeaking.value = false
}
const goResult = () => uni.navigateTo({ url: `/pages/report/report?interviewId=${interviewId.value}` })
// 官网购买引力值
const goH5Buy = () => {
showH5BuyModal.value = true
}
const goH5BuyAndClose = () => {
showH5BuyModal.value = false
const token = uni.getStorageSync('token') || ''
const url = `https://zhiyin.yzrcloud.cn/?buy=gravity${token ? '&token=' + token : ''}`
// #ifdef MP-WEIXIN
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '链接已复制,请在手机浏览器中打开', icon: 'none', duration: 3000 })
},
fail: () => {
uni.showToast({ title: '复制失败,请手动访问 zhiyin.yzrcloud.cn', icon: 'none', duration: 3000 })
},
})
// #endif
}
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() },
})
}
function startRecord() {
if (aiLoading.value || isComplete.value) return
// #ifdef MP-WEIXIN
isRecording.value = true
recorder = uni.getRecorderManager()
recorder.onStart(() => {})
recorder.onError(() => { isRecording.value = false })
recorder.start({ format: 'mp3' })
uni.vibrateShort({ type: 'medium' })
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '语音输入仅支持小程序', icon: 'none' })
// #endif
}
function stopRecord() {
if (!recorder || !isRecording.value) return
isRecording.value = false
recorder.stop()
recorder.onStop(async (res) => {
if (!res.tempFilePath) return
const audioPath = res.tempFilePath
try {
const uploadRes = await uni.uploadFile({
url: api(API_ENDPOINTS.TTS.ASR),
filePath: audioPath,
name: 'audio',
header: { 'Authorization': `Bearer ${token()}` },
})
console.log('[ASR] upload response:', uploadRes.statusCode, typeof uploadRes.data === 'string' ? uploadRes.data.slice(0, 200) : JSON.stringify(uploadRes.data).slice(0, 200))
if (uploadRes.statusCode === 200 && uploadRes.data) {
const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data
if (data.text) {
inputText.value = data.text
uni.vibrateShort({ type: 'light' })
return
}
}
} catch (e) {
console.error('[ASR] upload error:', e?.message || e)
}
uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' })
})
}
</script>
<style scoped>
.page { height: 100vh; display: flex; flex-direction: column; background: #F8F9FC; }
.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; }
.topbar-pos-row { display: flex; align-items: center; gap: 10rpx; }
.topbar-position { font-size: 26rpx; color: #FFFFFF; font-weight: 600; }
.topbar-status { font-size: 18rpx; color: #FFFFFF; background: rgba(255,255,255,0.2); padding: 2rpx 12rpx; border-radius: 20rpx; }
.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: 20rpx; color: rgba(255,255,255,0.7); font-variant-numeric: tabular-nums; }
.topbar-right { width: 60rpx; flex-shrink: 0; text-align: center; }
.avatar-toggle { font-size: 36rpx; cursor: pointer; }
/* Avatar section */
.avatar-section {
background: linear-gradient(180deg, #EEF2FF 0%, #F8F9FC 100%);
flex-shrink: 0;
}
/* Chat */
.chat-area { flex: 1; padding: 24rpx 20rpx; overflow-y: auto; }
.chat-compact { max-height: 40vh; }
.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; }
.mic-btn {
width: 64rpx; height: 64rpx; border-radius: 50%; background: #F3F4F6;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
transition: all 0.2s;
}
.mic-btn:active { transform: scale(0.9); }
.mic-btn.recording { background: #FEE2E2; animation: mic-pulse 1s infinite; }
.mic-icon { font-size: 28rpx; }
@keyframes mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 16rpx rgba(239, 68, 68, 0); }
}
.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-bar { background: #FFFFFF; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + var(--safe-bottom)); display: flex; flex-direction: column; gap: 16rpx; }
.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; }
.buy-btn { width: 100%; height: 72rpx; line-height: 72rpx; background: #FEF3C7; color: #92400E; border-radius: var(--radius-md); font-size: 26rpx; font-weight: 600; border: 2rpx solid #F59E0B; }
.disclaimer-bar { text-align: center; font-size: 20rpx; color: var(--color-text-tertiary); padding: 8rpx 24rpx; background: #FFFFFF; border-top: 1rpx solid var(--color-border); }
/* 岗位选择弹窗 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.45); z-index: 999; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #FFF; border-radius: 20rpx; width: 600rpx; max-height: 70vh; display: flex; flex-direction: column; align-items: center; padding: 40rpx 32rpx 32rpx; }
.modal-title { font-size: 30rpx; font-weight: 800; color: var(--color-text); margin-bottom: 24rpx; }
.pos-list { width: 100%; max-height: 440rpx; overflow-y: auto; }
.pos-option { display: flex; align-items: center; justify-content: space-between; padding: 24rpx 16rpx; border-bottom: 1rpx solid #F3F4F6; }
.pos-option:active { background: #F9FAFB; border-radius: 12rpx; }
.pos-name { font-size: 28rpx; color: var(--color-text); }
.pos-name.disabled { color: #9CA3AF; }
.pos-arrow { font-size: 32rpx; color: #9CA3AF; }
.modal-close { margin-top: 24rpx; font-size: 26rpx; color: #9CA3AF; padding: 12rpx 32rpx; }
/* 购买弹窗 */
.modal-hint { font-size: 24rpx; color: #6B7280; text-align: center; margin-bottom: 28rpx; padding: 0 16rpx; }
.purchase-options { width: 100%; display: flex; flex-direction: column; gap: 16rpx; }
.purchase-option { background: #F9FAFB; border-radius: var(--radius-md); padding: 24rpx; border: 2rpx solid #E5E7EB; }
.purchase-option.recommended { background: #FFFBEB; border-color: #F59E0B; }
.purchase-option:active { transform: scale(0.97); }
.purchase-badge { font-size: 18rpx; color: #FFF; background: #F59E0B; padding: 2rpx 12rpx; border-radius: 6rpx; align-self: flex-start; margin-bottom: 8rpx; }
.purchase-name { font-size: 28rpx; font-weight: 700; color: var(--color-text); display: block; }
.purchase-price { font-size: 36rpx; font-weight: 800; color: var(--color-primary); margin-top: 8rpx; }
.purchase-unit { font-size: 22rpx; font-weight: 400; color: #9CA3AF; }
.purchase-desc { font-size: 22rpx; color: #6B7280; margin-top: 6rpx; }
</style>