diff --git a/desktop/src/components/SaaS/PricingPage.tsx b/desktop/src/components/SaaS/PricingPage.tsx new file mode 100644 index 0000000..8ebf4ab --- /dev/null +++ b/desktop/src/components/SaaS/PricingPage.tsx @@ -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 = { + free: '#8c8c8c', + pro: '#863bff', + team: '#47bfff', +}; + +const PLAN_ICONS: Record = { + free: , + pro: , + team: , +}; + +const PLAN_FEATURES: Record = { + 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 ( +
+
+ {label} + {current.toLocaleString()} / {displayMax} +
+
+
+
+
+ ); +} + +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 ( +
+ {isCurrent && ( +
+ 当前计划 +
+ )} + +
+
+ {PLAN_ICONS[plan.name]} +
+

{plan.display_name}

+ {plan.description && ( +

{plan.description}

+ )} +
+ +
+ + ¥{priceYuan} + + /{plan.interval === 'month' ? '月' : '年'} +
+ +
    + {features.map((f) => ( +
  • + + {f} +
  • + ))} +
+ + +
+ ); +} + +// === 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 ( +
+
+ {/* Header */} +
+

+ 升级到 {plan.display_name} +

+ +
+ + {/* Body */} +
+ {payResult ? ( +
+ +

等待支付确认...

+

+ 支付窗口已打开,请在新窗口完成支付 +
+ 支付金额: ¥{(payResult.amount_cents / 100).toFixed(2)} +

+
+ ) : ( + <> +
+ + ¥{(plan.price_cents / 100).toFixed(0)} + + /月 +
+ +

选择支付方式

+ +
+ + + +
+ + )} +
+ + {/* Footer */} + {!payResult && ( +
+ +
+ )} +
+
+ ); +} + +// === 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 | 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 ( +
+

订阅与计费

+

管理你的订阅计划和用量配额

+ + {/* Error banner */} + {billingError && ( +
+ + {billingError} + +
+ )} + + {/* Current usage */} + {usage && ( +
+

当前用量

+
+ + + +
+ {subscription?.subscription && ( +

+ 订阅周期: {new Date(subscription.subscription.current_period_start).toLocaleDateString()} — {new Date(subscription.subscription.current_period_end).toLocaleDateString()} +

+ )} +
+ )} + + {/* Plan cards */} +

选择计划

