feat: unified gravity system - VIP members consume gravity instead of unlimited; add monthly gravity top-up cron

This commit is contained in:
yuzhiran
2026-06-19 22:43:52 +08:00
parent c2ba810a02
commit 2fbab1072f
22 changed files with 956 additions and 216 deletions
+113 -3
View File
@@ -66,10 +66,53 @@
</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="showPurchaseModal = true">引力值不足补充引力值或开通会员 </button>
</view>
<!-- 购买弹窗次数不足时 -->
<view class="modal-overlay" v-if="showPurchaseModal" @click="showPurchaseModal = 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="goBuyProduct">
<text class="purchase-name">补充引力值</text>
<text class="purchase-price">¥5 </text>
<text class="purchase-desc">¥5 = 5 引力值可面试 1 </text>
</view>
<view class="purchase-option recommended" @click="goBuyMember">
<text class="purchase-badge">推荐</text>
<text class="purchase-name">开通成长版会员</text>
<text class="purchase-price">¥19.9<text class="purchase-unit">/</text></text>
<text class="purchase-desc">每月 250 引力值解锁全部权益</text>
</view>
</view>
<text class="modal-close" @click="showPurchaseModal = false">取消</text>
</view>
</view>
</view>
</template>
@@ -86,9 +129,14 @@ 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 showPurchaseModal = ref(false)
const positions = ref([])
const positionsLoading = ref(false)
const aiSpeechText = ref('')
const aiAudioUrl = ref('')
const aiAmplitudeData = ref([])
@@ -116,9 +164,37 @@ onLoad((options) => {
}
})
/** 加载热门岗位列表 */
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 (token()) startInterview()
if (!position.value) {
// 未传入岗位,展示选择弹窗(无论是否登录)
loadPositions()
showPositionPicker.value = true
} else if (token()) {
startInterview()
}
})
onBeforeUnmount(() => {
@@ -155,6 +231,11 @@ const startInterview = async () => {
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 })
@@ -198,8 +279,10 @@ const sendAnswer = async () => {
answeredCount.value = res.data.questionCount || answeredCount.value + 1
if (res.data.totalQuestions) MAX_QUESTIONS = res.data.totalQuestions
} else if (res.statusCode === 403) {
messages.value.push({ role: 'ai', content: res.data?.message || '面试次数已用完' })
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 || '回答提交失败' })
}
@@ -240,6 +323,8 @@ function onAvatarSilent() {
}
const goResult = () => uni.navigateTo({ url: `/pages/report/report?interviewId=${interviewId.value}` })
const goBuyProduct = () => uni.navigateTo({ url: '/pages/member/member?buy=interview' })
const goBuyMember = () => uni.navigateTo({ url: '/pages/member/member' })
const scrollToBottom = () => {
nextTick(() => { scrollToId.value = ''; setTimeout(() => { scrollToId.value = 'msg-bottom' }, 100) })
}
@@ -389,7 +474,32 @@ function stopRecord() {
.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)); }
.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>