feat(desktop): add billing frontend — plans, subscription, payment flow
Sprint 1: Desktop 计费闭环 - Add 7 billing types to saas-types.ts (BillingPlan, Subscription, UsageQuota, etc.) - Add 6 billing API methods to saas-billing.ts (listPlans, getSubscription, createPayment, etc.) - Extend saas-client.ts with interface merging for billing methods - Extend saasStore with billing state/actions (plans, subscription, payment polling) - Create PricingPage component with plan cards, usage bars, and checkout modal - Add billing page entry in SettingsLayout (CreditCard icon + route) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
393
desktop/src/components/SaaS/PricingPage.tsx
Normal file
393
desktop/src/components/SaaS/PricingPage.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* PricingPage — 订阅与计费页面
|
||||
*
|
||||
* 展示三层定价计划(Free / Pro / Team),
|
||||
* 当前订阅状态和用量配额,
|
||||
* 支付入口。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSaaSStore } from '../../store/saasStore';
|
||||
import {
|
||||
CheckCircle,
|
||||
Zap,
|
||||
Users,
|
||||
Rocket,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
// === Constants ===
|
||||
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
free: '#8c8c8c',
|
||||
pro: '#863bff',
|
||||
team: '#47bfff',
|
||||
};
|
||||
|
||||
const PLAN_ICONS: Record<string, React.ReactNode> = {
|
||||
free: <Rocket className="w-6 h-6" />,
|
||||
pro: <Zap className="w-6 h-6" />,
|
||||
team: <Users className="w-6 h-6" />,
|
||||
};
|
||||
|
||||
const PLAN_FEATURES: Record<string, string[]> = {
|
||||
free: ['本地模型对话', '2 个 Hand (Quiz/Speech)', '3 个 Pipeline 模板', '基础记忆'],
|
||||
pro: ['SaaS Relay 代理', '全部 9 个 Hands', '无限 Pipeline', '完整记忆系统', '优先模型', '14 天免费试用'],
|
||||
team: ['Pro 全部功能', '多人管理', 'Admin 面板', 'API Key 统管', '优先支持', '团队共享模板'],
|
||||
};
|
||||
|
||||
// === Sub-components ===
|
||||
|
||||
function UsageBar({ label, current, max }: { label: string; current: number; max: number | null }) {
|
||||
const pct = max ? Math.min((current / max) * 100, 100) : 0;
|
||||
const displayMax = max ? max.toLocaleString() : '∞';
|
||||
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>{label}</span>
|
||||
<span>{current.toLocaleString()} / {displayMax}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${Math.max(pct, 1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
plan,
|
||||
isCurrent,
|
||||
priceYuan,
|
||||
onSelect,
|
||||
}: {
|
||||
plan: { id: string; name: string; display_name: string; description: string | null; price_cents: number; interval: string };
|
||||
isCurrent: boolean;
|
||||
priceYuan: string;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const color = PLAN_COLORS[plan.name] || '#666';
|
||||
const features = PLAN_FEATURES[plan.name] || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col rounded-xl border-2 p-6 transition-all duration-200 hover:shadow-lg ${
|
||||
isCurrent ? 'shadow-md' : 'border-gray-200'
|
||||
}`}
|
||||
style={isCurrent ? { borderColor: color } : {}}
|
||||
>
|
||||
{isCurrent && (
|
||||
<div
|
||||
className="absolute -top-0 right-4 px-3 py-1 text-xs font-medium text-white rounded-b-lg"
|
||||
style={{ background: color }}
|
||||
>
|
||||
当前计划
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-5">
|
||||
<div style={{ color }} className="mb-2 flex justify-center">
|
||||
{PLAN_ICONS[plan.name]}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{plan.display_name}</h3>
|
||||
{plan.description && (
|
||||
<p className="text-xs text-gray-500 mt-1">{plan.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-5">
|
||||
<span className="text-3xl font-bold" style={{ color }}>
|
||||
¥{priceYuan}
|
||||
</span>
|
||||
<span className="text-gray-500 text-sm"> /{plan.interval === 'month' ? '月' : '年'}</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2.5 text-sm flex-1 mb-6">
|
||||
{features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-gray-700">{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={onSelect}
|
||||
disabled={isCurrent}
|
||||
className={`w-full py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isCurrent
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'text-white hover:opacity-90'
|
||||
}`}
|
||||
style={!isCurrent ? { background: color } : {}}
|
||||
>
|
||||
{isCurrent ? '当前计划' : '升级'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Checkout Modal ===
|
||||
|
||||
function CheckoutModal({
|
||||
plan,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
payResult,
|
||||
}: {
|
||||
plan: { id: string; display_name: string; name: string; price_cents: number };
|
||||
onClose: () => void;
|
||||
onConfirm: (method: 'alipay' | 'wechat') => void;
|
||||
isLoading: boolean;
|
||||
payResult: { pay_url: string; amount_cents: number } | null;
|
||||
}) {
|
||||
const [method, setMethod] = useState<'alipay' | 'wechat'>('alipay');
|
||||
const color = PLAN_COLORS[plan.name] || '#666';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
升级到 {plan.display_name}
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5">
|
||||
{payResult ? (
|
||||
<div className="text-center py-6">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4" style={{ color }} />
|
||||
<p className="font-medium text-gray-900 mb-1">等待支付确认...</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
支付窗口已打开,请在新窗口完成支付
|
||||
<br />
|
||||
支付金额: ¥{(payResult.amount_cents / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center mb-6">
|
||||
<span className="text-2xl font-bold" style={{ color }}>
|
||||
¥{(plan.price_cents / 100).toFixed(0)}
|
||||
</span>
|
||||
<span className="text-gray-500 text-sm"> /月</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-gray-900 text-center mb-3">选择支付方式</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setMethod('alipay')}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-colors ${
|
||||
method === 'alipay' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-bold">支</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-sm">支付宝</div>
|
||||
<div className="text-xs text-gray-400">推荐个人用户</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setMethod('wechat')}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-colors ${
|
||||
method === 'wechat' ? 'border-green-500 bg-green-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-green-600 flex items-center justify-center text-white text-xs font-bold">微</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-sm">微信支付</div>
|
||||
<div className="text-xs text-gray-400">扫码支付</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!payResult && (
|
||||
<div className="p-5 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => onConfirm(method)}
|
||||
disabled={isLoading}
|
||||
className="w-full py-2.5 rounded-lg text-white text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
style={{ background: color }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
处理中...
|
||||
</span>
|
||||
) : (
|
||||
'确认支付'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Page ===
|
||||
|
||||
export function PricingPage() {
|
||||
const plans = useSaaSStore((s) => s.plans);
|
||||
const subscription = useSaaSStore((s) => s.subscription);
|
||||
const billingLoading = useSaaSStore((s) => s.billingLoading);
|
||||
const billingError = useSaaSStore((s) => s.billingError);
|
||||
const fetchBillingOverview = useSaaSStore((s) => s.fetchBillingOverview);
|
||||
const createPaymentAction = useSaaSStore((s) => s.createPayment);
|
||||
const pollPaymentStatus = useSaaSStore((s) => s.pollPaymentStatus);
|
||||
const clearBillingError = useSaaSStore((s) => s.clearBillingError);
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<{
|
||||
id: string; name: string; display_name: string; price_cents: number;
|
||||
} | null>(null);
|
||||
const [payResult, setPayResult] = useState<{
|
||||
payment_id: string; pay_url: string; amount_cents: number;
|
||||
} | null>(null);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Load billing data on mount
|
||||
useEffect(() => {
|
||||
fetchBillingOverview().catch(() => {});
|
||||
}, [fetchBillingOverview]);
|
||||
|
||||
// Cleanup polling on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentPlanName = subscription?.plan?.name || 'free';
|
||||
const usage = subscription?.usage;
|
||||
|
||||
const handleSelectPlan = useCallback((plan: typeof plans[number]) => {
|
||||
if (plan.price_cents === 0) return;
|
||||
setSelectedPlan(plan);
|
||||
setPayResult(null);
|
||||
}, []);
|
||||
|
||||
const handleConfirmPay = useCallback(async (method: 'alipay' | 'wechat') => {
|
||||
if (!selectedPlan) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const result = await createPaymentAction(selectedPlan.id, method);
|
||||
if (result) {
|
||||
setPayResult(result);
|
||||
window.open(result.pay_url, '_blank', 'width=480,height=640');
|
||||
// Start polling
|
||||
pollRef.current = setInterval(async () => {
|
||||
const status = await pollPaymentStatus(result.payment_id);
|
||||
if (status?.status === 'succeeded') {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
setPayResult(null);
|
||||
setSelectedPlan(null);
|
||||
fetchBillingOverview().catch(() => {});
|
||||
} else if (status?.status === 'failed') {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
clearBillingError();
|
||||
setPayResult(null);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
} catch {
|
||||
// Error already in store
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}, [selectedPlan, createPaymentAction, pollPaymentStatus, fetchBillingOverview, clearBillingError]);
|
||||
|
||||
const handleCloseCheckout = useCallback(() => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
setSelectedPlan(null);
|
||||
setPayResult(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">订阅与计费</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">管理你的订阅计划和用量配额</p>
|
||||
|
||||
{/* Error banner */}
|
||||
{billingError && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 flex items-center gap-2 text-sm text-red-700">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{billingError}</span>
|
||||
<button onClick={clearBillingError} className="ml-auto p-1 hover:bg-red-100 rounded">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current usage */}
|
||||
{usage && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-4">当前用量</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<UsageBar label="中转请求" current={usage.relay_requests} max={usage.max_relay_requests} />
|
||||
<UsageBar label="Hand 执行" current={usage.hand_executions} max={usage.max_hand_executions} />
|
||||
<UsageBar label="Pipeline 运行" current={usage.pipeline_runs} max={usage.max_pipeline_runs} />
|
||||
</div>
|
||||
{subscription?.subscription && (
|
||||
<p className="mt-3 text-xs text-gray-400">
|
||||
订阅周期: {new Date(subscription.subscription.current_period_start).toLocaleDateString()} — {new Date(subscription.subscription.current_period_end).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan cards */}
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-4">选择计划</h2>
|
||||
|
||||
{billingLoading && !plans.length ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-emerald-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrent={plan.name === currentPlanName}
|
||||
priceYuan={plan.price_cents === 0 ? '0' : (plan.price_cents / 100).toFixed(0)}
|
||||
onSelect={() => handleSelectPlan(plan)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checkout modal */}
|
||||
{selectedPlan && (
|
||||
<CheckoutModal
|
||||
plan={selectedPlan}
|
||||
onClose={handleCloseCheckout}
|
||||
onConfirm={handleConfirmPay}
|
||||
isLoading={paying}
|
||||
payResult={payResult}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Key,
|
||||
Database,
|
||||
Cloud,
|
||||
CreditCard,
|
||||
} from 'lucide-react';
|
||||
import { silentErrorHandler } from '../../lib/error-utils';
|
||||
import { General } from './General';
|
||||
@@ -39,6 +40,7 @@ import { HeartbeatConfig } from '../HeartbeatConfig';
|
||||
import { SecureStorage } from './SecureStorage';
|
||||
import { VikingPanel } from '../VikingPanel';
|
||||
import { SaaSSettings } from '../SaaS/SaaSSettings';
|
||||
import { PricingPage } from '../SaaS/PricingPage';
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
onBack: () => void;
|
||||
@@ -57,6 +59,7 @@ type SettingsPage =
|
||||
| 'security'
|
||||
| 'storage'
|
||||
| 'saas'
|
||||
| 'billing'
|
||||
| 'viking'
|
||||
| 'audit'
|
||||
| 'tasks'
|
||||
@@ -76,6 +79,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode }[] =
|
||||
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
|
||||
{ id: 'storage', label: '安全存储', icon: <Key className="w-4 h-4" /> },
|
||||
{ id: 'saas', label: 'SaaS 平台', icon: <Cloud className="w-4 h-4" /> },
|
||||
{ id: 'billing', label: '订阅与计费', icon: <CreditCard className="w-4 h-4" /> },
|
||||
{ id: 'viking', label: '语义记忆', icon: <Database className="w-4 h-4" /> },
|
||||
{ id: 'security', label: '安全状态', icon: <Shield className="w-4 h-4" /> },
|
||||
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" /> },
|
||||
@@ -102,6 +106,7 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
||||
case 'privacy': return <Privacy />;
|
||||
case 'storage': return <SecureStorage />;
|
||||
case 'saas': return <SaaSSettings />;
|
||||
case 'billing': return <PricingPage />;
|
||||
case 'security': return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user