327 lines
8.8 KiB
TypeScript
327 lines
8.8 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
||
import { App as AntApp } from 'antd';
|
||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||
import { MembershipAPI, MembershipInfo } from '../../api/membership';
|
||
|
||
export interface PricingTier {
|
||
id: string;
|
||
name: string;
|
||
badge?: string;
|
||
description: string;
|
||
billing: {
|
||
monthly: {
|
||
price: number;
|
||
originalPrice?: number;
|
||
text: string;
|
||
subtext?: string;
|
||
discountText?: string;
|
||
discountRate?: number;
|
||
};
|
||
yearly?: {
|
||
price: number;
|
||
originalPrice?: number;
|
||
text: string;
|
||
subtext?: string;
|
||
discountText?: string;
|
||
discountRate?: number;
|
||
};
|
||
};
|
||
features: Array<{
|
||
icon: string;
|
||
text: string;
|
||
}>;
|
||
details: string[];
|
||
limits: {
|
||
tier: string;
|
||
name: string;
|
||
maxSubAccounts: number;
|
||
maxTokens: number;
|
||
maxAgents: number;
|
||
maxKBSize: number;
|
||
};
|
||
type: 'personal' | 'enterprise';
|
||
}
|
||
|
||
const TIER_WEIGHTS: Record<string, number> = {
|
||
'trial': 1,
|
||
'pro': 2,
|
||
'ultra': 3,
|
||
'ent_basic': 10,
|
||
'ent_standard': 11,
|
||
'custom': 99
|
||
};
|
||
|
||
const CUSTOM_PLAN: PricingTier = {
|
||
id: 'custom',
|
||
name: '按需定制',
|
||
badge: '专享',
|
||
description: '适合大型团队 / 行业方案',
|
||
billing: {
|
||
monthly: {
|
||
price: 0,
|
||
text: '面议',
|
||
subtext: '专属优惠与支持',
|
||
discountRate: 1
|
||
}
|
||
},
|
||
features: [
|
||
{ icon: 'Zap', text: '独立部署 / 专属网络环境' },
|
||
{ icon: 'Check', text: '统一登录 / 操作记录可追溯' },
|
||
{ icon: 'Crown', text: '行业专属插件与技能' },
|
||
{ icon: 'Infinity', text: '服务保障与 7×24 专属支持' }
|
||
],
|
||
details: [
|
||
'企业积分池 面议',
|
||
'高质量模型配额 不限量',
|
||
'子账号上限 不限量'
|
||
],
|
||
limits: {
|
||
tier: 'custom',
|
||
name: '按需定制',
|
||
maxSubAccounts: -1,
|
||
maxTokens: -1,
|
||
maxAgents: -1,
|
||
maxKBSize: -1
|
||
},
|
||
type: 'enterprise'
|
||
};
|
||
|
||
export function usePricingLogic() {
|
||
const navigate = useNavigate();
|
||
const { tierId } = useParams();
|
||
const [searchParams] = useSearchParams();
|
||
const cycle = searchParams.get('cycle') || 'monthly';
|
||
|
||
const [activeTab, setActiveTab] = useState<'personal' | 'enterprise'>('personal');
|
||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>(cycle as any);
|
||
const [membership, setMembership] = useState<MembershipInfo | null>(null);
|
||
const [plans, setPlans] = useState<PricingTier[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [orderInfo, setOrderInfo] = useState<{
|
||
order_id: string;
|
||
pay_url: string;
|
||
pay_expire_at: string;
|
||
pay_is_expired: boolean;
|
||
} | null>(null);
|
||
const [payStatus, setPayStatus] = useState<'PENDING' | 'SUCCESS' | 'CLOSED' | 'FAIL' | 'EXPIRED'>('PENDING');
|
||
const [timeLeft, setTimeLeft] = useState(0);
|
||
const { message } = AntApp.useApp();
|
||
|
||
// 分类方案
|
||
const PERSONAL_TIERS = useMemo(() => plans.filter(p => p.type === 'personal'), [plans]);
|
||
const ENTERPRISE_TIERS = useMemo(() => plans.filter(p => p.type === 'enterprise'), [plans]);
|
||
|
||
// 根据 URL 参数计算当前的支付信息
|
||
const tierInfo = useMemo(() => {
|
||
if (!tierId || plans.length === 0) return null;
|
||
const tier = plans.find(t => t.id === tierId);
|
||
if (!tier) return null;
|
||
return {
|
||
tier,
|
||
duration: billingCycle === 'monthly' ? 30 : 365
|
||
};
|
||
}, [tierId, billingCycle, plans]);
|
||
|
||
// 综合支付信息
|
||
const paymentInfo = useMemo(() => {
|
||
if (!tierInfo || !orderInfo) return null;
|
||
return {
|
||
...tierInfo,
|
||
...orderInfo,
|
||
status: payStatus,
|
||
};
|
||
}, [tierInfo, orderInfo, payStatus]);
|
||
|
||
useEffect(() => {
|
||
loadMembership();
|
||
loadPlans();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (activeTab === 'enterprise') {
|
||
setBillingCycle('monthly');
|
||
}
|
||
}, [activeTab]);
|
||
|
||
const loadPlans = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await MembershipAPI.getPlans();
|
||
// res 结构: { categories: [ { id: 'personal', plans: [...] }, ... ] }
|
||
const categories = res.categories || [];
|
||
const allPlans: PricingTier[] = [];
|
||
|
||
categories.forEach((cat: any) => {
|
||
const catPlans = (cat.plans || []).map((p: any) => ({
|
||
...p,
|
||
type: cat.id // 注入 personal 或 enterprise
|
||
}));
|
||
allPlans.push(...catPlans);
|
||
});
|
||
|
||
// 追加静态的“按需定制”方案到企业版
|
||
if (!allPlans.find(p => p.id === 'custom')) {
|
||
allPlans.push(CUSTOM_PLAN);
|
||
}
|
||
|
||
setPlans(allPlans);
|
||
} catch (e) {
|
||
console.error('Failed to load plans', e);
|
||
message.error('加载会员方案失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 当进入支付路由时,发起订阅请求
|
||
useEffect(() => {
|
||
if (tierId && tierInfo && !orderInfo) {
|
||
createOrder();
|
||
}
|
||
}, [tierId, tierInfo]);
|
||
|
||
// 倒计时逻辑
|
||
useEffect(() => {
|
||
let timer: any;
|
||
if (orderInfo && timeLeft > 0 && payStatus === 'PENDING') {
|
||
timer = setInterval(() => {
|
||
setTimeLeft((prev) => {
|
||
if (prev <= 1) {
|
||
setPayStatus('EXPIRED');
|
||
return 0;
|
||
}
|
||
return prev - 1;
|
||
});
|
||
}, 1000);
|
||
}
|
||
return () => clearInterval(timer);
|
||
}, [orderInfo, timeLeft, payStatus]);
|
||
|
||
// 轮询支付状态
|
||
useEffect(() => {
|
||
let pollTimer: any;
|
||
if (orderInfo && payStatus === 'PENDING') {
|
||
pollTimer = setInterval(async () => {
|
||
try {
|
||
const res = await MembershipAPI.queryPayStatus(orderInfo.order_id);
|
||
if (res.status === 'SUCCESS') {
|
||
setPayStatus('SUCCESS');
|
||
message.success('支付成功!');
|
||
loadMembership(); // 刷新会员信息
|
||
clearInterval(pollTimer);
|
||
} else if (res.status === 'CLOSED' || res.status === 'FAIL') {
|
||
setPayStatus(res.status);
|
||
message.error(`支付失败: ${res.status}`);
|
||
clearInterval(pollTimer);
|
||
} else if (res.pay_is_expired) {
|
||
setPayStatus('EXPIRED');
|
||
message.warning('支付已过期,请重新下单');
|
||
clearInterval(pollTimer);
|
||
}
|
||
} catch (e) {
|
||
console.error('Polling payment status failed', e);
|
||
}
|
||
}, 2000);
|
||
}
|
||
return () => clearInterval(pollTimer);
|
||
}, [orderInfo, payStatus]);
|
||
|
||
const createOrder = async () => {
|
||
if (!tierId || !tierInfo) return;
|
||
setLoading(true);
|
||
try {
|
||
const res = await MembershipAPI.subscribe({
|
||
tier: tierId,
|
||
durationDays: tierInfo.duration,
|
||
});
|
||
setOrderInfo(res);
|
||
setPayStatus('PENDING');
|
||
|
||
// 计算剩余秒数
|
||
const expireTime = new Date(res.pay_expire_at).getTime();
|
||
const now = new Date().getTime();
|
||
const diff = Math.floor((expireTime - now) / 1000);
|
||
setTimeLeft(diff > 0 ? diff : 0);
|
||
|
||
if (res.pay_is_expired) {
|
||
setPayStatus('EXPIRED');
|
||
}
|
||
} catch (e: any) {
|
||
message.error(e.response?.data?.message || '创建订单失败,请稍后重试');
|
||
navigate('/pricing');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadMembership = async () => {
|
||
try {
|
||
const info = await MembershipAPI.getMe();
|
||
setMembership(info);
|
||
} catch (e) {
|
||
console.error('Failed to load membership', e);
|
||
}
|
||
};
|
||
|
||
const handleSubscribe = async (tier: PricingTier) => {
|
||
// 基础校验
|
||
if (tier.id === 'custom') {
|
||
message.info('请联系您的专属大客户经理或拨打客服热线进行面议');
|
||
return;
|
||
}
|
||
|
||
const price = billingCycle === 'yearly' && tier.billing.yearly
|
||
? tier.billing.yearly.price
|
||
: tier.billing.monthly.price;
|
||
|
||
if (tier.id === 'trial' && membership?.tier === 'trial') {
|
||
message.info('您当前已在试用期内');
|
||
return;
|
||
}
|
||
|
||
// 使用子路由跳转
|
||
navigate(`/pricing/pay/${tier.id}?cycle=${billingCycle}`);
|
||
};
|
||
|
||
const cancelPayment = async () => {
|
||
if (orderInfo) {
|
||
try {
|
||
await MembershipAPI.closePayOrder(orderInfo.order_id);
|
||
} catch (e) {
|
||
console.error('Failed to close order', e);
|
||
}
|
||
}
|
||
setOrderInfo(null);
|
||
setPayStatus('PENDING');
|
||
navigate('/pricing');
|
||
};
|
||
|
||
const getTierStatus = (targetTierId: string) => {
|
||
if (!membership) return 'none';
|
||
const currentWeight = TIER_WEIGHTS[membership.tier] || 0;
|
||
const targetWeight = TIER_WEIGHTS[targetTierId] || 0;
|
||
|
||
if (membership.tier === targetTierId) return 'current';
|
||
if (currentWeight > targetWeight) return 'included';
|
||
return 'none';
|
||
};
|
||
|
||
return {
|
||
activeTab,
|
||
setActiveTab,
|
||
billingCycle,
|
||
setBillingCycle,
|
||
membership,
|
||
loading,
|
||
handleSubscribe,
|
||
paymentInfo,
|
||
timeLeft,
|
||
cancelPayment,
|
||
PERSONAL_TIERS,
|
||
ENTERPRISE_TIERS,
|
||
getTierStatus,
|
||
};
|
||
}
|
||
|
||
export type PricingLogicOutput = ReturnType<typeof usePricingLogic>;
|