feat: 订阅管理增强、商品名称配置、余额充值开关
- R1: 用户订阅搜索改为模糊关键词(邮箱/用户名/备注/APIKey) - R2: "分组状态"列名改为"Sub2API 状态" - R3: 订阅套餐可配置支付商品名称(productName) - R4: 订阅订单校验 subscription_type 必须为 subscription - R5: 渠道管理配置余额充值商品名前缀/后缀 - R6: 渠道管理可关闭余额充值,前端隐藏入口,API 拒绝 - R7: 所有入口关闭时显示"入口被管理员关闭"提示 - fix: easy-pay client 测试 mock 方式修复(vi.fn + 参数快照)
This commit is contained in:
@@ -110,6 +110,15 @@ function getTexts(locale: Locale) {
|
||||
syncImportSuccess: (n: number) => `Successfully imported ${n} channel(s)`,
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
rechargeConfig: 'Recharge Configuration',
|
||||
productNamePrefix: 'Product Name Prefix',
|
||||
productNameSuffix: 'Product Name Suffix',
|
||||
preview: 'Preview',
|
||||
enableBalanceRecharge: 'Enable Balance Recharge',
|
||||
saveConfig: 'Save',
|
||||
savingConfig: 'Saving...',
|
||||
configSaved: 'Configuration saved',
|
||||
configSaveFailed: 'Failed to save configuration',
|
||||
}
|
||||
: {
|
||||
missingToken: '缺少管理员凭证',
|
||||
@@ -163,6 +172,15 @@ function getTexts(locale: Locale) {
|
||||
syncImportSuccess: (n: number) => `成功导入 ${n} 个渠道`,
|
||||
yes: '是',
|
||||
no: '否',
|
||||
rechargeConfig: '充值配置',
|
||||
productNamePrefix: '商品名前缀',
|
||||
productNameSuffix: '商品名后缀',
|
||||
preview: '预览',
|
||||
enableBalanceRecharge: '启用余额充值',
|
||||
saveConfig: '保存',
|
||||
savingConfig: '保存中...',
|
||||
configSaved: '配置已保存',
|
||||
configSaveFailed: '保存配置失败',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,6 +242,12 @@ function ChannelsContent() {
|
||||
const [form, setForm] = useState<ChannelFormData>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Recharge config state (R5, R6)
|
||||
const [rcPrefix, setRcPrefix] = useState('');
|
||||
const [rcSuffix, setRcSuffix] = useState('');
|
||||
const [rcBalanceEnabled, setRcBalanceEnabled] = useState(true);
|
||||
const [rcSaving, setRcSaving] = useState(false);
|
||||
|
||||
// Sync modal state
|
||||
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
||||
const [syncGroups, setSyncGroups] = useState<Sub2ApiGroup[]>([]);
|
||||
@@ -254,9 +278,55 @@ function ChannelsContent() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Fetch recharge config
|
||||
const fetchRechargeConfig = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/config?token=${encodeURIComponent(token)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const configs: { key: string; value: string }[] = data.configs ?? [];
|
||||
for (const c of configs) {
|
||||
if (c.key === 'PRODUCT_NAME_PREFIX') setRcPrefix(c.value);
|
||||
if (c.key === 'PRODUCT_NAME_SUFFIX') setRcSuffix(c.value);
|
||||
if (c.key === 'BALANCE_PAYMENT_DISABLED') setRcBalanceEnabled(c.value !== 'true');
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [token]);
|
||||
|
||||
const saveRechargeConfig = async () => {
|
||||
setRcSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
configs: [
|
||||
{ key: 'PRODUCT_NAME_PREFIX', value: rcPrefix.trim(), group: 'payment', label: '商品名前缀' },
|
||||
{ key: 'PRODUCT_NAME_SUFFIX', value: rcSuffix.trim(), group: 'payment', label: '商品名后缀' },
|
||||
{ key: 'BALANCE_PAYMENT_DISABLED', value: rcBalanceEnabled ? 'false' : 'true', group: 'payment', label: '余额充值禁用' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(t.configSaveFailed);
|
||||
}
|
||||
} catch {
|
||||
setError(t.configSaveFailed);
|
||||
} finally {
|
||||
setRcSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
}, [fetchChannels]);
|
||||
fetchRechargeConfig();
|
||||
}, [fetchChannels, fetchRechargeConfig]);
|
||||
|
||||
// ── Missing token ──
|
||||
|
||||
@@ -537,6 +607,76 @@ function ChannelsContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recharge config card (R5, R6) */}
|
||||
<div
|
||||
className={[
|
||||
'mb-4 rounded-xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['text-sm font-semibold mb-3', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
{t.rechargeConfig}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t.productNamePrefix}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rcPrefix}
|
||||
onChange={(e) => setRcPrefix(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="Sub2API"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t.productNameSuffix}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rcSuffix}
|
||||
onChange={(e) => setRcSuffix(e.target.value)}
|
||||
className={inputCls}
|
||||
placeholder="CNY"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t.preview}</label>
|
||||
<div className={['rounded-lg border px-3 py-2 text-sm', isDark ? 'border-slate-600 bg-slate-700 text-slate-300' : 'border-slate-300 bg-slate-50 text-slate-600'].join(' ')}>
|
||||
{`${rcPrefix.trim() || 'Sub2API'} 100 ${rcSuffix.trim() || 'CNY'}`.trim()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRcBalanceEnabled(!rcBalanceEnabled)}
|
||||
className={[
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
||||
rcBalanceEnabled ? 'bg-emerald-500' : isDark ? 'bg-slate-600' : 'bg-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
|
||||
rcBalanceEnabled ? 'translate-x-4.5' : 'translate-x-0.5',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
<span className={['text-sm', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
|
||||
{t.enableBalanceRecharge}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveRechargeConfig}
|
||||
disabled={rcSaving}
|
||||
className="inline-flex items-center rounded-lg bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{rcSaving ? t.savingConfig : t.saveConfig}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel table */}
|
||||
<div
|
||||
className={[
|
||||
|
||||
@@ -27,6 +27,7 @@ interface SubscriptionPlan {
|
||||
groupWeeklyLimit: number | null;
|
||||
groupMonthlyLimit: number | null;
|
||||
groupModelScopes: string[] | null;
|
||||
productName: string | null;
|
||||
}
|
||||
|
||||
interface Sub2ApiGroup {
|
||||
@@ -103,7 +104,7 @@ function buildText(locale: Locale) {
|
||||
colOriginalPrice: 'Original Price',
|
||||
colValidDays: 'Validity',
|
||||
colEnabled: 'For Sale',
|
||||
colGroupStatus: 'Group Status',
|
||||
colGroupStatus: 'Sub2API Status',
|
||||
colActions: 'Actions',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
@@ -112,10 +113,12 @@ function buildText(locale: Locale) {
|
||||
groupExists: 'Exists',
|
||||
groupMissing: 'Missing',
|
||||
noPlans: 'No plans configured',
|
||||
searchUserId: 'Search by user ID',
|
||||
searchUserId: 'Email / Username / Notes / API Key',
|
||||
search: 'Search',
|
||||
noSubs: 'No subscription records found',
|
||||
enterUserId: 'Enter a user ID to search',
|
||||
enterUserId: 'Enter a keyword to search users',
|
||||
fieldProductName: 'Payment Product Name',
|
||||
fieldProductNamePlaceholder: 'Leave empty for default',
|
||||
saveFailed: 'Failed to save plan',
|
||||
deleteFailed: 'Failed to delete plan',
|
||||
loadFailed: 'Failed to load data',
|
||||
@@ -183,7 +186,7 @@ function buildText(locale: Locale) {
|
||||
colOriginalPrice: '原价',
|
||||
colValidDays: '有效期',
|
||||
colEnabled: '启用售卖',
|
||||
colGroupStatus: '分组状态',
|
||||
colGroupStatus: 'Sub2API 状态',
|
||||
colActions: '操作',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
@@ -192,10 +195,12 @@ function buildText(locale: Locale) {
|
||||
groupExists: '存在',
|
||||
groupMissing: '缺失',
|
||||
noPlans: '暂无套餐配置',
|
||||
searchUserId: '按用户 ID 搜索',
|
||||
searchUserId: '邮箱/用户名/备注/API Key',
|
||||
search: '搜索',
|
||||
noSubs: '未找到订阅记录',
|
||||
enterUserId: '请输入用户 ID 进行搜索',
|
||||
enterUserId: '输入关键词搜索用户',
|
||||
fieldProductName: '支付商品名称',
|
||||
fieldProductNamePlaceholder: '留空使用默认名称',
|
||||
saveFailed: '保存套餐失败',
|
||||
deleteFailed: '删除套餐失败',
|
||||
loadFailed: '加载数据失败',
|
||||
@@ -332,10 +337,15 @@ function SubscriptionsContent() {
|
||||
const [formFeatures, setFormFeatures] = useState('');
|
||||
const [formSortOrder, setFormSortOrder] = useState('0');
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
const [formProductName, setFormProductName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
/* --- subs state --- */
|
||||
const [subsUserId, setSubsUserId] = useState('');
|
||||
const [subsKeyword, setSubsKeyword] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<{ id: number; email: string; username: string; notes?: string }[]>([]);
|
||||
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
|
||||
const [searchTimer, setSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [subs, setSubs] = useState<Sub2ApiSubscription[]>([]);
|
||||
const [subsUser, setSubsUser] = useState<SubsUserInfo | null>(null);
|
||||
const [subsLoading, setSubsLoading] = useState(false);
|
||||
@@ -396,6 +406,7 @@ function SubscriptionsContent() {
|
||||
setFormFeatures('');
|
||||
setFormSortOrder('0');
|
||||
setFormEnabled(true);
|
||||
setFormProductName('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -411,6 +422,7 @@ function SubscriptionsContent() {
|
||||
setFormFeatures((plan.features ?? []).join('\n'));
|
||||
setFormSortOrder(String(plan.sortOrder));
|
||||
setFormEnabled(plan.enabled);
|
||||
setFormProductName(plan.productName ?? '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -438,6 +450,7 @@ function SubscriptionsContent() {
|
||||
.filter(Boolean),
|
||||
sort_order: parseInt(formSortOrder, 10) || 0,
|
||||
for_sale: formEnabled,
|
||||
product_name: formProductName.trim() || null,
|
||||
};
|
||||
try {
|
||||
const url = editingPlan
|
||||
@@ -502,6 +515,39 @@ function SubscriptionsContent() {
|
||||
}
|
||||
};
|
||||
|
||||
/* --- search users (R1) --- */
|
||||
const handleKeywordChange = (value: string) => {
|
||||
setSubsKeyword(value);
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
if (!value.trim()) {
|
||||
setSearchResults([]);
|
||||
setSearchDropdownOpen(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/sub2api/search-users?token=${encodeURIComponent(token)}&keyword=${encodeURIComponent(value.trim())}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchResults(data.users ?? []);
|
||||
setSearchDropdownOpen(true);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 300);
|
||||
setSearchTimer(timer);
|
||||
};
|
||||
|
||||
const selectUser = (user: { id: number; email: string; username: string }) => {
|
||||
setSubsUserId(String(user.id));
|
||||
setSubsKeyword(`${user.email} #${user.id}`);
|
||||
setSearchDropdownOpen(false);
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
/* --- fetch user subs --- */
|
||||
const fetchSubs = async () => {
|
||||
if (!token || !subsUserId.trim()) return;
|
||||
@@ -883,19 +929,54 @@ function SubscriptionsContent() {
|
||||
{/* ====== Tab: User Subscriptions ====== */}
|
||||
{activeTab === 'subs' && (
|
||||
<>
|
||||
{/* Search bar */}
|
||||
{/* Search bar (R1: fuzzy search) */}
|
||||
<div className="mb-4 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={subsUserId}
|
||||
onChange={(e) => setSubsUserId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchSubs()}
|
||||
placeholder={t.searchUserId}
|
||||
className={[inputCls, 'max-w-xs'].join(' ')}
|
||||
/>
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={subsKeyword}
|
||||
onChange={(e) => handleKeywordChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setSearchDropdownOpen(false);
|
||||
fetchSubs();
|
||||
}
|
||||
}}
|
||||
onFocus={() => { if (searchResults.length > 0) setSearchDropdownOpen(true); }}
|
||||
placeholder={t.searchUserId}
|
||||
className={inputCls}
|
||||
/>
|
||||
{/* Dropdown */}
|
||||
{searchDropdownOpen && searchResults.length > 0 && (
|
||||
<div
|
||||
className={[
|
||||
'absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto rounded-lg border shadow-lg',
|
||||
isDark ? 'border-slate-600 bg-slate-800' : 'border-slate-200 bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{searchResults.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => selectUser(u)}
|
||||
className={[
|
||||
'w-full px-3 py-2 text-left text-sm transition-colors',
|
||||
isDark ? 'hover:bg-slate-700 text-slate-200' : 'hover:bg-slate-50 text-slate-800',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="font-medium">{u.email}</div>
|
||||
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
{u.username} #{u.id}
|
||||
{u.notes && <span className="ml-2 opacity-70">({u.notes})</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchSubs}
|
||||
onClick={() => { setSearchDropdownOpen(false); fetchSubs(); }}
|
||||
disabled={subsLoading || !subsUserId.trim()}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
|
||||
@@ -1192,6 +1273,18 @@ function SubscriptionsContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Name (R3) */}
|
||||
<div>
|
||||
<label className={labelCls}>{t.fieldProductName}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formProductName}
|
||||
onChange={(e) => setFormProductName(e.target.value)}
|
||||
placeholder={t.fieldProductNamePlaceholder}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
@@ -48,6 +48,9 @@ export async function PUT(request: NextRequest) {
|
||||
'DAILY_RECHARGE_LIMIT',
|
||||
'ORDER_TIMEOUT_MINUTES',
|
||||
'IFRAME_ALLOW_ORIGINS',
|
||||
'PRODUCT_NAME_PREFIX',
|
||||
'PRODUCT_NAME_SUFFIX',
|
||||
'BALANCE_PAYMENT_DISABLED',
|
||||
]);
|
||||
|
||||
// 校验每条配置
|
||||
|
||||
20
src/app/api/admin/sub2api/search-users/route.ts
Normal file
20
src/app/api/admin/sub2api/search-users/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { searchUsers } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
const keyword = request.nextUrl.searchParams.get('keyword')?.trim();
|
||||
if (!keyword) {
|
||||
return NextResponse.json({ users: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await searchUsers(keyword);
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
console.error('Failed to search users:', error instanceof Error ? error.message : String(error));
|
||||
return NextResponse.json({ error: '搜索用户失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
data.validityUnit = body.validity_unit;
|
||||
}
|
||||
if (body.features !== undefined) data.features = body.features ? JSON.stringify(body.features) : null;
|
||||
if (body.product_name !== undefined) data.productName = body.product_name?.trim() || null;
|
||||
if (body.for_sale !== undefined) data.forSale = body.for_sale;
|
||||
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
|
||||
|
||||
@@ -69,6 +70,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
features: plan.features ? JSON.parse(plan.features) : [],
|
||||
sortOrder: plan.sortOrder,
|
||||
enabled: plan.forSale,
|
||||
productName: plan.productName ?? null,
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ export async function GET(request: NextRequest) {
|
||||
groupWeeklyLimit: group?.weekly_limit_usd ?? null,
|
||||
groupMonthlyLimit: group?.monthly_limit_usd ?? null,
|
||||
groupModelScopes: group?.supported_model_scopes ?? null,
|
||||
productName: plan.productName ?? null,
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt,
|
||||
};
|
||||
@@ -62,7 +63,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { group_id, name, description, price, original_price, validity_days, validity_unit, features, for_sale, sort_order } = body;
|
||||
const { group_id, name, description, price, original_price, validity_days, validity_unit, features, for_sale, sort_order, product_name } = body;
|
||||
|
||||
if (!group_id || !name || price === undefined) {
|
||||
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
|
||||
@@ -103,6 +104,7 @@ export async function POST(request: NextRequest) {
|
||||
validityDays: validity_days ?? 30,
|
||||
validityUnit: ['day', 'week', 'month'].includes(validity_unit) ? validity_unit : 'day',
|
||||
features: features ? JSON.stringify(features) : null,
|
||||
productName: product_name?.trim() || null,
|
||||
forSale: for_sale ?? false,
|
||||
sortOrder: sort_order ?? 0,
|
||||
},
|
||||
@@ -122,6 +124,7 @@ export async function POST(request: NextRequest) {
|
||||
features: plan.features ? JSON.parse(plan.features) : [],
|
||||
sortOrder: plan.sortOrder,
|
||||
enabled: plan.forSale,
|
||||
productName: plan.productName ?? null,
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt,
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ export async function GET(request: NextRequest) {
|
||||
validityDays: plan.validityDays,
|
||||
validityUnit: plan.validityUnit,
|
||||
features: plan.features ? JSON.parse(plan.features) : [],
|
||||
productName: plan.productName ?? null,
|
||||
platform: group?.platform ?? null,
|
||||
rateMultiplier: group?.rate_multiplier ?? null,
|
||||
limits: groupInfo,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { queryMethodLimits } from '@/lib/order/limits';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
import { resolveLocale } from '@/lib/locale';
|
||||
import { getSystemConfig } from '@/lib/system-config';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
|
||||
@@ -40,7 +41,12 @@ export async function GET(request: NextRequest) {
|
||||
const env = getEnv();
|
||||
initPaymentProviders();
|
||||
const enabledTypes = paymentRegistry.getSupportedTypes();
|
||||
const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(enabledTypes)]);
|
||||
const [user, methodLimits, balanceDisabledVal] = await Promise.all([
|
||||
getUser(userId),
|
||||
queryMethodLimits(enabledTypes),
|
||||
getSystemConfig('BALANCE_PAYMENT_DISABLED'),
|
||||
]);
|
||||
const balanceDisabled = balanceDisabledVal === 'true';
|
||||
|
||||
// 收集 sublabel 覆盖
|
||||
const sublabelOverrides: Record<string, string> = {};
|
||||
@@ -84,6 +90,7 @@ export async function GET(request: NextRequest) {
|
||||
helpText: env.PAY_HELP_TEXT ?? null,
|
||||
stripePublishableKey:
|
||||
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null,
|
||||
balanceDisabled,
|
||||
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ interface AppConfig {
|
||||
helpImageUrl?: string | null;
|
||||
helpText?: string | null;
|
||||
stripePublishableKey?: string | null;
|
||||
balanceDisabled?: boolean;
|
||||
}
|
||||
|
||||
function PayContent() {
|
||||
@@ -123,6 +124,8 @@ function PayContent() {
|
||||
const MAX_PENDING = 3;
|
||||
const pendingBlocked = pendingCount >= MAX_PENDING;
|
||||
|
||||
// R6: 余额充值是否被禁用
|
||||
const balanceDisabled = config.balanceDisabled === true;
|
||||
// 是否有渠道配置(决定是直接显示充值表单还是渠道卡片+弹窗)
|
||||
const hasChannels = channels.length > 0;
|
||||
// 是否有可售卖套餐
|
||||
@@ -196,6 +199,7 @@ function PayContent() {
|
||||
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);
|
||||
@@ -463,7 +467,9 @@ function PayContent() {
|
||||
};
|
||||
|
||||
// ── 渲染 ──
|
||||
const showMainTabs = channelsLoaded && (hasChannels || hasPlans);
|
||||
// R7: 检查是否所有入口都关闭
|
||||
const allEntriesClosed = channelsLoaded && balanceDisabled && !hasPlans;
|
||||
const showMainTabs = channelsLoaded && !allEntriesClosed && (hasChannels || hasPlans);
|
||||
const pageTitle = showMainTabs
|
||||
? pickLocaleText(locale, '选择适合你的 订阅套餐', 'Choose Your Plan')
|
||||
: pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge');
|
||||
@@ -576,12 +582,33 @@ function PayContent() {
|
||||
</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 has been closed by admin')}
|
||||
</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={mainTab} onTabChange={setMainTab} showSubscribeTab={hasPlans} isDark={isDark} locale={locale} />
|
||||
<MainTabs activeTab={balanceDisabled ? 'subscribe' : mainTab} onTabChange={setMainTab} showSubscribeTab={hasPlans} showTopUpTab={!balanceDisabled} isDark={isDark} locale={locale} />
|
||||
|
||||
{mainTab === 'topup' && (
|
||||
{mainTab === 'topup' && !balanceDisabled && (
|
||||
<div className="mt-6">
|
||||
{/* 按量付费说明 banner */}
|
||||
<div className={[
|
||||
@@ -766,7 +793,7 @@ function PayContent() {
|
||||
)}
|
||||
|
||||
{/* ── 无渠道配置:传统充值UI ── */}
|
||||
{channelsLoaded && !showMainTabs && config.enabledPaymentTypes.length > 0 && !selectedPlan && (
|
||||
{channelsLoaded && !showMainTabs && !balanceDisabled && config.enabledPaymentTypes.length > 0 && !selectedPlan && (
|
||||
<>
|
||||
{isMobile ? (
|
||||
activeMobileTab === 'pay' ? (
|
||||
|
||||
Reference in New Issue
Block a user