+ + {billingLoading && !plans.length ? ( +
+ +
+ ) : ( +
+ {plans.map((plan) => ( + handleSelectPlan(plan)} + /> + ))} +
+ )} + + {/* Checkout modal */} + {selectedPlan && ( + + )} +
+ ); +} diff --git a/desktop/src/components/Settings/SettingsLayout.tsx b/desktop/src/components/Settings/SettingsLayout.tsx index 588a7e7..5adbe4f 100644 --- a/desktop/src/components/Settings/SettingsLayout.tsx +++ b/desktop/src/components/Settings/SettingsLayout.tsx @@ -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: }, { id: 'storage', label: '安全存储', icon: }, { id: 'saas', label: 'SaaS 平台', icon: }, + { id: 'billing', label: '订阅与计费', icon: }, { id: 'viking', label: '语义记忆', icon: }, { id: 'security', label: '安全状态', icon: }, { id: 'audit', label: '审计日志', icon: }, @@ -102,6 +106,7 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) { case 'privacy': return ; case 'storage': return ; case 'saas': return ; + case 'billing': return ; case 'security': return (
diff --git a/desktop/src/lib/saas-billing.ts b/desktop/src/lib/saas-billing.ts index d7ce713..2c9b805 100644 --- a/desktop/src/lib/saas-billing.ts +++ b/desktop/src/lib/saas-billing.ts @@ -1,10 +1,20 @@ /** * SaaS Billing Methods — Mixin * - * Installs billing-related methods (usage increment, quota check) onto - * SaaSClient.prototype. Uses the same mixin pattern as saas-telemetry.ts. + * Installs billing-related methods onto SaaSClient.prototype. + * Covers usage increment (fire-and-forget) and full billing CRUD + * (plans, subscription, usage, payments). */ +import type { + BillingPlan, + SubscriptionInfo, + UsageQuota, + CreatePaymentRequest, + PaymentResult, + PaymentStatus, +} from './saas-types'; + export interface UsageIncrementResult { dimension: string; incremented: number; @@ -18,9 +28,13 @@ export interface UsageIncrementResult { }; } +type RequestFn = (method: string, path: string, body?: unknown) => Promise; + export function installBillingMethods(ClientClass: { prototype: any }): void { const proto = ClientClass.prototype; + // --- Usage Increment --- + /** * Report a usage increment for a specific dimension. * @@ -52,9 +66,61 @@ export function installBillingMethods(ClientClass: { prototype: any }): void { count: number = 1, ): void { this.incrementUsageDimension(dimension, count).catch((err: unknown) => { - // Non-fatal: billing reporting failure must never block user operations const msg = err instanceof Error ? err.message : String(err); console.warn(`[Billing] Failed to report ${dimension} usage (+${count}): ${msg}`); }); }; + + // --- Plans --- + + /** List all active billing plans (public, no auth required) */ + proto.listPlans = async function ( + this: { request: RequestFn }, + ): Promise { + return this.request('GET', '/api/v1/billing/plans'); + }; + + /** Get a single plan by ID */ + proto.getPlan = async function ( + this: { request: RequestFn }, + planId: string, + ): Promise { + return this.request('GET', `/api/v1/billing/plans/${planId}`); + }; + + // --- Subscription --- + + /** Get current subscription info (plan + subscription + usage) */ + proto.getSubscription = async function ( + this: { request: RequestFn }, + ): Promise { + return this.request('GET', '/api/v1/billing/subscription'); + }; + + // --- Usage --- + + /** Get current month's usage quota */ + proto.getUsage = async function ( + this: { request: RequestFn }, + ): Promise { + return this.request('GET', '/api/v1/billing/usage'); + }; + + // --- Payments --- + + /** Create a payment order for a plan upgrade */ + proto.createPayment = async function ( + this: { request: RequestFn }, + data: CreatePaymentRequest, + ): Promise { + return this.request('POST', '/api/v1/billing/payments', data); + }; + + /** Check payment status by payment ID */ + proto.getPaymentStatus = async function ( + this: { request: RequestFn }, + paymentId: string, + ): Promise { + return this.request('GET', `/api/v1/billing/payments/${paymentId}`); + }; } diff --git a/desktop/src/lib/saas-client.ts b/desktop/src/lib/saas-client.ts index 0fd701c..8bb394d 100644 --- a/desktop/src/lib/saas-client.ts +++ b/desktop/src/lib/saas-client.ts @@ -115,6 +115,18 @@ import { installRelayMethods } from './saas-relay'; import { installPromptMethods } from './saas-prompt'; import { installTelemetryMethods } from './saas-telemetry'; import { installBillingMethods } from './saas-billing'; +export type { UsageIncrementResult } from './saas-billing'; + +// Re-export billing types for convenience +export type { + BillingPlan, + Subscription, + UsageQuota, + SubscriptionInfo, + CreatePaymentRequest, + PaymentResult, + PaymentStatus, +} from './saas-types'; // === Client Implementation === @@ -448,6 +460,7 @@ installRelayMethods(SaaSClient); installPromptMethods(SaaSClient); installTelemetryMethods(SaaSClient); installBillingMethods(SaaSClient); +export { installBillingMethods }; // === API Method Type Declarations === // These methods are installed at runtime by installXxxMethods() in saas-*.ts. @@ -506,6 +519,12 @@ export interface SaaSClient { // --- Billing (saas-billing.ts) --- incrementUsageDimension(dimension: string, count?: number): Promise; reportUsageFireAndForget(dimension: string, count?: number): void; + listPlans(): Promise; + getPlan(planId: string): Promise; + getSubscription(): Promise; + getUsage(): Promise; + createPayment(data: import('./saas-types').CreatePaymentRequest): Promise; + getPaymentStatus(paymentId: string): Promise; } // === Singleton === diff --git a/desktop/src/lib/saas-types.ts b/desktop/src/lib/saas-types.ts index 17a3005..1fc6d70 100644 --- a/desktop/src/lib/saas-types.ts +++ b/desktop/src/lib/saas-types.ts @@ -468,6 +468,91 @@ export interface AssignTemplateRequest { template_id: string; } +// === Billing Types === + +/** Subscription plan definition */ +export interface BillingPlan { + id: string; + name: string; + display_name: string; + description: string | null; + price_cents: number; + currency: string; + interval: string; + features: Record; + limits: Record; + is_default: boolean; + sort_order: number; + status: string; + created_at: string; + updated_at: string; +} + +/** User subscription record */ +export interface Subscription { + id: string; + account_id: string; + plan_id: string; + status: string; + current_period_start: string; + current_period_end: string; + trial_end: string | null; + canceled_at: string | null; + cancel_at_period_end: boolean; + created_at: string; + updated_at: string; +} + +/** Monthly usage quota tracking */ +export interface UsageQuota { + id: string; + account_id: string; + period_start: string; + period_end: string; + input_tokens: number; + output_tokens: number; + relay_requests: number; + hand_executions: number; + pipeline_runs: number; + max_input_tokens: number | null; + max_output_tokens: number | null; + max_relay_requests: number | null; + max_hand_executions: number | null; + max_pipeline_runs: number | null; + created_at: string; + updated_at: string; +} + +/** Combined subscription info (plan + subscription + usage) */ +export interface SubscriptionInfo { + plan: BillingPlan; + subscription: Subscription | null; + usage: UsageQuota; +} + +/** Payment creation request */ +export interface CreatePaymentRequest { + plan_id: string; + payment_method: 'alipay' | 'wechat'; +} + +/** Payment creation result */ +export interface PaymentResult { + payment_id: string; + trade_no: string; + pay_url: string; + amount_cents: number; +} + +/** Payment status query result */ +export interface PaymentStatus { + id: string; + method: string; + amount_cents: number; + currency: string; + status: string; +} + /** Agent configuration derived from a template. * capabilities are merged into tools (no separate field). */ export interface AgentConfigFromTemplate { diff --git a/desktop/src/store/saasStore.ts b/desktop/src/store/saasStore.ts index 3273ad2..ef8c5e4 100644 --- a/desktop/src/store/saasStore.ts +++ b/desktop/src/store/saasStore.ts @@ -28,6 +28,10 @@ import { type SyncConfigRequest, type AgentTemplateAvailable, type AgentTemplateFull, + type BillingPlan, + type SubscriptionInfo, + type PaymentResult, + type PaymentStatus, } from '../lib/saas-client'; import { createLogger } from '../lib/logger'; import { @@ -80,6 +84,12 @@ export interface SaaSStateSlice { _consecutiveFailures: number; _heartbeatTimer?: ReturnType; _healthCheckTimer?: ReturnType; + + // === Billing State === + plans: BillingPlan[]; + subscription: SubscriptionInfo | null; + billingLoading: boolean; + billingError: string | null; } export interface SaaSActionsSlice { @@ -101,6 +111,13 @@ export interface SaaSActionsSlice { unassignTemplate: () => Promise; clearError: () => void; restoreSession: () => void; + // === Billing Actions === + fetchPlans: () => Promise; + fetchSubscription: () => Promise; + fetchBillingOverview: () => Promise; + createPayment: (planId: string, method: 'alipay' | 'wechat') => Promise; + pollPaymentStatus: (paymentId: string) => Promise; + clearBillingError: () => void; setupTotp: () => Promise; verifyTotp: (code: string) => Promise; disableTotp: (password: string) => Promise; @@ -157,6 +174,12 @@ export const useSaaSStore = create((set, get) => { assignedTemplate: null, _consecutiveFailures: 0, + // === Billing State === + plans: [], + subscription: null, + billingLoading: false, + billingError: null, + // === Actions === login: async (saasUrl: string, username: string, password: string) => { @@ -214,6 +237,11 @@ export const useSaaSStore = create((set, get) => { log.warn('Failed to fetch assigned template after login:', err); }); + // Fetch billing data in background (non-blocking) + get().fetchBillingOverview().catch((err: unknown) => { + log.warn('Failed to fetch billing after login:', err); + }); + // Fetch available models in background (non-blocking) get().fetchAvailableModels().catch((err: unknown) => { log.warn('Failed to fetch models after login:', err); @@ -399,6 +427,10 @@ export const useSaaSStore = create((set, get) => { availableModels: [], availableTemplates: [], assignedTemplate: null, + plans: [], + subscription: null, + billingLoading: false, + billingError: null, error: null, totpRequired: false, totpSetupData: null, @@ -724,6 +756,7 @@ export const useSaaSStore = create((set, get) => { get().fetchAvailableModels().catch(() => {}); get().fetchAvailableTemplates().catch(() => {}); get().fetchAssignedTemplate().catch(() => {}); + get().fetchBillingOverview().catch(() => {}); get().syncConfigFromSaaS().then(() => { get().pushConfigToSaaS().catch(() => {}); }).catch(() => {}); @@ -780,5 +813,71 @@ export const useSaaSStore = create((set, get) => { cancelTotpSetup: () => { set({ totpSetupData: null }); }, + + // === Billing Actions === + + fetchPlans: async () => { + try { + const plans = await saasClient.listPlans(); + set({ plans }); + } catch (err: unknown) { + log.warn('Failed to fetch plans:', err); + } + }, + + fetchSubscription: async () => { + try { + const sub = await saasClient.getSubscription(); + set({ subscription: sub }); + } catch (err: unknown) { + log.warn('Failed to fetch subscription:', err); + set({ subscription: null }); + } + }, + + fetchBillingOverview: async () => { + set({ billingLoading: true, billingError: null }); + try { + const [plans, sub] = await Promise.all([ + saasClient.listPlans(), + saasClient.getSubscription(), + ]); + set({ plans, subscription: sub, billingLoading: false }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + set({ billingLoading: false, billingError: msg }); + } + }, + + createPayment: async (planId: string, method: 'alipay' | 'wechat') => { + set({ billingLoading: true, billingError: null }); + try { + const result = await saasClient.createPayment({ plan_id: planId, payment_method: method }); + set({ billingLoading: false }); + return result; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + set({ billingLoading: false, billingError: msg }); + return null; + } + }, + + pollPaymentStatus: async (paymentId: string) => { + try { + const status = await saasClient.getPaymentStatus(paymentId); + if (status.status === 'succeeded') { + // Refresh subscription after successful payment + await get().fetchSubscription(); + } + return status; + } catch (err: unknown) { + log.warn('Failed to poll payment status:', err); + return null; + } + }, + + clearBillingError: () => { + set({ billingError: null }); + }, }; });