8ee27fdd32
- 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
518 lines
21 KiB
Vue
518 lines
21 KiB
Vue
<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>
|