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:
TradeMate Dev
2026-06-12 11:00:22 +08:00
parent 2a107a42f3
commit a95e8b2b73
10 changed files with 714 additions and 11 deletions
+18
View File
@@ -62,4 +62,22 @@ export function listPayments(params) { return http.get('/admin/payments', { para
export function getPaymentStats() { return http.get('/admin/payments/stats') }
export function adminRefund(order_no, reason = '') { return http.post('/admin/payments/refund', { order_no, reason }) }
export function listCreditPackages() { return http.get('/admin/credit-packages') }
export function createCreditPackage(data) { return http.post('/admin/credit-packages', data) }
export function updateCreditPackage(id, data) { return http.put(`/admin/credit-packages/${id}`, data) }
export function deleteCreditPackage(id) { return http.delete(`/admin/credit-packages/${id}`) }
export function listSubscriptionPlans() { return http.get('/admin/subscription-plans') }
export function createSubscriptionPlan(data) { return http.post('/admin/subscription-plans', data) }
export function updateSubscriptionPlan(id, data) { return http.put(`/admin/subscription-plans/${id}`, data) }
export function deleteSubscriptionPlan(id) { return http.delete(`/admin/subscription-plans/${id}`) }
export function listUserCredits(page = 1, size = 20) { return http.get('/admin/user-credits', { params: { page, size } }) }
export function adjustUserCredits(userId, credits, reason = '') {
return http.post('/admin/user-credits/adjust', { user_id: userId, credits, reason })
}
export function listCreditConsumptions(params) { return http.get('/admin/credit-consumptions', { params }) }
export function getCreditStats() { return http.get('/admin/credit-stats') }
export default http
@@ -30,6 +30,10 @@
<el-icon><Wallet /></el-icon>
<span>支付管理</span>
</el-menu-item>
<el-menu-item index="/credits">
<el-icon><Coin /></el-icon>
<span>信用管理</span>
</el-menu-item>
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<span>配置</span>
+8
View File
@@ -44,6 +44,14 @@ const routes = [
{ path: '', name: 'Payments', component: () => import('@/views/Payments.vue'), meta: { title: '支付管理' } },
]
},
{
path: '/credits',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Credits', component: () => import('@/views/CreditManagement.vue'), meta: { title: '信用管理' } },
]
},
{
path: '/config',
component: AdminLayout,
@@ -0,0 +1,395 @@
<template>
<div class="credit-management">
<el-tabs v-model="activeTab">
<el-tab-pane label="消费速率" name="rates">
<el-card>
<el-alert title="各功能信用消耗速率" type="info" :closable="false" show-icon style="margin-bottom:16px" />
<el-table :data="rates" border stripe>
<el-table-column prop="feature" label="功能" width="200" />
<el-table-column prop="credits" label="消耗次数" width="120" />
<el-table-column prop="description" label="说明" />
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="次数包管理" name="packages">
<el-card>
<div style="margin-bottom:12px">
<el-button type="primary" @click="showPackageDialog = true">添加次数包</el-button>
</div>
<el-table :data="packages" border stripe v-loading="loading.packages">
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="name_en" label="英文名" width="120" />
<el-table-column prop="credits" label="次数" width="80" />
<el-table-column prop="price" label="价格(¥)" width="100" />
<el-table-column prop="price_usd" label="价格($)" width="100" />
<el-table-column prop="original_price" label="原价(¥)" width="100" />
<el-table-column prop="is_active" label="启用" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'">{{ row.is_active ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editPackage(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deletePackage(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="订阅套餐" name="plans">
<el-card>
<div style="margin-bottom:12px">
<el-button type="primary" @click="showPlanDialog = true">添加套餐</el-button>
</div>
<el-table :data="plans" border stripe v-loading="loading.plans">
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="name_en" label="英文名" width="120" />
<el-table-column prop="credits_per_month" label="月次数" width="100" />
<el-table-column prop="price" label="月费(¥)" width="100" />
<el-table-column prop="price_usd" label="月费($)" width="100" />
<el-table-column prop="is_active" label="启用" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'">{{ row.is_active ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editPlan(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deletePlan(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="用户余额" name="userCredits">
<el-card>
<div style="margin-bottom:12px;display:flex;gap:12px;align-items:center">
<el-input v-model="searchUserId" placeholder="搜索用户ID" style="width:300px" clearable />
<el-button type="primary" @click="loadUserCredits">查询</el-button>
</div>
<el-table :data="userCredits" border stripe v-loading="loading.userCredits">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="user_id" label="用户ID" width="240" show-overflow-tooltip />
<el-table-column prop="balance" label="余额" width="80" />
<el-table-column prop="total_purchased" label="总购买" width="80" />
<el-table-column prop="total_used" label="总消耗" width="80" />
<el-table-column prop="free_trial_used" label="试用已领" width="80">
<template #default="{ row }">
<el-tag :type="row.free_trial_used ? 'success' : 'info'">{{ row.free_trial_used ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="showAdjustDialog(row)">调整</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="userCreditTotal > userCreditSize"
v-model:current-page="userCreditPage"
:page-size="userCreditSize"
:total="userCreditTotal"
layout="prev, pager, next"
style="margin-top:16px;justify-content:center"
@current-change="loadUserCredits"
/>
</el-card>
</el-tab-pane>
<el-tab-pane label="消费流水" name="consumptions">
<el-card>
<div style="margin-bottom:12px;display:flex;gap:12px">
<el-input v-model="consumptionUserId" placeholder="用户ID" style="width:200px" clearable />
<el-select v-model="consumptionType" placeholder="功能类型" clearable style="width:150px">
<el-option v-for="r in rateOptions" :key="r.feature" :label="r.feature" :value="r.feature" />
</el-select>
<el-button type="primary" @click="loadConsumptions(1)">查询</el-button>
</div>
<el-table :data="consumptions" border stripe v-loading="loading.consumptions">
<el-table-column prop="user_id" label="用户ID" width="220" show-overflow-tooltip />
<el-table-column prop="result_type" label="功能" width="120" />
<el-table-column prop="credits_change" label="变化" width="80">
<template #default="{ row }">
<span :style="{ color: row.credits_change < 0 ? '#f56c6c' : '#67c23a' }">
{{ row.credits_change > 0 ? '+' : '' }}{{ row.credits_change }}
</span>
</template>
</el-table-column>
<el-table-column prop="balance_after" label="余额" width="80" />
<el-table-column prop="source" label="来源" width="120" />
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" width="180" />
</el-table>
<el-pagination
v-if="consumptionTotal > consumptionSize"
v-model:current-page="consumptionPage"
:page-size="consumptionSize"
:total="consumptionTotal"
layout="prev, pager, next"
style="margin-top:16px;justify-content:center"
@current-change="loadConsumptions"
/>
</el-card>
</el-tab-pane>
<el-tab-pane label="统计概览" name="stats">
<el-card>
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="总购买次数" :value="stats.total_purchased" />
</el-col>
<el-col :span="6">
<el-statistic title="总消耗次数" :value="stats.total_consumed" />
</el-col>
<el-col :span="6">
<el-statistic title="当前总余额" :value="stats.total_balance" />
</el-col>
<el-col :span="6">
<el-statistic title="活跃用户" :value="stats.total_users_with_credits" />
</el-col>
</el-row>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="showPackageDialog" :title="editingPackage ? '编辑次数包' : '添加次数包'" width="500px">
<el-form :model="packageForm" label-width="100px">
<el-form-item label="名称"><el-input v-model="packageForm.name" /></el-form-item>
<el-form-item label="英文名"><el-input v-model="packageForm.name_en" /></el-form-item>
<el-form-item label="次数"><el-input-number v-model="packageForm.credits" :min="1" /></el-form-item>
<el-form-item label="价格(¥)"><el-input-number v-model="packageForm.price" :min="0" :precision="2" /></el-form-item>
<el-form-item label="价格($)"><el-input-number v-model="packageForm.price_usd" :min="0" :precision="2" /></el-form-item>
<el-form-item label="原价(¥)"><el-input-number v-model="packageForm.original_price" :min="0" :precision="2" /></el-form-item>
<el-form-item label="启用">
<el-switch v-model="packageForm.is_active" />
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="packageForm.sort_order" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showPackageDialog = false">取消</el-button>
<el-button type="primary" @click="savePackage">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="showPlanDialog" :title="editingPlan ? '编辑套餐' : '添加套餐'" width="500px">
<el-form :model="planForm" label-width="120px">
<el-form-item label="名称"><el-input v-model="planForm.name" /></el-form-item>
<el-form-item label="英文名"><el-input v-model="planForm.name_en" /></el-form-item>
<el-form-item label="月次数"><el-input-number v-model="planForm.credits_per_month" :min="1" /></el-form-item>
<el-form-item label="月费(¥)"><el-input-number v-model="planForm.price" :min="0" :precision="2" /></el-form-item>
<el-form-item label="月费($)"><el-input-number v-model="planForm.price_usd" :min="0" :precision="2" /></el-form-item>
<el-form-item label="天数"><el-input-number v-model="planForm.duration_days" :min="1" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="planForm.is_active" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="planForm.sort_order" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showPlanDialog = false">取消</el-button>
<el-button type="primary" @click="savePlan">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="showAdjustDialog" title="调整用户余额" width="400px">
<p>用户ID: {{ adjustTarget?.user_id }}</p>
<p>当前余额: {{ adjustTarget?.balance }}</p>
<el-form label-width="80px" style="margin-top:12px">
<el-form-item label="调整次数">
<el-input-number v-model="adjustCredits" :min="-99999" :precision="1" />
</el-form-item>
<el-form-item label="原因">
<el-input v-model="adjustReason" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAdjustDialog = false">取消</el-button>
<el-button type="primary" @click="confirmAdjust">确认调整</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listCreditPackages, createCreditPackage, updateCreditPackage, deleteCreditPackage,
listSubscriptionPlans, createSubscriptionPlan, updateSubscriptionPlan, deleteSubscriptionPlan,
listUserCredits, adjustUserCredits,
listCreditConsumptions, getCreditStats,
} from '@/api'
const activeTab = ref('stats')
const rateOptions = [
{ feature: 'lead_search', credits: 10, description: '客户搜索 (15条线索+AI匹配)' },
{ feature: 'company_analysis', credits: 5, description: '公司深度分析' },
{ feature: 'market_intel', credits: 20, description: '市场情报报告' },
{ feature: 'translate_per_1000chars', credits: 1, description: '翻译 (每1000字符)' },
{ feature: 'reply_suggest', credits: 2, description: '回复建议生成' },
{ feature: 'outreach', credits: 3, description: '开发信生成' },
{ feature: 'marketing_content', credits: 5, description: '营销素材生成' },
{ feature: 'competitor_analysis', credits: 10, description: '竞品分析' },
{ feature: 'ai_chat', credits: 1, description: 'AI助手对话' },
{ feature: 'info_extract', credits: 1, description: '信息提取' },
{ feature: 'followup_scan', credits: 2, description: '跟进扫描' },
]
const rates = ref(rateOptions)
const packages = ref([])
const plans = ref([])
const userCredits = ref([])
const consumptions = ref([])
const stats = ref({ total_purchased: 0, total_consumed: 0, total_balance: 0, total_users_with_credits: 0 })
const loading = reactive({ packages: false, plans: false, userCredits: false, consumptions: false })
const showPackageDialog = ref(false)
const showPlanDialog = ref(false)
const showAdjustDialog = ref(false)
const editingPackage = ref(null)
const editingPlan = ref(null)
const adjustTarget = ref(null)
const adjustCredits = ref(0)
const adjustReason = ref('')
const defaultPackageForm = () => ({ name: '', name_en: '', credits: 100, price: 79, price_usd: null, original_price: null, is_active: true, sort_order: 0 })
const defaultPlanForm = () => ({ name: '', name_en: '', credits_per_month: 100, price: 69, price_usd: null, duration_days: 30, is_active: true, sort_order: 0 })
const packageForm = ref(defaultPackageForm())
const planForm = ref(defaultPlanForm())
const searchUserId = ref('')
const userCreditPage = ref(1)
const userCreditSize = ref(20)
const userCreditTotal = ref(0)
const consumptionUserId = ref('')
const consumptionType = ref('')
const consumptionPage = ref(1)
const consumptionSize = ref(20)
const consumptionTotal = ref(0)
async function loadPackages() {
loading.packages = true
try { packages.value = await listCreditPackages() } catch (e) { ElMessage.error('加载次数包失败') }
loading.packages = false
}
async function loadPlans() {
loading.plans = true
try { plans.value = await listSubscriptionPlans() } catch (e) { ElMessage.error('加载订阅套餐失败') }
loading.plans = false
}
async function loadUserCredits() {
loading.userCredits = true
try {
const res = await listUserCredits(userCreditPage.value, userCreditSize.value)
userCredits.value = res.items
userCreditTotal.value = res.total
} catch (e) { ElMessage.error('加载用户余额失败') }
loading.userCredits = false
}
async function loadConsumptions(page) {
if (page) consumptionPage.value = page
loading.consumptions = true
try {
const params = { page: consumptionPage.value, size: consumptionSize.value }
if (consumptionUserId.value) params.user_id = consumptionUserId.value
if (consumptionType.value) params.result_type = consumptionType.value
const res = await listCreditConsumptions(params)
consumptions.value = res.items
consumptionTotal.value = res.total
} catch (e) { ElMessage.error('加载消费流水失败') }
loading.consumptions = false
}
async function loadStats() {
try { stats.value = await getCreditStats() } catch (e) { /* ignore */ }
}
function editPackage(row) {
editingPackage.value = row
packageForm.value = { ...row }
showPackageDialog.value = true
}
async function savePackage() {
try {
if (editingPackage.value) {
await updateCreditPackage(editingPackage.value.id, packageForm.value)
ElMessage.success('更新成功')
} else {
await createCreditPackage(packageForm.value)
ElMessage.success('创建成功')
}
showPackageDialog.value = false
editingPackage.value = null
packageForm.value = defaultPackageForm()
await loadPackages()
} catch (e) { ElMessage.error('保存失败') }
}
async function deletePackage(row) {
try {
await ElMessageBox.confirm('确认删除?')
await deleteCreditPackage(row.id)
ElMessage.success('删除成功')
await loadPackages()
} catch (e) { /* cancelled or error */ }
}
function editPlan(row) {
editingPlan.value = row
planForm.value = { ...row }
showPlanDialog.value = true
}
async function savePlan() {
try {
if (editingPlan.value) {
await updateSubscriptionPlan(editingPlan.value.id, planForm.value)
ElMessage.success('更新成功')
} else {
await createSubscriptionPlan(planForm.value)
ElMessage.success('创建成功')
}
showPlanDialog.value = false
editingPlan.value = null
planForm.value = defaultPlanForm()
await loadPlans()
} catch (e) { ElMessage.error('保存失败') }
}
async function deletePlan(row) {
try {
await ElMessageBox.confirm('确认删除?')
await deleteSubscriptionPlan(row.id)
ElMessage.success('删除成功')
await loadPlans()
} catch (e) { /* cancelled */ }
}
function showAdjustDialog(row) {
adjustTarget.value = row
adjustCredits.value = 0
adjustReason.value = ''
showAdjustDialog.value = true
}
async function confirmAdjust() {
if (!adjustCredits.value) {
ElMessage.warning('请输入调整次数')
return
}
try {
await adjustUserCredits(adjustTarget.value.user_id, adjustCredits.value, adjustReason.value)
ElMessage.success('调整成功')
showAdjustDialog.value = false
await loadUserCredits()
} catch (e) { ElMessage.error('调整失败') }
}
onMounted(() => {
loadPackages()
loadPlans()
loadUserCredits()
loadConsumptions(1)
loadStats()
})
</script>
+12
View File
@@ -119,4 +119,16 @@ export function getUsageStats() { return http.get('/usage/stats') }
export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) }
export function aiQuickQuestions() { return http.get('/ai/quick-questions') }
export function getCreditBalance() { return http.get('/credits/balance') }
export function getCreditHistory(params) { return http.get('/credits/history', { params }) }
export function getCreditPackages() { return http.get('/credits/packages') }
export function getSubscriptionPlans() { return http.get('/credits/subscription-plans') }
export function purchaseCreditPackage(packageId, payType = 'alipay') {
return http.post('/credits/purchase', { package_id: packageId, pay_type: payType })
}
export function subscribeCreditPlan(planId, payType = 'alipay') {
return http.post('/credits/subscribe', { plan_id: planId, pay_type: payType })
}
export function cancelCreditSubscription() { return http.post('/credits/cancel-subscription') }
export default http
+25 -3
View File
@@ -16,13 +16,13 @@
:collapse-transition="false"
@select="showMobileMenu = false"
>
<el-menu-item index="/discovery"><el-icon><Search /></el-icon><span>发现客户</span></el-menu-item>
<el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>工作台</span></el-menu-item>
<el-menu-item index="/translate"><el-icon><ChatLineSquare /></el-icon><span>智能翻译</span></el-menu-item>
<el-menu-item index="/customers"><el-icon><User /></el-icon><span>客户管理</span></el-menu-item>
<el-menu-item index="/products"><el-icon><Goods /></el-icon><span>产品库</span></el-menu-item>
<el-menu-item index="/quotations"><el-icon><DocumentCopy /></el-icon><span>报价单</span></el-menu-item>
<el-menu-item index="/translate"><el-icon><ChatLineSquare /></el-icon><span>智能翻译</span></el-menu-item>
<el-menu-item index="/marketing"><el-icon><Promotion /></el-icon><span>营销素材</span></el-menu-item>
<el-menu-item index="/discovery"><el-icon><Search /></el-icon><span>挖掘新客</span></el-menu-item>
<el-menu-item index="/followup"><el-icon><Message /></el-icon><span>智能跟进</span></el-menu-item>
<el-menu-item index="/analytics"><el-icon><DataAnalysis /></el-icon><span>数据分析</span></el-menu-item>
<el-menu-item index="/team"><el-icon><UserFilled /></el-icon><span>团队协作</span></el-menu-item>
@@ -39,6 +39,10 @@
<el-breadcrumb-item v-if="route.meta?.title" :to="route.path">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
<div class="topbar-right">
<el-button v-if="creditBalance !== null" text class="credit-btn" @click="$router.push('/credits')">
<el-icon><Coin /></el-icon>
<span class="credit-text">{{ creditBalance }} </span>
</el-button>
<el-badge :value="unread" :hidden="!unread" class="notif-badge">
<el-button text style="font-size:18px" @click="$router.push('/notifications')">
<el-icon><Bell /></el-icon>
@@ -86,7 +90,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { getUnreadCount } from '@/api'
import { getUnreadCount, getCreditBalance } from '@/api'
import AiAssistant from '@/components/AiAssistant.vue'
const route = useRoute()
@@ -95,6 +99,22 @@ const auth = useAuthStore()
const collapsed = ref(false)
const showMobileMenu = ref(false)
const unread = ref(0)
const creditBalance = ref(null)
async function loadCreditBalance() {
try {
const res = await getCreditBalance()
creditBalance.value = res.balance
} catch (e) { creditBalance.value = null }
}
onMounted(async () => {
try {
const res = await getUnreadCount()
unread.value = res.count || res || 0
} catch { /* ignore */ }
loadCreditBalance()
})
const beianInfo = computed(() => {
const hostname = window.location.hostname
@@ -143,6 +163,8 @@ function handleLogout() {
.footer-links a:hover { color: #1890ff; }
.gongan-link { display: inline-flex; align-items: center; gap: 4px; }
.gongan-icon { height: 14px; vertical-align: middle; }
.credit-btn { display: flex; align-items: center; gap: 4px; color: #e6a23c !important; font-weight: 600; }
.credit-text { font-size: 13px; }
@media (max-width: 768px) {
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 1000; transition: left 0.3s; }
+3 -3
View File
@@ -56,7 +56,7 @@ const routes = [
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '挖掘新客' } },
{ path: '', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '发现客户' } },
]
},
{
@@ -100,11 +100,11 @@ const routes = [
]
},
{
path: '/upgrade',
path: '/credits',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Upgrade', component: () => import('@/views/Upgrade.vue'), meta: { title: '升级会员' } },
{ path: '', name: 'Credits', component: () => import('@/views/Credits.vue'), meta: { title: '购买次数' } },
]
},
{
+227
View File
@@ -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>
+2 -2
View File
@@ -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 -3
View File
@@ -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(() => []),