feat: 简化为'每天5次免费→充值星尘'模型——问答免费优先星尘扣减(10/次)+充值引导弹窗+我的页星尘余额卡(去掉免费/Pro分层)

This commit is contained in:
2026-09-14 11:07:19 +08:00
parent 3777f67af6
commit d44893d78d
3 changed files with 87 additions and 35 deletions
+15
View File
@@ -160,6 +160,21 @@ async function sendMessage() {
const msg = e.message || 'AI 服务暂时不可用,请稍后再试' const msg = e.message || 'AI 服务暂时不可用,请稍后再试'
if (msg.includes('游客体验已用完')) { if (msg.includes('游客体验已用完')) {
guestExhausted.value = true guestExhausted.value = true
} else if (msg.includes('免费次数已用完') || msg.includes('星尘余额不足') || msg.includes('充值星尘')) {
// 引导充值
uni.showModal({
title: '免费次数用完了',
content: '充值星尘可继续提问(10星尘/次),还可以享受更多功能',
confirmText: '去充值',
cancelText: '分享得次数',
success: (m) => {
if (m.confirm) {
uni.navigateTo({ url: '/pages/vpay/recharge' })
} else {
onQuotaTap()
}
},
})
} else { } else {
messages.value.push({ role: 'assistant', content: escapeHtml(`抱歉,${msg}`) }) messages.value.push({ role: 'assistant', content: escapeHtml(`抱歉,${msg}`) })
} }
+50 -30
View File
@@ -55,39 +55,21 @@
</view> </view>
</view> </view>
<!-- 订阅卡片 --> <!-- 星尘余额 + 今日免费(简化模型:每天5次免费,用完充值星尘) -->
<view class="plan-card" @click="goPayment"> <view class="balance-card">
<view class="plan-info"> <view class="balance-left">
<text class="plan-title">{{ planTitle }}</text> <view class="coin-row">
<text class="plan-desc">{{ planDesc }}</text> <text class="coin-icon">✨</text>
<view class="plan-progress" v-if="userStore.subInfo"> <text class="coin-num">{{ vpayBalance }}</text>
<view class="progress-track"> <text class="coin-name">星尘</text>
<view class="progress-fill" :style="{ width: planPercent + '%' }"></view>
</view>
<text class="progress-text">{{ planExpiryText }}</text>
</view> </view>
<text class="free-left">今日免费问答剩余 {{ freeRemaining }} 次</text>
</view> </view>
<view class="plan-btn" :class="{ disabled: userStore.isSubscribed }"> <view class="recharge-go" @click="goRecharge">
<text>{{ planBtnText }}</text> <text>充值</text>
</view> </view>
</view> </view>
<!-- 星尘充值入口 -->
<!-- AI 问答配额 -->
<view class="quota-card" @click="goChat" v-if="userStore.isLoggedIn && quotaInfo">
<view class="quota-head">
<AppIcon name="chat" :size="15" />
<text class="quota-card-title">AI 问答 · 今日</text>
</view>
<view class="quota-bar">
<view class="quota-fill" :style="{ width: quotaPercent + '%' }"></view>
</view>
<view class="quota-foot">
<text class="quota-text">剩余 {{ quotaInfo.remainingQuota }} 次</text>
<text class="quota-share" @click.stop="goShare">分享 +5 次</text>
</view>
</view>
<!-- 星尘充值入口 -->
<view class="vpay-entry" @click="goRecharge"> <view class="vpay-entry" @click="goRecharge">
<view class="ve-icon">✨</view> <view class="ve-icon">✨</view>
<view class="ve-info"> <view class="ve-info">
@@ -155,7 +137,7 @@
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { knowledgeApi, aiChatApi } from '@/utils/api' import { knowledgeApi, aiChatApi, request } from '@/utils/api'
import { DIMENSIONS } from '@/utils/dimensions' import { DIMENSIONS } from '@/utils/dimensions'
import AppIcon from '@/components/AppIcon.vue' import AppIcon from '@/components/AppIcon.vue'
import { APP_VERSION } from '@/utils/version' import { APP_VERSION } from '@/utils/version'
@@ -169,6 +151,8 @@ const userStore = useUserStore()
const dimensions = DIMENSIONS const dimensions = DIMENSIONS
const collectedCount = ref(0) const collectedCount = ref(0)
const vpayBalance = ref(0)
const freeRemaining = ref(5)
const stats = ref(null) const stats = ref(null)
const byDim = ref({}) const byDim = ref({})
const quotaInfo = ref(null) const quotaInfo = ref(null)
@@ -218,12 +202,23 @@ const quotaPercent = computed(() => {
onMounted(() => { onMounted(() => {
loadStats() loadStats()
if (userStore.isLoggedIn) { if (userStore.isLoggedIn) {
loadVpayInfo()
userStore.fetchMe() userStore.fetchMe()
loadCollected() loadCollected()
loadQuota() loadQuota()
} }
}) })
async function loadVpayInfo() {
try {
const data = await request('GET', '/api/vpay/info', null, userStore.token)
vpayBalance.value = data.balance || 0
if (userStore.quota) freeRemaining.value = userStore.quota.remainingQuota ?? 5
} catch (e) {
// 未开通或未登录
}
}
async function loadStats() { async function loadStats() {
try { try {
const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '') const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '')
@@ -649,3 +644,28 @@ onShareTimeline(() => ({
.ve-desc { font-size: 11px; color: var(--text-tertiary); } .ve-desc { font-size: 11px; color: var(--text-tertiary); }
.ve-arrow { font-size: 14px; color: var(--text-muted); } .ve-arrow { font-size: 14px; color: var(--text-muted); }
</style> </style>
<style scoped>
.balance-card {
display: flex; align-items: center; gap: 14px;
background: linear-gradient(135deg, #5b5ff8, #8b5cf6);
border-radius: var(--radius-lg);
padding: 18px 20px;
margin-bottom: 12px;
}
.balance-left { flex: 1; }
.coin-row { display: flex; align-items: baseline; gap: 6px; }
.coin-icon { font-size: 20px; }
.coin-num { font-size: 32px; font-weight: 800; color: #fff; }
.coin-name { font-size: 13px; color: rgba(255,255,255,.7); }
.free-left { display: block; font-size: 11px; color: rgba(255,255,255,.55); margin-top: 4px; }
.recharge-go {
padding: 10px 22px;
background: #fff;
border-radius: var(--radius-full);
color: #5b5ff8;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
</style>
+22 -5
View File
@@ -1,6 +1,6 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const { AIChatQuota, AIModel } = require('../models') const { AIChatQuota, AIModel, User } = require('../models')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth') const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger') const logger = require('../utils/logger')
@@ -81,10 +81,27 @@ router.post('/ask', askRateLimit, optionalAuthMiddleware, async (req, res) => {
const quota = await getOrCreateQuota(openid, isGuest) const quota = await getOrCreateQuota(openid, isGuest)
if (!quota.hasQuota()) { if (!quota.hasQuota()) {
const msg = isGuest if (isGuest) {
? `今日 ${GUEST_DAILY_QUOTA} 次游客体验已用完,登录后每天可问 5 次` return ApiResponse.error(res, `今日 ${GUEST_DAILY_QUOTA} 次游客体验已用完,登录后每天可问 5 次`, 403, { isGuest, needLogin: true })
: '今日问答次数已用完,分享好友可获得更多次数' }
return ApiResponse.error(res, msg, 403, { isGuest, needLogin: isGuest }) // 免费额度用完 → 检查星尘余额,有则扣星尘(10星尘/次)
const user = await User.findOne({ openid })
const coins = (user && user.vpayCoins) || 0
if (coins < 10) {
return ApiResponse.error(res, '今日免费次数已用完,充值星尘可继续提问(10星尘/次)', 402, { needRecharge: true, coins, costPerAsk: 10 })
}
// 扣星尘(原子操作防并发)
const updated = await User.updateOne({ _id: user._id, vpayCoins: { $gte: 10 } }, { $inc: { vpayCoins: -10 } })
if (updated.modifiedCount === 0) {
return ApiResponse.error(res, '星尘余额不足,请充值后继续', 402, { needRecharge: true })
}
// 跳过正常 quota 扣减,直接用星尘
const aiModels2 = await AIModel.getActiveModels()
if (!aiModels2 || aiModels2.length === 0) {
const env2 = [{ name: 'env-model', apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions', apiKey: process.env.OPENAI_API_KEY, modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo' }]
return await tryModels(env2, question, dimension, res, quota, isGuest)
}
return await tryModels(aiModels2, question, dimension, res, quota, isGuest)
} }
const aiModels = await AIModel.getActiveModels() const aiModels = await AIModel.getActiveModels()