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

@@ -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<typeof setInterval>;
_healthCheckTimer?: ReturnType<typeof setInterval>;
// === Billing State ===
plans: BillingPlan[];
subscription: SubscriptionInfo | null;
billingLoading: boolean;
billingError: string | null;
}
export interface SaaSActionsSlice {
@@ -101,6 +111,13 @@ export interface SaaSActionsSlice {
unassignTemplate: () => Promise<void>;
clearError: () => void;
restoreSession: () => void;
// === Billing Actions ===
fetchPlans: () => Promise<void>;
fetchSubscription: () => Promise<void>;
fetchBillingOverview: () => Promise<void>;
createPayment: (planId: string, method: 'alipay' | 'wechat') => Promise<PaymentResult | null>;
pollPaymentStatus: (paymentId: string) => Promise<PaymentStatus | null>;
clearBillingError: () => void;
setupTotp: () => Promise<TotpSetupResponse>;
verifyTotp: (code: string) => Promise<void>;
disableTotp: (password: string) => Promise<void>;
@@ -157,6 +174,12 @@ export const useSaaSStore = create<SaaSStore>((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<SaaSStore>((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<SaaSStore>((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<SaaSStore>((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<SaaSStore>((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 });
},
};
});