Files
sub2apipay/src/app/pay/page.tsx
erio a7089936a4 fix: 修复页面加载时闪现「入口未开放」的问题
allEntriesClosed 判断需要等 userLoaded 和 channelsLoaded 都完成,
避免 channelsLoaded 先完成但 config 还未加载时误判为入口关闭。
2026-03-15 12:03:27 +08:00

1102 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useSearchParams } from 'next/navigation';
import { useState, useEffect, Suspense, useCallback } from 'react';
import PaymentForm from '@/components/PaymentForm';
import PaymentQRCode from '@/components/PaymentQRCode';
import OrderStatus from '@/components/OrderStatus';
import PayPageLayout from '@/components/PayPageLayout';
import MobileOrderList from '@/components/MobileOrderList';
import MainTabs from '@/components/MainTabs';
import ChannelGrid from '@/components/ChannelGrid';
import SubscriptionPlanCard from '@/components/SubscriptionPlanCard';
import SubscriptionConfirm from '@/components/SubscriptionConfirm';
import UserSubscriptions from '@/components/UserSubscriptions';
import PurchaseFlow from '@/components/PurchaseFlow';
import { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale';
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
import type { MethodLimitInfo } from '@/components/PaymentForm';
import type { ChannelInfo } from '@/components/ChannelGrid';
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
import type { UserSub } from '@/components/UserSubscriptions';
interface OrderResult {
orderId: string;
amount: number;
payAmount?: number;
status: string;
paymentType: string;
payUrl?: string | null;
qrCode?: string | null;
clientSecret?: string | null;
expiresAt: string;
statusAccessToken: string;
}
interface AppConfig {
enabledPaymentTypes: string[];
minAmount: number;
maxAmount: number;
maxDailyAmount: number;
methodLimits?: Record<string, MethodLimitInfo>;
helpImageUrl?: string | null;
helpText?: string | null;
stripePublishableKey?: string | null;
balanceDisabled?: boolean;
}
function PayContent() {
const searchParams = useSearchParams();
const token = (searchParams.get('token') || '').trim();
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const tab = searchParams.get('tab');
const srcHost = searchParams.get('src_host') || undefined;
const srcUrl = searchParams.get('src_url') || undefined;
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const [isIframeContext, setIsIframeContext] = useState(true);
const [isMobile, setIsMobile] = useState(false);
const [step, setStep] = useState<'form' | 'paying' | 'result'>('form');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [subscriptionError, setSubscriptionError] = useState('');
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
const [finalOrderState, setFinalOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
const [ordersPage, setOrdersPage] = useState(1);
const [ordersHasMore, setOrdersHasMore] = useState(false);
const [ordersLoadingMore, setOrdersLoadingMore] = useState(false);
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
const [pendingCount, setPendingCount] = useState(0);
// 新增状态
const [mainTab, setMainTab] = useState<'topup' | 'subscribe'>('topup');
const [channels, setChannels] = useState<ChannelInfo[]>([]);
const [plans, setPlans] = useState<PlanInfo[]>([]);
const [userSubscriptions, setUserSubscriptions] = useState<UserSub[]>([]);
const [showTopUpForm, setShowTopUpForm] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanInfo | null>(null);
const [channelsLoaded, setChannelsLoaded] = useState(false);
const [userLoaded, setUserLoaded] = useState(false);
const [config, setConfig] = useState<AppConfig>({
enabledPaymentTypes: [],
minAmount: 1,
maxAmount: 1000,
maxDailyAmount: 0,
});
const [userNotFound, setUserNotFound] = useState(false);
const [helpImageOpen, setHelpImageOpen] = useState(false);
const hasToken = token.length > 0;
const isEmbedded = uiMode === 'embedded' && isIframeContext;
const helpImageUrl = (config.helpImageUrl || '').trim();
const helpText = (config.helpText || '').trim();
const hasHelpContent = Boolean(helpImageUrl || helpText);
// 通用帮助/客服信息区块
const renderHelpSection = () => {
if (!hasHelpContent) return null;
return (
<div
className={[
'mt-6 rounded-2xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<div className={['text-xs font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, '帮助', 'Support')}
</div>
{helpImageUrl && (
<img
src={helpImageUrl}
alt="help"
onClick={() => setHelpImageOpen(true)}
className={`mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain p-2 ${isDark ? 'bg-slate-700/50' : 'bg-white/70'}`}
/>
)}
{helpText && (
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{helpText.split('\n').map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
)}
</div>
);
};
const MAX_PENDING = 3;
const pendingBlocked = pendingCount >= MAX_PENDING;
// R6: 余额充值是否被禁用
const balanceDisabled = config.balanceDisabled === true;
// 是否有渠道配置(决定是直接显示充值表单还是渠道卡片+弹窗)
const hasChannels = channels.length > 0;
// 是否有可售卖套餐
const hasPlans = plans.length > 0;
// 是否可以充值(未禁用且有支付方式)
const canTopUp = !balanceDisabled && config.enabledPaymentTypes.length > 0;
useEffect(() => {
if (typeof window === 'undefined') return;
setIsIframeContext(window.self !== window.top);
setIsMobile(detectDeviceIsMobile());
}, []);
useEffect(() => {
if (!isMobile || step !== 'form') return;
if (tab === 'orders') {
setActiveMobileTab('orders');
return;
}
setActiveMobileTab('pay');
}, [isMobile, step, tab]);
const loadUserAndOrders = useCallback(async () => {
if (!token) return;
setUserNotFound(false);
try {
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
if (!meRes.ok) {
setUserNotFound(true);
return;
}
const meData = await meRes.json();
const meUser = meData.user || {};
const meId = Number(meUser.id);
if (!Number.isInteger(meId) || meId <= 0) {
setUserNotFound(true);
return;
}
setResolvedUserId(meId);
setPendingCount(meData.summary?.pending ?? 0);
setUserInfo({
id: meId,
username:
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
(typeof meUser.username === 'string' && meUser.username.trim()) ||
pickLocaleText(locale, `用户 #${meId}`, `User #${meId}`),
balance: typeof meUser.balance === 'number' ? meUser.balance : undefined,
});
if (Array.isArray(meData.orders)) {
setMyOrders(meData.orders);
setOrdersPage(1);
setOrdersHasMore((meData.total_pages ?? 1) > 1);
} else {
setMyOrders([]);
setOrdersPage(1);
setOrdersHasMore(false);
}
const cfgRes = await fetch(`/api/user?user_id=${meId}&token=${encodeURIComponent(token)}`);
if (cfgRes.ok) {
const cfgData = await cfgRes.json();
if (cfgData.config) {
setConfig({
enabledPaymentTypes: cfgData.config.enabledPaymentTypes ?? ['alipay', 'wxpay'],
minAmount: cfgData.config.minAmount ?? 1,
maxAmount: cfgData.config.maxAmount ?? 1000,
maxDailyAmount: cfgData.config.maxDailyAmount ?? 0,
methodLimits: cfgData.config.methodLimits,
helpImageUrl: cfgData.config.helpImageUrl ?? null,
helpText: cfgData.config.helpText ?? null,
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
balanceDisabled: cfgData.config.balanceDisabled ?? false,
});
if (cfgData.config.sublabelOverrides) {
applySublabelOverrides(cfgData.config.sublabelOverrides);
}
}
}
} catch {
} finally {
setUserLoaded(true);
}
}, [token, locale]);
// 加载渠道和订阅套餐
const loadChannelsAndPlans = useCallback(async () => {
if (!token) return;
try {
const [chRes, plRes, subRes] = await Promise.all([
fetch(`/api/channels?token=${encodeURIComponent(token)}`),
fetch(`/api/subscription-plans?token=${encodeURIComponent(token)}`),
fetch(`/api/subscriptions/my?token=${encodeURIComponent(token)}`),
]);
if (chRes.ok) {
const chData = await chRes.json();
setChannels(chData.channels ?? []);
}
if (plRes.ok) {
const plData = await plRes.json();
setPlans(plData.plans ?? []);
}
if (subRes.ok) {
const subData = await subRes.json();
setUserSubscriptions(subData.subscriptions ?? []);
}
} catch {
} finally {
setChannelsLoaded(true);
}
}, [token]);
const loadMoreOrders = async () => {
if (!token || ordersLoadingMore || !ordersHasMore) return;
const nextPage = ordersPage + 1;
setOrdersLoadingMore(true);
try {
const res = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}&page=${nextPage}&page_size=20`);
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.orders) && data.orders.length > 0) {
setMyOrders((prev) => [...prev, ...data.orders]);
setOrdersPage(nextPage);
setOrdersHasMore(nextPage < (data.total_pages ?? 1));
} else {
setOrdersHasMore(false);
}
} catch {
} finally {
setOrdersLoadingMore(false);
}
};
useEffect(() => {
loadUserAndOrders();
loadChannelsAndPlans();
}, [loadUserAndOrders, loadChannelsAndPlans]);
useEffect(() => {
if (step !== 'result' || finalOrderState?.status !== 'COMPLETED') return;
loadUserAndOrders();
loadChannelsAndPlans();
const timer = setTimeout(() => {
setStep('form');
setOrderResult(null);
setFinalOrderState(null);
setError('');
setSubscriptionError('');
setSelectedPlan(null);
}, 2200);
return () => clearTimeout(timer);
}, [step, finalOrderState, loadUserAndOrders, loadChannelsAndPlans]);
// 检查订单完成后是否是订阅分组消失的情况
useEffect(() => {
if (step !== 'result' || !finalOrderState) return;
if (finalOrderState.status === 'FAILED' && finalOrderState.failedReason?.includes('SUBSCRIPTION_GROUP_GONE')) {
setSubscriptionError(
pickLocaleText(
locale,
'您已成功支付,但订阅分组已下架,无法自动开通。请联系客服处理,提供订单号。',
'Payment successful, but the subscription group has been removed. Please contact support with your order ID.',
),
);
}
}, [step, finalOrderState, locale]);
if (!hasToken) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className="text-center text-red-500">
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{pickLocaleText(
locale,
'请从 Sub2API 平台正确访问充值页面',
'Please open the recharge page from the Sub2API platform',
)}
</p>
</div>
</div>
);
}
if (userNotFound) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className="text-center text-red-500">
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{pickLocaleText(
locale,
'请检查链接是否正确,或联系管理员',
'Please check whether the link is correct or contact the administrator',
)}
</p>
</div>
</div>
);
}
const buildScopedUrl = (path: string, forceOrdersTab = false) => {
const params = new URLSearchParams();
if (token) params.set('token', token);
params.set('theme', theme);
params.set('ui_mode', uiMode);
if (forceOrdersTab) params.set('tab', 'orders');
if (srcHost) params.set('src_host', srcHost);
if (srcUrl) params.set('src_url', srcUrl);
applyLocaleToSearchParams(params, locale);
return `${path}?${params.toString()}`;
};
const pcOrdersUrl = buildScopedUrl('/pay/orders');
const mobileOrdersUrl = buildScopedUrl('/pay', true);
const ordersUrl = isMobile ? mobileOrdersUrl : pcOrdersUrl;
// ── 余额充值提交 ──
const handleSubmit = async (amount: number, paymentType: string) => {
if (pendingBlocked) {
setError(
pickLocaleText(
locale,
`您有 ${pendingCount} 个待支付订单,请先完成或取消后再试(最多 ${MAX_PENDING} 个)`,
`You have ${pendingCount} pending orders. Please complete or cancel them first (maximum ${MAX_PENDING}).`,
),
);
return;
}
setLoading(true);
setError('');
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
amount,
payment_type: paymentType,
is_mobile: isMobile,
src_host: srcHost,
src_url: srcUrl,
}),
});
const data = await res.json();
if (!res.ok) {
const codeMessages: Record<string, string> = {
INVALID_TOKEN: pickLocaleText(locale, '认证已失效,请重新从平台进入充值页面', 'Authentication expired'),
USER_INACTIVE: pickLocaleText(locale, '账户已被禁用,无法充值', 'Account is disabled'),
TOO_MANY_PENDING: pickLocaleText(locale, '待支付订单过多,请先处理', 'Too many pending orders'),
USER_NOT_FOUND: pickLocaleText(locale, '用户不存在', 'User not found'),
DAILY_LIMIT_EXCEEDED: data.error,
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
PAYMENT_GATEWAY_ERROR: data.error,
};
setError(
codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'),
);
return;
}
setOrderResult({
orderId: data.orderId,
amount: data.amount,
payAmount: data.payAmount,
status: data.status,
paymentType: data.paymentType || paymentType,
payUrl: data.payUrl,
qrCode: data.qrCode,
clientSecret: data.clientSecret,
expiresAt: data.expiresAt,
statusAccessToken: data.statusAccessToken,
});
setStep('paying');
} catch {
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error'));
} finally {
setLoading(false);
}
};
// ── 订阅下单 ──
const handleSubscriptionSubmit = async (paymentType: string) => {
if (!selectedPlan) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
amount: selectedPlan.price,
payment_type: paymentType,
is_mobile: isMobile,
src_host: srcHost,
src_url: srcUrl,
order_type: 'subscription',
plan_id: selectedPlan.id,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || pickLocaleText(locale, '创建订阅订单失败', 'Failed to create subscription order'));
return;
}
setOrderResult({
orderId: data.orderId,
amount: data.amount,
payAmount: data.payAmount,
status: data.status,
paymentType: data.paymentType || paymentType,
payUrl: data.payUrl,
qrCode: data.qrCode,
clientSecret: data.clientSecret,
expiresAt: data.expiresAt,
statusAccessToken: data.statusAccessToken,
});
setStep('paying');
} catch {
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error'));
} finally {
setLoading(false);
}
};
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
setFinalOrderState(order);
setStep('result');
if (isMobile) setActiveMobileTab('orders');
};
const handleBack = () => {
setStep('form');
setOrderResult(null);
setFinalOrderState(null);
setError('');
setSubscriptionError('');
setSelectedPlan(null);
setShowTopUpForm(false);
};
// ── 渲染 ──
// R7: 检查是否所有入口都关闭(无可用充值方式 且 无订阅套餐)
const allEntriesClosed = channelsLoaded && userLoaded && !canTopUp && !hasPlans;
const showMainTabs = channelsLoaded && userLoaded && !allEntriesClosed && (hasChannels || hasPlans);
const pageTitle = showMainTabs
? pickLocaleText(locale, '选择适合你的 充值/订阅服务', 'Choose Your Recharge / Subscription')
: pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge');
const pageSubtitle = showMainTabs
? pickLocaleText(locale, '充值余额或者订阅套餐', 'Top up balance or subscribe to a plan')
: pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting');
return (
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth={showMainTabs ? 'full' : isMobile ? 'sm' : 'lg'}
title={pageTitle}
subtitle={pageSubtitle}
locale={locale}
actions={
!isMobile ? (
<>
<button
type="button"
onClick={() => {
loadUserAndOrders();
loadChannelsAndPlans();
}}
className={[
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
{pickLocaleText(locale, '刷新', 'Refresh')}
</button>
<a
href={ordersUrl}
className={[
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
{pickLocaleText(locale, '我的订单', 'My Orders')}
</a>
</>
) : undefined
}
>
{/* 订阅分组消失的常驻错误 */}
{subscriptionError && (
<div
className={[
'mb-4 rounded-lg border-2 p-4 text-sm',
isDark ? 'border-red-600 bg-red-900/40 text-red-300' : 'border-red-400 bg-red-50 text-red-700',
].join(' ')}
>
<div className="font-semibold mb-1">{pickLocaleText(locale, '订阅开通失败', 'Subscription Failed')}</div>
<div>{subscriptionError}</div>
{orderResult && (
<div className="mt-2 text-xs opacity-80">
{pickLocaleText(locale, '订单号', 'Order ID')}: {orderResult.orderId}
</div>
)}
</div>
)}
{error && (
<div
className={[
'mb-4 rounded-lg border p-3 text-sm',
isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
].join(' ')}
>
{error}
</div>
)}
{/* ── 表单阶段 ── */}
{step === 'form' && (
<>
{/* 移动端 Tab充值/订单 */}
{isMobile && (
<div
className={[
'mb-4 grid grid-cols-2 rounded-xl border p-1',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-300 bg-slate-100/90',
].join(' ')}
>
<button
type="button"
onClick={() => setActiveMobileTab('pay')}
className={[
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
activeMobileTab === 'pay'
? isDark
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
: isDark
? 'text-slate-400 hover:text-slate-200'
: 'text-slate-500 hover:text-slate-700',
].join(' ')}
>
{pickLocaleText(locale, '充值', 'Recharge')}
</button>
<button
type="button"
onClick={() => setActiveMobileTab('orders')}
className={[
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
activeMobileTab === 'orders'
? isDark
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
: isDark
? 'text-slate-400 hover:text-slate-200'
: 'text-slate-500 hover:text-slate-700',
].join(' ')}
>
{pickLocaleText(locale, '我的订单', 'My Orders')}
</button>
</div>
)}
{/* 加载中 */}
{(!channelsLoaded || !userLoaded) && !allEntriesClosed && (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{pickLocaleText(locale, '加载中...', 'Loading...')}
</span>
</div>
)}
{/* R7: 所有入口关闭提示 */}
{allEntriesClosed && (activeMobileTab === 'pay' || !isMobile) && (
<div
className={[
'rounded-2xl border p-8 text-center',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<div className={['text-4xl mb-4'].join(' ')}>
<svg
className={['mx-auto h-12 w-12', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
</div>
<p className={['text-lg font-medium mb-2', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
{pickLocaleText(locale, '充值/订阅 入口未开放', 'Recharge / Subscription entry is not available')}
</p>
<p className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(
locale,
'如有疑问,请联系管理员',
'Please contact the administrator if you have questions',
)}
</p>
</div>
)}
{/* ── 有渠道配置新版UI ── */}
{channelsLoaded &&
showMainTabs &&
(activeMobileTab === 'pay' || !isMobile) &&
!selectedPlan &&
!showTopUpForm && (
<>
<MainTabs
activeTab={!canTopUp ? 'subscribe' : mainTab}
onTabChange={setMainTab}
showSubscribeTab={hasPlans}
showTopUpTab={canTopUp}
isDark={isDark}
locale={locale}
/>
{mainTab === 'topup' && canTopUp && (
<div className="mt-6">
{/* 按量付费说明 banner */}
<div
className={[
'mb-6 rounded-2xl border p-6',
isDark
? 'border-emerald-500/20 bg-gradient-to-r from-emerald-500/10 to-purple-500/10'
: 'border-emerald-500/20 bg-gradient-to-r from-emerald-50 to-purple-50',
].join(' ')}
>
<div className="flex items-start gap-4">
<div
className={[
'flex-shrink-0 rounded-lg p-2',
isDark ? 'bg-emerald-500/20' : 'bg-emerald-500/15',
].join(' ')}
>
<svg
className="h-6 w-6 text-emerald-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
</div>
<div className="flex-1">
<h3
className={[
'text-lg font-semibold mb-2',
isDark ? 'text-emerald-400' : 'text-emerald-700',
].join(' ')}
>
{pickLocaleText(locale, '按量付费模式', 'Pay-as-you-go')}
</h3>
<p className={['text-sm mb-4', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(
locale,
'无需订阅充值即用按实际消耗扣费。余额所有渠道通用可自由切换。价格以美元计价当前比例1美元≈1人民币',
'No subscription needed. Top up and use. Charged by actual usage. Balance works across all channels. Priced in USD (current rate: 1 USD ≈ 1 CNY)',
)}
</p>
<div className="flex flex-wrap gap-4 text-sm">
<div
className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(
' ',
)}
>
<svg
className="h-4 w-4 text-green-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" />
</svg>
<span>{pickLocaleText(locale, '倍率越低越划算', 'Lower rate = better value')}</span>
</div>
<div
className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(
' ',
)}
>
<svg
className="h-4 w-4 text-blue-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<span>
{pickLocaleText(
locale,
'0.15倍率 = 1元可用约6.67美元额度',
'0.15 rate = 1 CNY ≈ $6.67 quota',
)}
</span>
</div>
</div>
</div>
</div>
</div>
{hasChannels ? (
<ChannelGrid
channels={channels}
onTopUp={() => setShowTopUpForm(true)}
isDark={isDark}
locale={locale}
userBalance={userInfo?.balance}
/>
) : (
<PaymentForm
userId={resolvedUserId ?? 0}
userName={userInfo?.username}
userBalance={userInfo?.balance}
enabledPaymentTypes={config.enabledPaymentTypes}
methodLimits={config.methodLimits}
minAmount={config.minAmount}
maxAmount={config.maxAmount}
onSubmit={handleSubmit}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
locale={locale}
/>
)}
{renderHelpSection()}
</div>
)}
{mainTab === 'subscribe' && (
<div className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{plans.map((plan) => (
<SubscriptionPlanCard
key={plan.id}
plan={plan}
onSubscribe={() => setSelectedPlan(plan)}
isDark={isDark}
locale={locale}
/>
))}
</div>
{renderHelpSection()}
</div>
)}
{/* 用户已有订阅 — 所有 tab 共用 */}
{userSubscriptions.length > 0 && (
<div className="mt-8">
<h3
className={['text-lg font-semibold mb-3', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}
>
{pickLocaleText(locale, '我的订阅', 'My Subscriptions')}
</h3>
<UserSubscriptions
subscriptions={userSubscriptions}
onRenew={(groupId) => {
const plan = plans.find((p) => p.groupId === groupId);
if (plan) {
setSelectedPlan(plan);
setMainTab('subscribe');
}
}}
isDark={isDark}
locale={locale}
/>
</div>
)}
<PurchaseFlow isDark={isDark} locale={locale} />
</>
)}
{/* 点击"立即充值"后:直接显示 PaymentForm含金额选择 */}
{showTopUpForm && step === 'form' && (
<div>
<button
type="button"
onClick={() => setShowTopUpForm(false)}
className={[
'mb-4 flex items-center gap-1 text-sm transition-colors',
isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
].join(' ')}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{pickLocaleText(locale, '返回', 'Back')}
</button>
<PaymentForm
userId={resolvedUserId ?? 0}
userName={userInfo?.username}
userBalance={userInfo?.balance}
enabledPaymentTypes={config.enabledPaymentTypes}
methodLimits={config.methodLimits}
minAmount={config.minAmount}
maxAmount={config.maxAmount}
onSubmit={handleSubmit}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
locale={locale}
/>
{renderHelpSection()}
</div>
)}
{/* 订阅确认页 */}
{selectedPlan && step === 'form' && (
<>
<SubscriptionConfirm
plan={selectedPlan}
paymentTypes={config.enabledPaymentTypes}
onBack={() => setSelectedPlan(null)}
onSubmit={handleSubscriptionSubmit}
loading={loading}
isDark={isDark}
locale={locale}
/>
{renderHelpSection()}
</>
)}
{/* ── 无渠道配置传统充值UI ── */}
{channelsLoaded && userLoaded && !showMainTabs && canTopUp && !selectedPlan && (
<>
{isMobile ? (
activeMobileTab === 'pay' ? (
<PaymentForm
userId={resolvedUserId ?? 0}
userName={userInfo?.username}
userBalance={userInfo?.balance}
enabledPaymentTypes={config.enabledPaymentTypes}
methodLimits={config.methodLimits}
minAmount={config.minAmount}
maxAmount={config.maxAmount}
onSubmit={handleSubmit}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
locale={locale}
/>
) : (
<MobileOrderList
isDark={isDark}
hasToken={hasToken}
orders={myOrders}
hasMore={ordersHasMore}
loadingMore={ordersLoadingMore}
onRefresh={loadUserAndOrders}
onLoadMore={loadMoreOrders}
locale={locale}
/>
)
) : (
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(300px,0.8fr)]">
<div className="min-w-0">
<PaymentForm
userId={resolvedUserId ?? 0}
userName={userInfo?.username}
userBalance={userInfo?.balance}
enabledPaymentTypes={config.enabledPaymentTypes}
methodLimits={config.methodLimits}
minAmount={config.minAmount}
maxAmount={config.maxAmount}
onSubmit={handleSubmit}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
locale={locale}
/>
</div>
<div className="space-y-4">
<div
className={[
'rounded-2xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
</div>
<ul
className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}
>
<li>
{pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically')}
</li>
<li>
{pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for history')}
</li>
{config.maxDailyAmount > 0 && (
<li>
{pickLocaleText(locale, '每日最大充值', 'Max daily recharge')} ¥
{config.maxDailyAmount.toFixed(2)}
</li>
)}
</ul>
</div>
{hasHelpContent && (
<div
className={[
'rounded-2xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, '帮助', 'Support')}
</div>
{helpImageUrl && (
<img
src={helpImageUrl}
alt="help"
onClick={() => setHelpImageOpen(true)}
className={`mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain p-2 ${isDark ? 'bg-slate-700/50' : 'bg-white/70'}`}
/>
)}
{helpText && (
<div
className={[
'mt-3 space-y-1 text-sm leading-6',
isDark ? 'text-slate-300' : 'text-slate-600',
].join(' ')}
>
{helpText.split('\n').map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
)}
</div>
)}
</div>
</div>
)}
</>
)}
{/* 移动端订单列表 */}
{isMobile && activeMobileTab === 'orders' && showMainTabs && (
<MobileOrderList
isDark={isDark}
hasToken={hasToken}
orders={myOrders}
hasMore={ordersHasMore}
loadingMore={ordersLoadingMore}
onRefresh={loadUserAndOrders}
onLoadMore={loadMoreOrders}
locale={locale}
/>
)}
</>
)}
{/* ── 支付阶段 ── */}
{step === 'paying' && orderResult && (
<>
<PaymentQRCode
orderId={orderResult.orderId}
token={token || undefined}
payUrl={orderResult.payUrl}
qrCode={orderResult.qrCode}
clientSecret={orderResult.clientSecret}
stripePublishableKey={config.stripePublishableKey}
paymentType={orderResult.paymentType}
amount={orderResult.amount}
payAmount={orderResult.payAmount}
expiresAt={orderResult.expiresAt}
statusAccessToken={orderResult.statusAccessToken}
onStatusChange={handleStatusChange}
onBack={handleBack}
dark={isDark}
isEmbedded={isEmbedded}
isMobile={isMobile}
locale={locale}
/>
{renderHelpSection()}
</>
)}
{/* ── 结果阶段 ── */}
{step === 'result' && orderResult && finalOrderState && (
<OrderStatus
orderId={orderResult.orderId}
order={finalOrderState}
statusAccessToken={orderResult.statusAccessToken}
onStateChange={setFinalOrderState}
onBack={handleBack}
dark={isDark}
locale={locale}
/>
)}
{/* 帮助图片放大 */}
{helpImageOpen && helpImageUrl && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
onClick={() => setHelpImageOpen(false)}
>
<img
src={helpImageUrl}
alt="help"
className="max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</PayPageLayout>
);
}
function PayPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
const isDark = searchParams.get('theme') === 'dark';
return (
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
{pickLocaleText(locale, '加载中...', 'Loading...')}
</div>
</div>
);
}
export default function PayPage() {
return (
<Suspense fallback={<PayPageFallback />}>
<PayContent />
</Suspense>
);
}