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:
@@ -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 });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user