feat: 渠道展示、订阅套餐、系统配置全功能

- 新增 Channel / SubscriptionPlan / SystemConfig 三个数据模型
- Order 模型扩展支持订阅订单(order_type, plan_id, subscription_group_id)
- Sub2API client 新增分组查询、订阅分配/续期、用户订阅查询
- 订单服务支持订阅履约流程(CAS 锁 + 分组消失安全处理)
- 管理后台:渠道管理、订阅套餐管理、系统配置、Sub2API 分组同步
- 用户页面:双 Tab UI(按量付费/包月订阅)、渠道卡片、充值弹窗、订阅确认
- PaymentForm 支持 fixedAmount 固定金额模式
- 订单状态 API 返回 failedReason 用于订阅异常展示
- 数据库迁移脚本
This commit is contained in:
erio
2026-03-13 19:06:25 +08:00
parent 9f621713c3
commit eafb7e49fa
38 changed files with 5376 additions and 289 deletions

View File

@@ -1,16 +1,26 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useState, useEffect, Suspense } from 'react';
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 TopUpModal from '@/components/TopUpModal';
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;
@@ -52,6 +62,7 @@ function PayContent() {
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);
@@ -63,6 +74,15 @@ function PayContent() {
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 [topUpModalOpen, setTopUpModalOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanInfo | null>(null);
const [channelsLoaded, setChannelsLoaded] = useState(false);
const [config, setConfig] = useState<AppConfig>({
enabledPaymentTypes: [],
minAmount: 1,
@@ -80,9 +100,13 @@ function PayContent() {
const MAX_PENDING = 3;
const pendingBlocked = pendingCount >= MAX_PENDING;
// 是否有渠道配置(决定是直接显示充值表单还是渠道卡片+弹窗)
const hasChannels = channels.length > 0;
// 是否有可售卖套餐
const hasPlans = plans.length > 0;
useEffect(() => {
if (typeof window === 'undefined') return;
setIsIframeContext(window.self !== window.top);
setIsMobile(detectDeviceIsMobile());
}, []);
@@ -96,9 +120,8 @@ function PayContent() {
setActiveMobileTab('pay');
}, [isMobile, step, tab]);
const loadUserAndOrders = async () => {
const loadUserAndOrders = useCallback(async () => {
if (!token) return;
setUserNotFound(false);
try {
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
@@ -157,7 +180,34 @@ function PayContent() {
}
}
} catch {}
};
}, [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;
@@ -182,19 +232,40 @@ function PayContent() {
useEffect(() => {
loadUserAndOrders();
}, [token, locale]);
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]);
}, [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 (
@@ -202,11 +273,7 @@ function PayContent() {
<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 text-gray-500">
{pickLocaleText(
locale,
'请从 Sub2API 平台正确访问充值页面',
'Please open the recharge page from the Sub2API platform',
)}
{pickLocaleText(locale, '请从 Sub2API 平台正确访问充值页面', 'Please open the recharge page from the Sub2API platform')}
</p>
</div>
</div>
@@ -219,11 +286,7 @@ function PayContent() {
<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 text-gray-500">
{pickLocaleText(
locale,
'请检查链接是否正确,或联系管理员',
'Please check whether the link is correct or contact the administrator',
)}
{pickLocaleText(locale, '请检查链接是否正确,或联系管理员', 'Please check whether the link is correct or contact the administrator')}
</p>
</div>
</div>
@@ -246,6 +309,7 @@ function PayContent() {
const mobileOrdersUrl = buildScopedUrl('/pay', true);
const ordersUrl = isMobile ? mobileOrdersUrl : pcOrdersUrl;
// ── 余额充值提交 ──
const handleSubmit = async (amount: number, paymentType: string) => {
if (pendingBlocked) {
setError(
@@ -279,33 +343,15 @@ function PayContent() {
if (!res.ok) {
const codeMessages: Record<string, string> = {
INVALID_TOKEN: pickLocaleText(
locale,
'认证已失效,请重新从平台进入充值页面',
'Authentication expired. Please re-enter the recharge page from the platform',
),
USER_INACTIVE: pickLocaleText(
locale,
'账户已被禁用,无法充值,请联系管理员',
'This account is disabled and cannot be recharged. Please contact the administrator',
),
TOO_MANY_PENDING: pickLocaleText(
locale,
'您有过多待支付订单,请先完成或取消现有订单后再试',
'You have too many pending orders. Please complete or cancel existing orders first',
),
USER_NOT_FOUND: pickLocaleText(
locale,
'用户不存在,请检查链接是否正确',
'User not found. Please check whether the link is correct',
),
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'),
);
setError(codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'));
return;
}
@@ -321,10 +367,66 @@ function PayContent() {
expiresAt: data.expiresAt,
statusAccessToken: data.statusAccessToken,
});
setTopUpModalOpen(false);
setStep('paying');
} catch {
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error. Please try again later'));
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error'));
} finally {
setLoading(false);
}
};
// ── 充值弹窗确认 → 进入支付方式选择(复用 PaymentForm ──
const [topUpAmount, setTopUpAmount] = useState<number | null>(null);
const handleTopUpConfirm = (amount: number) => {
setTopUpAmount(amount);
setTopUpModalOpen(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);
}
@@ -333,9 +435,7 @@ function PayContent() {
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
setFinalOrderState(order);
setStep('result');
if (isMobile) {
setActiveMobileTab('orders');
}
if (isMobile) setActiveMobileTab('orders');
};
const handleBack = () => {
@@ -343,27 +443,37 @@ function PayContent() {
setOrderResult(null);
setFinalOrderState(null);
setError('');
setSubscriptionError('');
setSelectedPlan(null);
setTopUpAmount(null);
};
// ── 渲染 ──
const showMainTabs = channelsLoaded && (hasChannels || hasPlans);
const pageTitle = showMainTabs
? pickLocaleText(locale, '选择适合你的 订阅套餐', 'Choose Your Plan')
: pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge');
const pageSubtitle = showMainTabs
? pickLocaleText(locale, '通过支付购买或兑换码激活获取订阅服务', 'Subscribe via payment or activation code')
: pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting');
return (
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth={isMobile ? 'sm' : 'lg'}
title={pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge')}
subtitle={pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting')}
maxWidth={showMainTabs ? 'full' : isMobile ? 'sm' : 'lg'}
title={pageTitle}
subtitle={pageSubtitle}
locale={locale}
actions={
!isMobile ? (
<>
<button
type="button"
onClick={loadUserAndOrders}
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',
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')}
@@ -372,9 +482,7 @@ function PayContent() {
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',
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')}
@@ -383,72 +491,193 @@ function PayContent() {
) : 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(' ')}
>
<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' && 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>
)}
{step === 'form' && config.enabledPaymentTypes.length === 0 && (
<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>
)}
{step === 'form' && config.enabledPaymentTypes.length > 0 && (
{/* ── 表单阶段 ── */}
{step === 'form' && (
<>
{isMobile ? (
activeMobileTab === 'pay' ? (
{/* 移动端 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 && config.enabledPaymentTypes.length === 0 && (
<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>
)}
{/* ── 有渠道配置新版UI ── */}
{channelsLoaded && showMainTabs && (activeMobileTab === 'pay' || !isMobile) && !selectedPlan && !topUpAmount && (
<>
<MainTabs activeTab={mainTab} onTabChange={setMainTab} showSubscribeTab={hasPlans} isDark={isDark} locale={locale} />
{mainTab === 'topup' && (
<div className="mt-6">
{/* 按量付费说明 banner */}
<div className={[
'mb-6 rounded-xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50',
].join(' ')}>
<div className="flex items-start gap-3">
<div className={['text-2xl'].join(' ')}>💰</div>
<div>
<div className={['font-semibold', isDark ? 'text-emerald-400' : 'text-emerald-600'].join(' ')}>
{pickLocaleText(locale, '按量付费模式', 'Pay-as-you-go')}
</div>
<div className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(
locale,
'无需订阅,充值即用,按实际消耗扣费,余额所有渠道通用。',
'No subscription needed. Top up and use. Charged by actual usage. Balance works across all channels.',
)}
</div>
</div>
</div>
</div>
<ChannelGrid
channels={channels}
onTopUp={() => setTopUpModalOpen(true)}
isDark={isDark}
locale={locale}
userBalance={userInfo?.balance}
/>
{/* 用户已有订阅 */}
{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>
)}
</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>
{/* 用户已有订阅 */}
{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);
}}
isDark={isDark}
locale={locale}
/>
</div>
)}
</div>
)}
<PurchaseFlow isDark={isDark} locale={locale} />
<TopUpModal
open={topUpModalOpen}
onClose={() => setTopUpModalOpen(false)}
onConfirm={handleTopUpConfirm}
isDark={isDark}
locale={locale}
/>
</>
)}
{/* 充值弹窗确认后:选择支付方式 */}
{topUpAmount && step === 'form' && (
<div>
<button
type="button"
onClick={() => setTopUpAmount(null)}
className={['mb-4 text-sm', isDark ? 'text-emerald-400 hover:text-emerald-300' : 'text-emerald-600 hover:text-emerald-500'].join(' ')}
>
{pickLocaleText(locale, '返回', 'Back')}
</button>
<PaymentForm
userId={resolvedUserId ?? 0}
userName={userInfo?.username}
@@ -457,116 +686,132 @@ function PayContent() {
methodLimits={config.methodLimits}
minAmount={config.minAmount}
maxAmount={config.maxAmount}
onSubmit={handleSubmit}
onSubmit={(_, paymentType) => handleSubmit(topUpAmount, paymentType)}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
locale={locale}
fixedAmount={topUpAmount}
/>
) : (
<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 after the order completes',
)}
</li>
<li>
{pickLocaleText(
locale,
'如需历史记录请查看「我的订单」',
'Check "My Orders" for payment history',
)}
</li>
{config.maxDailyAmount > 0 && (
<li>
{pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥
{config.maxDailyAmount.toFixed(2)}
</li>
)}
</ul>
</div>
</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')}
{/* 订阅确认页 */}
{selectedPlan && step === 'form' && (
<SubscriptionConfirm
plan={selectedPlan}
paymentTypes={config.enabledPaymentTypes}
onBack={() => setSelectedPlan(null)}
onSubmit={handleSubscriptionSubmit}
loading={loading}
isDark={isDark}
locale={locale}
/>
)}
{/* ── 无渠道配置传统充值UI ── */}
{channelsLoaded && !showMainTabs && config.enabledPaymentTypes.length > 0 && !topUpAmount && !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>
{helpImageUrl && (
<img
src={helpImageUrl}
alt="help"
onClick={() => setHelpImageOpen(true)}
className="mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2"
/>
)}
{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>
))}
{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 bg-white/70 p-2" />
)}
{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>
</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}
@@ -589,6 +834,7 @@ function PayContent() {
/>
)}
{/* ── 结果阶段 ── */}
{step === 'result' && orderResult && finalOrderState && (
<OrderStatus
orderId={orderResult.orderId}
@@ -601,17 +847,10 @@ function PayContent() {
/>
)}
{/* 帮助图片放大 */}
{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 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>
@@ -621,7 +860,6 @@ function PayContent() {
function PayPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>