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
+43 -1
View File
@@ -39,6 +39,20 @@
</view>
</view>
<!-- 配额与会员信息 -->
<view class="quota-card" v-if="isLoggedIn">
<view class="quota-row">
<view class="quota-info">
<text class="quota-plan">{{ memberInfo.planName || '免费版' }}</text>
<text class="quota-count">引力值 {{ memberInfo.gravity ?? 0 }}</text>
</view>
<view class="quota-actions">
<text class="quota-btn primary" @click="goBuyCredits">补充引力值</text>
<text class="quota-btn" :class="memberInfo.plan !== 'free' ? 'owned' : ''" @click="goUpgrade">{{ memberInfo.plan !== 'free' ? '已开通' : '升级会员' }}</text>
</view>
</view>
</view>
<!-- 菜单列表 -->
<view class="menu-area">
<view class="menu-group">
@@ -102,6 +116,7 @@ const userInfo = ref({})
const isAdmin = ref(false)
const stats = ref({ interviewCount: 0, avgScore: '--', completedCount: 0 })
const token = ref('')
const memberInfo = ref({ plan: 'free', planName: '免费版', remaining: 0, gravity: 0 })
const isLoggedIn = computed(() => !!token.value)
const maskedId = computed(() => {
@@ -114,6 +129,7 @@ const refreshState = () => {
if (!token.value) return
try { const s = uni.getStorageSync('userInfo'); if (s) userInfo.value = JSON.parse(s) } catch(e) {}
loadStats()
loadMemberStatus()
checkAdmin()
fetchUserInfo()
}
@@ -131,6 +147,16 @@ const fetchUserInfo = async () => {
onMounted(refreshState)
onShow(refreshState)
const loadMemberStatus = async () => {
if (!token.value) return
try {
const res = await uni.request({ url: api('/member/status'), method: 'GET', header: { 'Authorization': `Bearer ${token.value}` } })
if (res.statusCode >= 200 && res.statusCode < 300 && res.data) {
memberInfo.value = { plan: res.data.plan || 'free', planName: res.data.planName || '免费版', remaining: res.data.remaining ?? 0, gravity: res.data.gravity ?? 0 }
}
} catch(e) { /* silent */ }
}
const loadStats = async () => {
try {
const res = await uni.request({ url: api('/interview/stats/mine'), method: 'GET', header: { 'Authorization': `Bearer ${token.value}` } })
@@ -153,6 +179,11 @@ const checkAdmin = () => {
}
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
const goBuyCredits = () => uni.navigateTo({ url: '/pages/member/member?buy=interview' })
const goUpgrade = () => {
if (memberInfo.value.plan !== 'free') return // already on a paid plan
uni.navigateTo({ url: '/pages/member/member' })
}
const goCareer = () => uni.navigateTo({ url: '/pages/career/career' })
const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
const goReviewReview = () => uni.navigateTo({ url: '/pages/review/review' })
@@ -197,7 +228,18 @@ const doLogout = () => {
.guest-info { flex: 1; }
.guest-name { font-size: 30rpx; font-weight: 600; color: #FFFFFF; }
.menu-area { padding: 0 32rpx 32rpx; margin-top: -40rpx; }
.quota-card { margin: -48rpx 32rpx 16rpx; background: #FFFFFF; border-radius: var(--radius-lg); padding: 28rpx 24rpx; box-shadow: var(--shadow-sm); position: relative; z-index: 1; }
.quota-row { display: flex; align-items: center; justify-content: space-between; }
.quota-info { display: flex; flex-direction: column; gap: 4rpx; }
.quota-plan { font-size: 28rpx; font-weight: 700; color: var(--color-text); }
.quota-count { font-size: 22rpx; color: #6B7280; }
.quota-actions { display: flex; gap: 12rpx; }
.quota-btn { font-size: 22rpx; padding: 8rpx 20rpx; border-radius: var(--radius-sm); font-weight: 500; white-space: nowrap; }
.quota-btn.primary { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; }
.quota-btn.owned { background: #F3F4F6; color: #9CA3AF; }
.quota-btn:not(.primary):not(.owned) { background: #FEF3C7; color: #92400E; border: 2rpx solid #F59E0B; }
.menu-area { padding: 0 32rpx 32rpx; margin-top: 8rpx; }
.menu-group { background: #FFFFFF; border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 24rpx; box-shadow: var(--shadow-sm); }
.menu-item { display: flex; align-items: center; padding: 28rpx 32rpx; border-bottom: 1rpx solid var(--color-border); }
.menu-item:last-child { border-bottom: none; }