feat: Admin定价管理界面 + 定价DB配置化 (P2)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user