feat: Admin定价管理界面 + 定价DB配置化 (P2)

This commit is contained in:
yuzhiran
2026-06-12 09:52:04 +08:00
parent a55cb56be2
commit d379d181e4
10 changed files with 361 additions and 104 deletions
+147 -23
View File
@@ -15,11 +15,11 @@
<view class="body" v-if="verified">
<view class="tabs">
<text class="tab" :class="{ active: tab === 'overview' }" @click="switchTab('overview')">概览</text>
<text class="tab" :class="{ active: tab === 'users' }" @click="switchTab('users')">用户管理</text>
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试记录</text>
<text class="tab" :class="{ active: tab === 'users' }" @click="switchTab('users')">用户</text>
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试</text>
<text class="tab" :class="{ active: tab === 'orders' }" @click="switchTab('orders')">订单</text>
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理员</text>
<text class="tab" :class="{ active: tab === 'config' }" @click="switchTab('config')">配置</text>
<text class="tab" :class="{ active: tab === 'pricing' }" @click="switchTab('pricing')">定价</text>
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理</text>
</view>
<!-- 概览 -->
@@ -102,24 +102,86 @@
</view>
<text class="loading-text" v-if="orderLoading">加载中...</text>
</view>
<!-- 套餐配置 -->
<view v-if="tab === 'config'" class="section">
<view class="config-card" v-if="!cfgLoading">
<view class="cfg-title">面试限制</view>
<view class="cfg-row"><text>免费版每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsFree }}</text></view>
<view class="cfg-row"><text>会员每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsVip }}</text></view>
<view class="cfg-row"><text>免费版每日面试次数</text><text class="cfg-val">{{ memberConfig.interview.dailyFreeLimit }}</text></view>
<!-- 定价管理 -->
<view v-if="tab === 'pricing'" class="section">
<view class="config-card">
<view class="cfg-title">产品定价</view>
<view class="cfg-row">
<text>AI 面试/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.interview.pricePerSession" @blur="calcInterviewPrice" />
</view>
<view class="cfg-row">
<text>简历优化/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.resumeOptimize.pricePerOptimize" />
</view>
<view class="cfg-row">
<text>简历下载/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.resumeDownload.pricePerDownload" />
</view>
<view class="cfg-row">
<text>免费优化次数</text>
<input class="cfg-input" type="digit" v-model.number="pricing.resumeOptimize.freeLimit" />
</view>
</view>
<view class="config-card" v-if="!cfgLoading">
<view class="cfg-title">诊断与优化限制</view>
<view class="cfg-row"><text>免费版每日诊断次数</text><text class="cfg-val">{{ memberConfig.diagnosis.dailyFreeLimit }}</text></view>
<view class="cfg-row"><text>免费版每日优化次数</text><text class="cfg-val">{{ memberConfig.optimize.dailyFreeLimit }}</text></view>
<view class="config-card">
<view class="cfg-title">成长版 ¥{{ growthPriceDisplay }}</view>
<view class="cfg-row">
<text>价格/</text>
<input class="cfg-input" type="digit" v-model.number="growthPriceTemp" />
</view>
<view class="cfg-row">
<text>面试额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.interview" />
</view>
<view class="cfg-row">
<text>优化额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.resumeOptimize" />
</view>
<view class="cfg-row">
<text>下载额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.resumeDownload" />
</view>
<view class="cfg-row">
<text>功能列表每行一个</text>
</view>
<textarea class="cfg-textarea" v-model="growthFeaturesText" placeholder="每行一个功能" />
</view>
<view class="config-card" v-if="!cfgLoading">
<view class="cfg-title">价格</view>
<view class="cfg-row"><text>月度会员</text><text class="cfg-val">¥{{ (memberConfig.price.monthly / 100).toFixed(0) }}</text></view>
<view class="config-card">
<view class="cfg-title">冲刺版 ¥{{ sprintPriceDisplay }}</view>
<view class="cfg-row">
<text>价格/</text>
<input class="cfg-input" type="digit" v-model.number="sprintPriceTemp" />
</view>
<view class="cfg-row">
<text>面试额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.interview" />
</view>
<view class="cfg-row">
<text>优化额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.resumeOptimize" />
</view>
<view class="cfg-row">
<text>下载额度/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.resumeDownload" />
</view>
<view class="cfg-row">
<text>功能列表每行一个</text>
</view>
<textarea class="cfg-textarea" v-model="sprintFeaturesText" placeholder="每行一个功能" />
</view>
<view class="empty-text" v-if="cfgLoading">加载中...</view>
<view class="config-card">
<view class="cfg-title">其他配置</view>
<view class="cfg-row">
<text>会员有效期</text>
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.durationDays" />
</view>
</view>
<button class="save-btn" @click="savePricing" :disabled="pricingLoading">保存定价配置</button>
<text class="loading-text" v-if="pricingLoading">保存中...</text>
</view>
<!-- 管理员 -->
<view v-if="tab === 'admins'" class="section">
@@ -151,8 +213,8 @@
</template>
<script setup>
import { ref } from 'vue'
import { api } from '../../config'
import { ref, computed } from 'vue'
import { api, API_ENDPOINTS } from '../../config'
const verified = ref(false)
const adminName = ref('')
@@ -172,6 +234,27 @@ const adminList = ref([])
const searchResult = ref(null)
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
const cfgLoading = ref(false)
const pricing = ref({
interview: { pricePerSession: 500 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300 },
resumeDownload: { pricePerDownload: 200 },
plans: {
growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: ['免费版全部权益', 'AI 数字人面试无限次'] },
sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: ['成长版全部权益'] },
},
})
const pricingLoading = ref(false)
const growthPriceTemp = ref(19.9)
const sprintPriceTemp = ref(49.9)
const growthFeaturesText = ref('')
const sprintFeaturesText = ref('')
const growthPriceDisplay = computed(() => growthPriceTemp.value.toFixed(1))
const sprintPriceDisplay = computed(() => sprintPriceTemp.value.toFixed(1))
const calcInterviewPrice = () => {
// Convert to 分 on save
}
const orders = ref([])
const ordersTotal = ref(0)
const ordersPage = ref(1)
@@ -218,7 +301,7 @@ const switchTab = (t) => {
if (t === 'users' && users.value.length === 0) loadUsers()
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
if (t === 'config') loadConfig()
if (t === 'pricing') loadPricing()
if (t === 'orders') loadOrders()
}
@@ -249,6 +332,43 @@ const loadInterviews = async () => {
finally { ivLoading.value = false }
}
const loadPricing = async () => {
pricingLoading.value = true
try {
const res = await apiAdmin('/pricing')
if (res.statusCode === 200 && res.data) {
pricing.value = res.data
growthPriceTemp.value = (res.data.plans?.growth?.price || 1990) / 100
sprintPriceTemp.value = (res.data.plans?.sprint?.price || 4990) / 100
growthFeaturesText.value = (res.data.plans?.growth?.features || []).join('\n')
sprintFeaturesText.value = (res.data.plans?.sprint?.features || []).join('\n')
}
} catch (e) { console.error(e) }
finally { pricingLoading.value = false }
}
const savePricing = async () => {
pricingLoading.value = true
try {
const data = JSON.parse(JSON.stringify(pricing.value))
data.plans.growth.price = Math.round(growthPriceTemp.value * 100)
data.plans.sprint.price = Math.round(sprintPriceTemp.value * 100)
data.plans.growth.features = growthFeaturesText.value.split('\n').filter(f => f.trim())
data.plans.sprint.features = sprintFeaturesText.value.split('\n').filter(f => f.trim())
const res = await apiAdmin('/pricing/save', { method: 'POST', data })
if (res.statusCode === 200) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
console.error(e)
}
finally { pricingLoading.value = false }
}
const loadConfig = async () => {
cfgLoading.value = true
try {
@@ -401,6 +521,10 @@ const setVip = async (targetUserId) => {
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); }
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); align-items: center; }
.cfg-val { font-weight: 600; color: var(--color-primary); }
.cfg-input { width: 160rpx; height: 56rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 12rpx; font-size: 22rpx; text-align: center; }
.cfg-textarea { width: 100%; height: 160rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 12rpx; font-size: 22rpx; margin-top: 8rpx; box-sizing: border-box; }
.save-btn { width: 100%; height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; border-radius: var(--radius-sm); font-size: 26rpx; border: none; margin-top: 12rpx; }
.save-btn:disabled { opacity: 0.6; }
</style>