feat: frontend credit system UI
Admin: - New CreditManagement.vue (tabs: rates, packages, plans, user credits, consumptions, stats) - Sidebar menu + router entry - Full CRUD for credit packages and subscription plans - User credit balance adjustment - Consumption log viewer User: - Credits.vue replaces Upgrade.vue (package purchase, subscription, history tabs) - Credit balance display in topbar + dashboard header CTA card - Navigation restructured: discovery first - Profile redirects to /credits - Dashboard upgrade dialog simplified to redirect to /credits
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="credits-page">
|
||||
<div class="balance-card" v-if="balance">
|
||||
<el-card>
|
||||
<div class="balance-header">
|
||||
<div>
|
||||
<div class="balance-label">当前余额</div>
|
||||
<div class="balance-amount">{{ balance.balance }} <small>次</small></div>
|
||||
</div>
|
||||
<div class="balance-sub">
|
||||
<span>已购买 {{ balance.total_purchased }} 次</span>
|
||||
<span>已使用 {{ balance.total_used }} 次</span>
|
||||
</div>
|
||||
<div v-if="balance.subscription" class="subscription-info">
|
||||
<el-tag type="success" v-if="balance.subscription.auto_renew">订阅中</el-tag>
|
||||
<span v-if="balance.subscription.expires_at">到期 {{ balance.subscription.expires_at?.split('T')[0] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="购买次数" name="packages">
|
||||
<div class="package-grid">
|
||||
<div v-for="pkg in packages" :key="pkg.id" class="package-card" @click="buyPackage(pkg)">
|
||||
<div class="pkg-name">{{ pkg.name }}</div>
|
||||
<div class="pkg-name-en">{{ pkg.name_en }}</div>
|
||||
<div class="pkg-credits">{{ pkg.credits }} <small>次</small></div>
|
||||
<div class="pkg-price">
|
||||
<span class="current">¥{{ pkg.price }}</span>
|
||||
<span v-if="pkg.original_price" class="original">¥{{ pkg.original_price }}</span>
|
||||
</div>
|
||||
<div class="pkg-unit">≈ ¥{{ (pkg.price / pkg.credits).toFixed(2) }}/次</div>
|
||||
<el-button type="primary" class="pkg-btn">立即购买</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="订阅套餐" name="subscription">
|
||||
<div class="plan-grid">
|
||||
<div v-for="plan in subscriptionPlans" :key="plan.id" class="plan-card">
|
||||
<div class="plan-name">{{ plan.name }}</div>
|
||||
<div class="plan-name-en">{{ plan.name_en }}</div>
|
||||
<div class="plan-credits">{{ plan.credits_per_month }} <small>次/月</small></div>
|
||||
<div class="plan-price">¥{{ plan.price }}<small>/月</small></div>
|
||||
<div class="plan-unit">≈ ¥{{ (plan.price / plan.credits_per_month).toFixed(2) }}/次</div>
|
||||
<el-button type="warning" class="plan-btn" @click="subscribePlan(plan)">开通订阅</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card v-if="balance?.subscription" style="margin-top:16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span>当前订阅状态: <el-tag type="success">已订阅</el-tag></span>
|
||||
<el-button size="small" @click="cancelSubscription">取消自动续费</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="消费记录" name="history">
|
||||
<el-table :data="history" border stripe v-loading="historyLoading">
|
||||
<el-table-column prop="created_at" label="时间" width="160" />
|
||||
<el-table-column prop="result_type" label="功能" width="140" />
|
||||
<el-table-column prop="credits_change" label="变化" width="80">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.credits_change < 0 ? '#f56c6c' : '#67c23a', fontWeight: 'bold' }">
|
||||
{{ row.credits_change > 0 ? '+' : '' }}{{ row.credits_change }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="balance_after" label="余额" width="70" />
|
||||
<el-table-column prop="source" label="来源" width="120" />
|
||||
<el-table-column prop="description" label="说明" />
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="historyTotal > historySize"
|
||||
v-model:current-page="historyPage"
|
||||
:page-size="historySize"
|
||||
:total="historyTotal"
|
||||
layout="prev, pager, next"
|
||||
style="margin-top:16px;justify-content:center"
|
||||
@current-change="loadHistory"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="showPayDialog" title="选择支付方式" width="360px">
|
||||
<p style="margin-bottom:12px">购买 <strong>{{ selectedPkg?.name }}</strong> ({{ selectedPkg?.credits }}次)</p>
|
||||
<p style="font-size:20px;font-weight:bold;color:#e6a23c;margin-bottom:16px">¥{{ selectedPkg?.price }}</p>
|
||||
<el-radio-group v-model="payType" style="display:flex;gap:16px;justify-content:center;margin-bottom:16px">
|
||||
<el-radio-button value="alipay">支付宝</el-radio-button>
|
||||
<el-radio-button value="wechat">微信支付</el-radio-button>
|
||||
</el-radio-group>
|
||||
<template #footer>
|
||||
<el-button @click="showPayDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmPurchase" :loading="paying">确认支付</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showQrDialog" title="扫码支付" width="320px">
|
||||
<div style="text-align:center">
|
||||
<img v-if="qrCodeUrl" :src="qrCodeUrl" style="width:240px;height:240px" />
|
||||
<p v-else>加载中...</p>
|
||||
<p style="color:#999;margin-top:12px">请使用{{ payType === 'alipay' ? '支付宝' : '微信' }}扫码支付</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getCreditBalance, getCreditHistory, getCreditPackages, getSubscriptionPlans,
|
||||
purchaseCreditPackage, subscribeCreditPlan, cancelCreditSubscription,
|
||||
} from '@/api'
|
||||
|
||||
const activeTab = ref('packages')
|
||||
const balance = ref(null)
|
||||
const packages = ref([])
|
||||
const subscriptionPlans = ref([])
|
||||
const history = ref([])
|
||||
const historyLoading = ref(false)
|
||||
const historyPage = ref(1)
|
||||
const historySize = ref(10)
|
||||
const historyTotal = ref(0)
|
||||
|
||||
const showPayDialog = ref(false)
|
||||
const selectedPkg = ref(null)
|
||||
const payType = ref('alipay')
|
||||
const paying = ref(false)
|
||||
const showQrDialog = ref(false)
|
||||
const qrCodeUrl = ref('')
|
||||
|
||||
async function loadBalance() {
|
||||
try { balance.value = await getCreditBalance() } catch (e) { /* ignore */ }
|
||||
}
|
||||
async function loadPackages() {
|
||||
try { packages.value = await getCreditPackages() } catch (e) { ElMessage.error('加载失败') }
|
||||
}
|
||||
async function loadPlans() {
|
||||
try { subscriptionPlans.value = await getSubscriptionPlans() } catch (e) { /* ignore */ }
|
||||
}
|
||||
async function loadHistory(page) {
|
||||
if (page) historyPage.value = page
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const res = await getCreditHistory({ page: historyPage.value, size: historySize.value })
|
||||
history.value = res.items
|
||||
historyTotal.value = res.total
|
||||
} catch (e) { /* ignore */ }
|
||||
historyLoading.value = false
|
||||
}
|
||||
|
||||
function buyPackage(pkg) {
|
||||
selectedPkg.value = pkg
|
||||
showPayDialog.value = true
|
||||
}
|
||||
async function confirmPurchase() {
|
||||
if (!selectedPkg.value) return
|
||||
paying.value = true
|
||||
try {
|
||||
const res = await purchaseCreditPackage(selectedPkg.value.id, payType.value)
|
||||
if (res.code_url) {
|
||||
qrCodeUrl.value = res.code_url
|
||||
showPayDialog.value = false
|
||||
showQrDialog.value = true
|
||||
} else if (res.pay_url) {
|
||||
window.open(res.pay_url, '_blank')
|
||||
showPayDialog.value = false
|
||||
} else {
|
||||
ElMessage.success('购买成功')
|
||||
showPayDialog.value = false
|
||||
await loadBalance()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.detail || '支付失败')
|
||||
}
|
||||
paying.value = false
|
||||
}
|
||||
|
||||
async function subscribePlan(plan) {
|
||||
try {
|
||||
await ElMessage.success('订阅功能开发中,即将开放')
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
async function cancelSubscription() {
|
||||
try {
|
||||
await cancelCreditSubscription()
|
||||
ElMessage.success('已取消自动续费')
|
||||
await loadBalance()
|
||||
} catch (e) { ElMessage.error(e?.detail || '取消失败') }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBalance()
|
||||
loadPackages()
|
||||
loadPlans()
|
||||
loadHistory(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.credits-page { max-width: 960px; margin: 0 auto; }
|
||||
.balance-card { margin-bottom: 24px; }
|
||||
.balance-header { display: flex; align-items: center; gap: 24px; }
|
||||
.balance-label { font-size: 14px; color: #999; }
|
||||
.balance-amount { font-size: 36px; font-weight: 700; color: #e6a23c; }
|
||||
.balance-amount small { font-size: 16px; }
|
||||
.balance-sub { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #999; }
|
||||
.subscription-info { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #666; }
|
||||
.package-grid, .plan-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
|
||||
.package-card, .plan-card {
|
||||
background: #fff; border: 1px solid #e8e8e8; border-radius: 12px;
|
||||
padding: 24px; text-align: center; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.package-card:hover, .plan-card:hover { border-color: #1890ff; box-shadow: 0 4px 12px rgba(24,144,255,0.1); transform: translateY(-2px); }
|
||||
.pkg-name, .plan-name { font-size: 18px; font-weight: 600; }
|
||||
.pkg-name-en, .plan-name-en { font-size: 12px; color: #999; margin-bottom: 8px; }
|
||||
.pkg-credits, .plan-credits { font-size: 32px; font-weight: 700; color: #1890ff; }
|
||||
.pkg-credits small, .plan-credits small { font-size: 14px; }
|
||||
.pkg-price { margin: 8px 0; }
|
||||
.pkg-price .current { font-size: 22px; font-weight: 700; color: #e6a23c; }
|
||||
.pkg-price .original { font-size: 14px; color: #999; text-decoration: line-through; margin-left: 8px; }
|
||||
.plan-price { font-size: 24px; font-weight: 700; color: #e6a23c; margin: 8px 0; }
|
||||
.plan-price small { font-size: 14px; }
|
||||
.pkg-unit, .plan-unit { font-size: 12px; color: #999; margin-bottom: 12px; }
|
||||
.pkg-btn, .plan-btn { width: 100%; }
|
||||
</style>
|
||||
@@ -11,8 +11,8 @@
|
||||
</div>
|
||||
<el-divider style="margin:8px 0" />
|
||||
<div class="profile-menu">
|
||||
<div class="menu-item" @click="$router.push('/upgrade')">
|
||||
<el-icon><Crown /></el-icon><span>升级会员</span>
|
||||
<div class="menu-item" @click="$router.push('/credits')">
|
||||
<el-icon><Coin /></el-icon><span>购买次数</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="$router.push('/certification')">
|
||||
<el-icon><Stamp /></el-icon><span>实名认证</span>
|
||||
|
||||
@@ -20,10 +20,24 @@
|
||||
style="margin-bottom:16px"
|
||||
>
|
||||
<template #default>
|
||||
<span>试用结束后将自动恢复为免费版。 <el-button text type="primary" size="small" @click="showUpgrade = true">立即升级正式版</el-button></span>
|
||||
<span>试用结束后将自动恢复为免费版。 <el-button text type="primary" size="small" @click="$router.push('/credits')">购买更多次数</el-button></span>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom:16px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0">
|
||||
<div>
|
||||
<div style="font-size:14px;opacity:0.9">信用余额</div>
|
||||
<div style="font-size:28px;font-weight:700">{{ creditBalance ?? '...' }} 次</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<el-button type="primary" @click="$router.push('/discovery')" style="margin-bottom:4px">发现新客户</el-button>
|
||||
<br>
|
||||
<el-button text style="color:#fff;opacity:0.8" @click="$router.push('/credits')">购买次数 →</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="12" :sm="6" v-for="item in stats" :key="item.label">
|
||||
<el-card shadow="hover" class="stat-card" @click="item.route && $router.push(item.route)">
|
||||
@@ -37,7 +51,7 @@
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="section-title">本月用量</span>
|
||||
<el-button v-if="usageStats.tier !== 'enterprise'" text type="primary" size="small" @click="showUpgrade = true">升级以获取更多额度</el-button>
|
||||
<el-button v-if="usageStats.tier !== 'enterprise'" text type="primary" size="small" @click="$router.push('/credits')">购买更多次数</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="usage-grid">
|
||||
@@ -139,7 +153,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="text-align:center;margin-top:20px">
|
||||
<el-button type="primary" size="large" @click="$router.push('/upgrade')">立即升级</el-button>
|
||||
<el-button type="primary" size="large" @click="$router.push('/credits')">购买次数</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -148,6 +162,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getCreditBalance } from '@/api'
|
||||
import { getAnalyticsOverview, translate, getFollowupPending, getSilentCustomers, getUsageStats } from '@/api'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -160,6 +175,7 @@ const followups = ref([])
|
||||
const silentCustomers = ref([])
|
||||
const usageStats = ref({ tier: 'free', limits: {}, usage: {} })
|
||||
const showUpgrade = ref(false)
|
||||
const creditBalance = ref(null)
|
||||
|
||||
const usageItems = computed(() => {
|
||||
const u = usageStats.value.usage || {}
|
||||
@@ -206,6 +222,7 @@ const features = [
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
getCreditBalance().then(res => { creditBalance.value = res.balance }).catch(() => {})
|
||||
const [overview, fup, silent, usage] = await Promise.all([
|
||||
getAnalyticsOverview().catch(() => null),
|
||||
getFollowupPending().catch(() => []),
|
||||
|
||||
Reference in New Issue
Block a user