306 lines
13 KiB
Vue
306 lines
13 KiB
Vue
<template>
|
||
<div class="upgrade-page">
|
||
<div class="page-head">
|
||
<h1>选择适合你的套餐</h1>
|
||
<p class="page-sub">订阅后所有产品线通用 — 网页工作台、浏览器插件、Agent Skills</p>
|
||
<div class="billing-toggle">
|
||
<el-radio-group v-model="billingPeriod" size="small">
|
||
<el-radio-button value="monthly">月付</el-radio-button>
|
||
<el-radio-button value="yearly">年付 <span class="save-tag" v-if="billingPeriod === 'yearly'">省 2 个月</span></el-radio-button>
|
||
</el-radio-group>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="plans-grid" v-loading="loading">
|
||
<!-- Free Tier -->
|
||
<div class="plan-card" :class="{ current: currentPlan === 'free' }">
|
||
<div class="plan-name">Free</div>
|
||
<div class="plan-price free">免费</div>
|
||
<div class="plan-credits">30 积分(一次性)</div>
|
||
<ul class="plan-features">
|
||
<li>每日 1000 字免费翻译</li>
|
||
<li>基本功能体验</li>
|
||
<li>用完即止</li>
|
||
</ul>
|
||
<el-button type="default" disabled class="plan-btn" v-if="currentPlan === 'free'">当前套餐</el-button>
|
||
<el-button type="default" disabled class="plan-btn" v-else>当前套餐</el-button>
|
||
</div>
|
||
|
||
<!-- Dynamically loaded plan cards -->
|
||
<div
|
||
v-for="p in planCards"
|
||
:key="p.id"
|
||
class="plan-card"
|
||
:class="{
|
||
featured: p.featured,
|
||
current: p.isCurrent,
|
||
'yearly-active': billingPeriod === 'yearly'
|
||
}"
|
||
>
|
||
<div v-if="p.badge" class="plan-badge">{{ p.badge }}</div>
|
||
<div class="plan-name">{{ p.name }}</div>
|
||
<div class="plan-name-en">{{ p.name_en }}</div>
|
||
<div class="plan-price">
|
||
¥{{ billingPeriod === 'yearly' ? p.yearlyPrice : p.price }}
|
||
<small>/{{ billingPeriod === 'yearly' ? '年' : '月' }}</small>
|
||
</div>
|
||
<div v-if="billingPeriod === 'yearly' && p.yearlyOriginal" class="plan-original">
|
||
<del>¥{{ p.yearlyOriginal }}/年</del>
|
||
<span class="plan-discount">{{ p.discountPct }}% 优惠</span>
|
||
</div>
|
||
<div class="plan-credits">{{ p.credits }} <small>积分/月</small></div>
|
||
<ul class="plan-features">
|
||
<li v-for="f in p.features" :key="f">{{ f }}</li>
|
||
</ul>
|
||
<el-button
|
||
v-if="p.isCurrent"
|
||
type="default"
|
||
disabled
|
||
class="plan-btn"
|
||
>当前套餐</el-button>
|
||
<el-button
|
||
v-else
|
||
type="primary"
|
||
class="plan-btn"
|
||
:loading="payingId === p.id"
|
||
@click="handleUpgrade(p)"
|
||
>升级到 {{ p.name }}</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Feature Comparison Table -->
|
||
<el-card class="comparison-card" v-if="planCards.length">
|
||
<template #header><strong>完整功能对比</strong></template>
|
||
<el-table :data="comparisonRows" border stripe>
|
||
<el-table-column prop="feature" label="功能" width="160" />
|
||
<el-table-column prop="free" label="Free" width="120" align="center" />
|
||
<el-table-column v-for="p in planCards" :key="p.id" :prop="p.id" :label="p.name" width="130" align="center" />
|
||
</el-table>
|
||
</el-card>
|
||
|
||
<!-- Package section -->
|
||
<el-card class="package-card">
|
||
<template #header><strong>积分包(无需订阅,按需购买)</strong></template>
|
||
<div class="package-grid">
|
||
<div v-for="pkg in packages" :key="pkg.id" class="package-item">
|
||
<div class="pkg-name">{{ pkg.name }}</div>
|
||
<div class="pkg-credits">{{ pkg.credits }} <small>积分</small></div>
|
||
<div class="pkg-price">¥{{ pkg.price }}</div>
|
||
<div class="pkg-unit">≈ ¥{{ (pkg.price / pkg.credits).toFixed(2) }}/积分</div>
|
||
<el-button size="small" type="primary" @click="buyPackage(pkg)">购买</el-button>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- Purchase Dialog -->
|
||
<el-dialog v-model="payDialog.visible" title="选择支付方式" width="360px">
|
||
<p style="margin-bottom:12px;text-align:center" v-if="payDialog.type === 'subscription'">
|
||
订阅 <strong>{{ payDialog.plan?.name }}</strong>
|
||
({{ payDialog.plan?.credits }} 积分/月)
|
||
</p>
|
||
<p style="margin-bottom:12px;text-align:center" v-else>
|
||
购买 <strong>{{ payDialog.pkg?.name }}</strong> ({{ payDialog.pkg?.credits }} 积分)
|
||
</p>
|
||
<p style="font-size:22px;font-weight:bold;color:#e6a23c;text-align:center;margin-bottom:16px">
|
||
¥{{ payDialog.type === 'subscription' ? payDialog.plan?.price : payDialog.pkg?.price }}
|
||
</p>
|
||
<el-radio-group v-model="payDialog.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="payDialog.visible = false">取消</el-button>
|
||
<el-button type="primary" @click="confirmPay" :loading="payDialog.loading">确认支付</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
getSubscriptionPlans, getCreditPackages, getCreditBalance,
|
||
subscribeCreditPlan, purchaseCreditPackage,
|
||
} from '@/api'
|
||
|
||
const billingPeriod = ref('monthly')
|
||
const loading = ref(false)
|
||
const plans = ref([])
|
||
const packages = ref([])
|
||
const currentPlan = ref('free')
|
||
const payingId = ref(null)
|
||
|
||
const payDialog = ref({
|
||
visible: false,
|
||
type: 'subscription', // 'subscription' | 'package'
|
||
plan: null,
|
||
pkg: null,
|
||
payType: 'alipay',
|
||
loading: false,
|
||
})
|
||
|
||
const PLAN_META = {
|
||
starter: { badge: '入门', yearlyDiscount: 0.17, features: ['200 积分/月', '无每日限制', '翻译 + 客户发现 + 营销生成', '智能回复 + 报价单'] },
|
||
pro: { badge: '推荐', featured: true, yearlyDiscount: 0.15, features: ['1000 积分/月', 'AI 数字员工', '团队协作(3 人)', '优先技术支持'] },
|
||
enterprise: { badge: '旗舰', yearlyDiscount: 0.16, features: ['2500 积分/月', '不限团队人数', 'API 调用权限', 'SLA 保障'] },
|
||
}
|
||
|
||
const planCards = computed(() => {
|
||
return plans.value.map(p => {
|
||
const meta = PLAN_META[p.id] || {}
|
||
const yearlyOriginal = Math.round(p.price * 12)
|
||
const yearlyPrice = Math.round(p.price * 12 * (1 - (meta.yearlyDiscount || 0)))
|
||
return {
|
||
...p,
|
||
credits: p.credits_per_month || p.credits || 0,
|
||
badge: meta.badge || '',
|
||
featured: meta.featured || false,
|
||
isCurrent: p.id === currentPlan.value,
|
||
yearlyPrice,
|
||
yearlyOriginal,
|
||
discountPct: Math.round((meta.yearlyDiscount || 0) * 100),
|
||
features: meta.features || [],
|
||
}
|
||
}).filter(p => p.price > 0) // exclude free
|
||
})
|
||
|
||
const comparisonRows = computed(() => {
|
||
const features = [
|
||
{ feature: '积分/月', free: '30(一次性)', ...Object.fromEntries(planCards.value.map(p => [p.id, p.credits])) },
|
||
{ feature: 'AI 翻译', free: '1000字/天', ...Object.fromEntries(planCards.value.map(p => [p.id, '✓'])) },
|
||
{ feature: '智能回复', free: '—', ...Object.fromEntries(planCards.value.map(p => [p.id, '✓'])) },
|
||
{ feature: '客户发现', free: '—', ...Object.fromEntries(planCards.value.map(p => [p.id, '✓'])) },
|
||
{ feature: '营销生成', free: '—', ...Object.fromEntries(planCards.value.map(p => [p.id, '✓'])) },
|
||
{ feature: '报价单', free: '—', ...Object.fromEntries(planCards.value.map(p => [p.id, '✓'])) },
|
||
{ feature: 'AI 数字员工', free: '—', ...Object.fromEntries(planCards.value.map(p => [p.id, p.credits >= 1000 ? '✓' : '—'])) },
|
||
{ feature: '团队协作', free: '—', ...Object.fromEntries(planCards.value.map(p => [p.id, p.credits >= 1000 ? '3 人' : p.credits >= 500 ? '3 人' : '—'])) },
|
||
{ feature: 'API 调用', free: '—', ...Object.fromEntries(planCards.value.map(p => [p.id, p.credits >= 2000 ? '✓' : '—'])) },
|
||
{ feature: '技术支持', free: '—', ...Object.fromEntries(planCards.value.map(p => [p.id, p.credits >= 1000 ? '优先' : '—'])) },
|
||
]
|
||
return features
|
||
})
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const [plansRes, pkgsRes, balanceRes] = await Promise.all([
|
||
getSubscriptionPlans().catch(() => []),
|
||
getCreditPackages().catch(() => []),
|
||
getCreditBalance().catch(() => null),
|
||
])
|
||
plans.value = Array.isArray(plansRes) ? plansRes : (plansRes.data || plansRes.items || [])
|
||
packages.value = Array.isArray(pkgsRes) ? pkgsRes : (pkgsRes.data || pkgsRes.items || [])
|
||
if (balanceRes?.subscription?.plan_id) {
|
||
currentPlan.value = balanceRes.subscription.plan_id
|
||
}
|
||
} catch { /* ignore */ }
|
||
loading.value = false
|
||
}
|
||
|
||
async function handleUpgrade(plan) {
|
||
if (plan.isCurrent) return
|
||
payDialog.value = {
|
||
visible: true,
|
||
type: 'subscription',
|
||
plan,
|
||
pkg: null,
|
||
payType: 'alipay',
|
||
loading: false,
|
||
}
|
||
}
|
||
|
||
async function confirmPay() {
|
||
const d = payDialog.value
|
||
d.loading = true
|
||
try {
|
||
if (d.type === 'subscription') {
|
||
const res = await subscribeCreditPlan(d.plan.id, d.payType)
|
||
if (res.pay_url) window.open(res.pay_url, '_blank')
|
||
else ElMessage.success('订阅成功!')
|
||
} else {
|
||
const res = await purchaseCreditPackage(d.pkg.id, d.payType)
|
||
if (res.code_url || res.pay_url) {
|
||
if (res.pay_url) window.open(res.pay_url, '_blank')
|
||
// QR code handling
|
||
if (res.code_url) {
|
||
ElMessage.info('请在新页面扫码支付')
|
||
}
|
||
} else {
|
||
ElMessage.success('购买成功!')
|
||
}
|
||
}
|
||
d.visible = false
|
||
} catch (e) {
|
||
ElMessage.error(e?.detail || e?.message || '支付失败')
|
||
}
|
||
d.loading = false
|
||
}
|
||
|
||
function buyPackage(pkg) {
|
||
payDialog.value = {
|
||
visible: true,
|
||
type: 'package',
|
||
plan: null,
|
||
pkg,
|
||
payType: 'alipay',
|
||
loading: false,
|
||
}
|
||
}
|
||
|
||
onMounted(loadData)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.upgrade-page { max-width: 960px; margin: 0 auto; }
|
||
.page-head { text-align: center; margin-bottom: 32px; }
|
||
.page-head h1 { font-size: 28px; color: #1e293b; margin: 0 0 8px; }
|
||
.page-sub { font-size: 14px; color: #64748b; margin: 0 0 20px; }
|
||
.billing-toggle { display: inline-flex; align-items: center; gap: 8px; }
|
||
.save-tag { background: #52c41a; color: #fff; font-size: 10px; padding: 1px 6px; border-radius: 8px; margin-left: 4px; }
|
||
.plans-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
||
.plan-card {
|
||
background: #fff; border: 1px solid #e5e7eb; border-radius: 16px;
|
||
padding: 24px 20px; text-align: center; position: relative;
|
||
transition: all 0.25s;
|
||
}
|
||
.plan-card:hover { border-color: #2563eb; box-shadow: 0 4px 16px rgba(37,99,235,0.1); transform: translateY(-2px); }
|
||
.plan-card.featured { border-color: #2563eb; border-width: 2px; background: #f8faff; }
|
||
.plan-card.current { border-color: #52c41a; background: #f6ffed; }
|
||
.plan-card.yearly-active.featured { border-color: #2563eb; box-shadow: 0 4px 20px rgba(37,99,235,0.15); }
|
||
.plan-badge {
|
||
position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
|
||
background: #2563eb; color: #fff; font-size: 11px; padding: 2px 14px;
|
||
border-radius: 10px; font-weight: 600;
|
||
}
|
||
.plan-card.current .plan-badge { background: #52c41a; }
|
||
.plan-name { font-size: 16px; font-weight: 700; color: #1e293b; margin-bottom: 2px; }
|
||
.plan-name-en { font-size: 12px; color: #94a3b8; margin-bottom: 8px; }
|
||
.plan-price { font-size: 28px; font-weight: 800; color: #2563eb; margin: 8px 0 2px; }
|
||
.plan-price.free { color: #64748b; font-size: 20px; }
|
||
.plan-price small { font-size: 14px; font-weight: 400; color: #64748b; }
|
||
.plan-original { font-size: 12px; color: #94a3b8; margin-bottom: 4px; }
|
||
.plan-discount { color: #52c41a; font-weight: 600; margin-left: 6px; }
|
||
.plan-credits { font-size: 13px; color: #64748b; margin-bottom: 12px; }
|
||
.plan-features { list-style: none; padding: 0; margin: 0 0 16px; }
|
||
.plan-features li { font-size: 13px; color: #475569; line-height: 2; }
|
||
.plan-features li::before { content: '✓ '; color: #52c41a; font-weight: 700; }
|
||
.plan-btn { width: 100%; }
|
||
|
||
.comparison-card { margin-bottom: 24px; }
|
||
.comparison-card :deep(td) { font-size: 13px; }
|
||
|
||
.package-card { margin-bottom: 24px; }
|
||
.package-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
|
||
.package-item {
|
||
border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; text-align: center;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.package-item:hover { border-color: #2563eb; }
|
||
.pkg-name { font-size: 15px; font-weight: 600; color: #1e293b; }
|
||
.pkg-credits { font-size: 20px; font-weight: 700; color: #2563eb; margin: 6px 0; }
|
||
.pkg-credits small { font-size: 12px; font-weight: 400; }
|
||
.pkg-price { font-size: 18px; font-weight: 700; color: #e6a23c; margin-bottom: 2px; }
|
||
.pkg-unit { font-size: 11px; color: #94a3b8; margin-bottom: 10px; }
|
||
</style>
|