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:
iven
2026-04-04 10:48:33 +08:00
parent be0a78a523
commit eac1d9449e
6 changed files with 670 additions and 3 deletions

View 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>
);
}

View File

@@ -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>