Files
trade-assistant/user-frontend/src/views/Upgrade.vue
T

306 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>