feat: add pricing pay page

main
yannyang 2026-07-30 17:16:23 +08:00
parent aabc979fec
commit 7faf2cddcf
5 changed files with 330 additions and 18 deletions

View File

@ -64,6 +64,7 @@ export default function App() {
<Route path="/stats" element={<StatsPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/pricing/pay/:tierId" element={<PricingPage />} />
<Route path="/workflows" element={<WorkflowsPage />} />
<Route path="/knowledge" element={<KnowledgeBasePage />} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
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 {
@ -113,16 +114,47 @@ export const ENTERPRISE_TIERS: PricingTier[] = [
];
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'>('monthly');
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>(cycle as any);
const [membership, setMembership] = useState<MembershipInfo | null>(null);
const [loading, setLoading] = useState(false);
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds
const { message } = AntApp.useApp();
// 根据 URL 参数计算当前的支付信息
const paymentInfo = useMemo(() => {
if (!tierId) return null;
const allTiers = [...PERSONAL_TIERS, ...ENTERPRISE_TIERS];
const tier = allTiers.find(t => t.id === tierId);
if (!tier) return null;
return {
tier,
duration: cycle === 'monthly' ? 30 : 365
};
}, [tierId, cycle]);
useEffect(() => {
loadMembership();
}, []);
useEffect(() => {
let timer: any;
if (paymentInfo && timeLeft > 0) {
timer = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
} else if (timeLeft === 0) {
navigate('/pricing', { replace: true });
message.error('支付超时,请重新发起');
}
return () => clearInterval(timer);
}, [paymentInfo, timeLeft, navigate]);
const loadMembership = async () => {
try {
const info = await MembershipAPI.getMe();
@ -135,18 +167,13 @@ export function usePricingLogic() {
const handleSubscribe = async (tier: PricingTier) => {
if (tier.price === '面议' || tier.id === 'trial') return;
setLoading(true);
try {
const durationDays = billingCycle === 'monthly' ? 30 : 365;
const { payUrl } = await MembershipAPI.subscribe({ tier: tier.id, durationDays });
if (payUrl) {
window.location.href = payUrl;
}
} catch (e: any) {
message.error(e?.response?.data?.error ?? e?.message ?? '发起订阅失败');
} finally {
setLoading(false);
}
// 使用子路由跳转
navigate(`/pricing/pay/${tier.id}?cycle=${billingCycle}`);
setTimeLeft(600);
};
const cancelPayment = () => {
navigate('/pricing');
};
return {
@ -157,6 +184,9 @@ export function usePricingLogic() {
membership,
loading,
handleSubscribe,
paymentInfo,
timeLeft,
cancelPayment,
PERSONAL_TIERS,
ENTERPRISE_TIERS,
};

View File

@ -1,5 +1,5 @@
import { CheckOutlined } from '@ant-design/icons';
import { Button, Spin } from 'antd';
import { CheckOutlined, ArrowLeftOutlined, LoadingOutlined } from '@ant-design/icons';
import { Button, Spin, QRCode, Divider } from 'antd';
import type { PricingLogicOutput } from '../PricingLogic';
import '../styles/pricing.css';
@ -19,10 +19,80 @@ export default function PricingH5({ logic }: Props) {
setBillingCycle,
loading,
handleSubscribe,
paymentInfo,
timeLeft,
cancelPayment,
PERSONAL_TIERS,
ENTERPRISE_TIERS,
} = logic;
if (paymentInfo) {
const { tier, duration } = paymentInfo;
const minutes = Math.floor(timeLeft / 60);
const seconds = timeLeft % 60;
const price = duration === 30 ? tier.price : (tier.yearlyPrice || tier.price);
return (
<div className="pricing-page pricing-page-h5">
<div className="payment-container">
<div className="payment-header">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={cancelPayment}
className="back-btn"
>
</Button>
</div>
<div className="payment-content">
<div className="payment-info-card">
<h2 className="payment-title"></h2>
<div className="payment-detail-item">
<span className="label"></span>
<span className="value">{tier.name}</span>
</div>
<div className="payment-detail-item">
<span className="label"></span>
<span className="value">{duration === 30 ? '30天' : '365天'}</span>
</div>
<Divider style={{ margin: '16px 0' }} />
<div className="payment-total">
<span className="label"></span>
<span className="total-amount">
<span className="symbol">¥</span>
{price}
</span>
</div>
</div>
<div className="payment-qr-card">
<div className="qr-wrapper">
<QRCode value="https://aura-ai.com/mock-pay" size={180} />
<div className="qr-status">
<LoadingOutlined /> ...
</div>
</div>
<div className="payment-countdown">
<span className="time">
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</span>
</div>
<div className="payment-tips">
<p></p>
<p></p>
</div>
</div>
</div>
</div>
</div>
);
}
const tiers = activeTab === 'personal' ? PERSONAL_TIERS : ENTERPRISE_TIERS;
return (

View File

@ -1,5 +1,5 @@
import { CheckOutlined } from '@ant-design/icons';
import { Button, Spin } from 'antd';
import { CheckOutlined, ArrowLeftOutlined, LoadingOutlined } from '@ant-design/icons';
import { Button, Spin, QRCode, Space, Divider } from 'antd';
import type { PricingLogicOutput, PricingTier } from '../PricingLogic';
import '../styles/pricing.css';
@ -19,10 +19,80 @@ export default function PricingWeb({ logic }: Props) {
setBillingCycle,
loading,
handleSubscribe,
paymentInfo,
timeLeft,
cancelPayment,
PERSONAL_TIERS,
ENTERPRISE_TIERS,
} = logic;
if (paymentInfo) {
const { tier, duration } = paymentInfo;
const minutes = Math.floor(timeLeft / 60);
const seconds = timeLeft % 60;
const price = duration === 30 ? tier.price : (tier.yearlyPrice || tier.price);
return (
<div className="pricing-page">
<div className="payment-container">
<div className="payment-header">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={cancelPayment}
className="back-btn"
>
</Button>
</div>
<div className="payment-content">
<div className="payment-info-card">
<h2 className="payment-title"></h2>
<div className="payment-detail-item">
<span className="label"></span>
<span className="value">{tier.name}</span>
</div>
<div className="payment-detail-item">
<span className="label"></span>
<span className="value">{duration === 30 ? '30 天' : '365 天'}</span>
</div>
<Divider />
<div className="payment-total">
<span className="label"></span>
<span className="total-amount">
<span className="symbol">¥</span>
{price}
</span>
</div>
</div>
<div className="payment-qr-card">
<div className="qr-wrapper">
<QRCode value="https://aura-ai.com/mock-pay" size={200} />
<div className="qr-status">
<LoadingOutlined /> ...
</div>
</div>
<div className="payment-countdown">
<span className="time">
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</span>
</div>
<div className="payment-tips">
<p>使</p>
<p></p>
</div>
</div>
</div>
</div>
</div>
);
}
const tiers = activeTab === 'personal' ? PERSONAL_TIERS : ENTERPRISE_TIERS;
return (

View File

@ -213,3 +213,144 @@
.pricing-page-h5 .pricing-grid {
grid-template-columns: 1fr;
}
/* Payment View Styles */
.payment-container {
max-width: 800px;
margin: 0 auto;
text-align: left;
background: #fff;
border: 1px solid var(--color-border);
border-radius: 24px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
}
.payment-header {
padding: 16px 24px;
border-bottom: 1px solid var(--color-border-secondary);
}
.payment-header .back-btn {
color: var(--color-text-secondary);
font-weight: 500;
}
.payment-content {
display: grid;
grid-template-columns: 1.2fr 1fr;
padding: 40px;
gap: 40px;
}
.payment-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 32px;
}
.payment-detail-item {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
font-size: 15px;
}
.payment-detail-item .label {
color: var(--color-text-tertiary);
}
.payment-detail-item .value {
font-weight: 600;
color: var(--color-text);
}
.payment-total {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
}
.payment-total .label {
font-weight: 600;
font-size: 16px;
}
.payment-total .total-amount {
font-size: 32px;
font-weight: 800;
color: var(--color-brand);
}
.payment-total .symbol {
font-size: 18px;
margin-right: 4px;
}
.payment-qr-card {
background: var(--color-fill-secondary);
border-radius: 20px;
padding: 32px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.qr-wrapper {
background: #fff;
padding: 16px;
border-radius: 12px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
position: relative;
}
.qr-status {
margin-top: 12px;
font-size: 13px;
color: var(--color-brand);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.payment-countdown {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: 16px;
}
.payment-countdown .time {
font-family: monospace;
font-weight: 700;
color: #ef4444;
font-size: 16px;
margin-left: 4px;
}
.payment-tips {
font-size: 12px;
color: var(--color-text-tertiary);
line-height: 1.6;
}
.payment-tips p {
margin: 0;
}
@media (max-width: 768px) {
.payment-content {
grid-template-columns: 1fr;
padding: 24px;
gap: 32px;
}
.payment-container {
border-radius: 0;
border: none;
box-shadow: none;
}
}