Files
sub2apipay/src/app/admin/subscriptions/page.tsx
erio bd1db1efd8 feat: 套餐分组清理 + 续费延期 + UI统一
- Schema: groupId 改为 nullable,新增迁移
- GET 套餐列表自动检测并清除 Sub2API 中已删除的分组绑定
- PUT 保存时校验分组存在性,已删除则自动解绑并返回 409
- 续费逻辑:同分组有活跃订阅时从到期日计算天数再 createAndRedeem
- 提取 PlanInfoDisplay 共享组件,SubscriptionConfirm 复用
- 默认模型统一到 /v1/messages badge 内
- 前端编辑表单适配 nullable groupId,未绑定时禁用保存
2026-03-14 05:06:36 +08:00

1519 lines
58 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, useCallback, Suspense } from 'react';
import PayPageLayout from '@/components/PayPageLayout';
import { resolveLocale, type Locale } from '@/lib/locale';
import { PlatformBadge } from '@/lib/platform-style';
/* ---------- types ---------- */
interface SubscriptionPlan {
id: string;
name: string;
description: string | null;
price: number;
originalPrice: number | null;
validDays: number;
validityUnit: 'day' | 'week' | 'month';
features: string[];
groupId: string | null;
groupName: string | null;
sortOrder: number;
enabled: boolean;
groupExists: boolean;
groupPlatform: string | null;
groupRateMultiplier: number | null;
groupDailyLimit: number | null;
groupWeeklyLimit: number | null;
groupMonthlyLimit: number | null;
groupModelScopes: string[] | null;
productName: string | null;
groupAllowMessagesDispatch: boolean;
groupDefaultMappedModel: string | null;
}
interface Sub2ApiGroup {
id: string;
name: string;
subscription_type: string;
daily_limit_usd: number | null;
weekly_limit_usd: number | null;
monthly_limit_usd: number | null;
platform: string | null;
rate_multiplier: number | null;
allow_messages_dispatch: boolean;
default_mapped_model: string | null;
}
interface Sub2ApiSubscription {
id: number;
user_id: number;
group_id: number;
starts_at: string;
expires_at: string;
status: string;
daily_usage_usd: number;
weekly_usage_usd: number;
monthly_usage_usd: number;
daily_window_start: string | null;
weekly_window_start: string | null;
monthly_window_start: string | null;
notes: string | null;
}
interface SubsUserInfo {
id: number;
username: string;
email: string;
}
/* ---------- i18n ---------- */
function buildText(locale: Locale) {
return locale === 'en'
? {
missingToken: 'Missing admin token',
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
invalidToken: 'Invalid admin token',
requestFailed: 'Request failed',
title: 'Subscription Management',
subtitle: 'Manage subscription plans and user subscriptions',
orders: 'Order Management',
dashboard: 'Dashboard',
refresh: 'Refresh',
loading: 'Loading...',
tabPlans: 'Plan Configuration',
tabSubs: 'User Subscriptions',
newPlan: 'New Plan',
editPlan: 'Edit Plan',
deletePlan: 'Delete Plan',
deleteConfirm: 'Delete this plan?',
save: 'Save',
cancel: 'Cancel',
fieldGroup: 'Sub2API Group',
fieldGroupPlaceholder: 'Select a group',
fieldName: 'Plan Name',
fieldDescription: 'Description',
fieldPrice: 'Price (CNY)',
fieldOriginalPrice: 'Original Price (CNY)',
fieldValidDays: 'Validity',
fieldValidUnit: 'Unit',
unitDay: 'Day(s)',
unitWeek: 'Week(s)',
unitMonth: 'Month(s)',
fieldFeatures: 'Features (one per line)',
fieldSortOrder: 'Sort Order',
fieldEnabled: 'For Sale',
colName: 'Name',
colGroup: 'Group ID',
colPrice: 'Price',
colOriginalPrice: 'Original Price',
colValidDays: 'Validity',
colEnabled: 'For Sale',
colGroupStatus: 'Sub2API Status',
colActions: 'Actions',
edit: 'Edit',
delete: 'Delete',
enabled: 'Yes',
disabled: 'No',
groupExists: 'Exists',
groupMissing: 'Missing',
noPlans: 'No plans configured',
searchUserId: 'Email / Username / Notes / API Key',
search: 'Search',
noSubs: 'No subscription records found',
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',
days: 'days',
user: 'User',
group: 'Group',
usage: 'Usage',
expiresAt: 'Expires At',
status: 'Status',
active: 'Active',
expired: 'Expired',
suspended: 'Suspended',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
remaining: 'remaining',
unlimited: 'Unlimited',
resetIn: 'Reset in',
noGroup: 'Unknown Group',
groupInfo: 'Sub2API Group Info',
groupInfoReadonly: '(read-only, from Sub2API)',
platform: 'Platform',
rateMultiplier: 'Rate',
dailyLimit: 'Daily Limit',
weeklyLimit: 'Weekly Limit',
monthlyLimit: 'Monthly Limit',
modelScopes: 'Models',
}
: {
missingToken: '缺少管理员凭证',
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
invalidToken: '管理员凭证无效',
requestFailed: '请求失败',
title: '订阅管理',
subtitle: '管理订阅套餐与用户订阅',
orders: '订单管理',
dashboard: '数据概览',
refresh: '刷新',
loading: '加载中...',
tabPlans: '套餐配置',
tabSubs: '用户订阅',
newPlan: '新建套餐',
editPlan: '编辑套餐',
deletePlan: '删除套餐',
deleteConfirm: '确认删除该套餐?',
save: '保存',
cancel: '取消',
fieldGroup: 'Sub2API 分组',
fieldGroupPlaceholder: '请选择分组',
fieldName: '套餐名称',
fieldDescription: '描述',
fieldPrice: '价格(元)',
fieldOriginalPrice: '原价(元)',
fieldValidDays: '有效期',
fieldValidUnit: '单位',
unitDay: '天',
unitWeek: '周',
unitMonth: '月',
fieldFeatures: '特性描述(每行一个)',
fieldSortOrder: '排序',
fieldEnabled: '启用售卖',
colName: '名称',
colGroup: '分组 ID',
colPrice: '价格',
colOriginalPrice: '原价',
colValidDays: '有效期',
colEnabled: '启用售卖',
colGroupStatus: 'Sub2API 状态',
colActions: '操作',
edit: '编辑',
delete: '删除',
enabled: '是',
disabled: '否',
groupExists: '存在',
groupMissing: '缺失',
noPlans: '暂无套餐配置',
searchUserId: '邮箱/用户名/备注/API Key',
search: '搜索',
noSubs: '未找到订阅记录',
enterUserId: '输入关键词搜索用户',
fieldProductName: '支付商品名称',
fieldProductNamePlaceholder: '留空使用默认名称',
saveFailed: '保存套餐失败',
deleteFailed: '删除套餐失败',
loadFailed: '加载数据失败',
days: '天',
user: '用户',
group: '分组',
usage: '用量',
expiresAt: '到期时间',
status: '状态',
active: '生效中',
expired: '已过期',
suspended: '已暂停',
daily: '日用量',
weekly: '周用量',
monthly: '月用量',
remaining: '剩余',
unlimited: '无限制',
resetIn: '重置于',
noGroup: '未知分组',
groupInfo: 'Sub2API 分组信息',
groupInfoReadonly: '(只读,来自 Sub2API',
platform: '平台',
rateMultiplier: '倍率',
dailyLimit: '日限额',
weeklyLimit: '周限额',
monthlyLimit: '月限额',
modelScopes: '模型',
};
}
/* ---------- helpers ---------- */
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
function daysRemaining(expiresAt: string | null): number | null {
if (!expiresAt) return null;
const now = new Date();
const exp = new Date(expiresAt);
const diff = exp.getTime() - now.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
function resetCountdown(windowStart: string | null, periodDays: number): string | null {
if (!windowStart) return null;
const start = new Date(windowStart);
const resetAt = new Date(start.getTime() + periodDays * 24 * 60 * 60 * 1000);
const now = new Date();
const diffMs = resetAt.getTime() - now.getTime();
if (diffMs <= 0) return null;
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours >= 24) {
const d = Math.floor(hours / 24);
const h = hours % 24;
return `${d}d ${h}h`;
}
return `${hours}h ${minutes}m`;
}
/* ---------- UsageBar component ---------- */
function UsageBar({
label,
usage,
limit,
resetText,
isDark,
}: {
label: string;
usage: number;
limit: number | null;
resetText: string | null;
isDark: boolean;
}) {
const pct = limit && limit > 0 ? Math.min((usage / limit) * 100, 100) : 0;
const barColor = pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-yellow-500' : 'bg-green-500';
return (
<div className="mb-1.5 last:mb-0">
<div className="flex items-center justify-between text-xs">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>{label}</span>
<span className={isDark ? 'text-slate-300' : 'text-slate-600'}>
${usage.toFixed(2)} {limit != null ? `/ $${limit.toFixed(2)}` : ''}
</span>
</div>
{limit != null && limit > 0 ? (
<div className={`mt-0.5 h-1.5 w-full rounded-full ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`}>
<div className={`h-full rounded-full transition-all ${barColor}`} style={{ width: `${pct}%` }} />
</div>
) : null}
{resetText && (
<div className={`mt-0.5 text-[10px] ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>{resetText}</div>
)}
</div>
);
}
/* ---------- main content ---------- */
function SubscriptionsContent() {
const searchParams = useSearchParams();
const token = searchParams.get('token') || '';
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const isEmbedded = uiMode === 'embedded';
const t = buildText(locale);
/* --- shared state --- */
const [activeTab, setActiveTab] = useState<'plans' | 'subs'>('plans');
const [error, setError] = useState('');
/* --- plans state --- */
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [groups, setGroups] = useState<Sub2ApiGroup[]>([]);
const [plansLoading, setPlansLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
/* form state */
const [formGroupId, setFormGroupId] = useState('');
const [formName, setFormName] = useState('');
const [formDescription, setFormDescription] = useState('');
const [formPrice, setFormPrice] = useState('');
const [formOriginalPrice, setFormOriginalPrice] = useState('');
const [formValidDays, setFormValidDays] = useState('30');
const [formValidUnit, setFormValidUnit] = useState<'day' | 'week' | 'month'>('day');
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);
const [subsSearched, setSubsSearched] = useState(false);
/* --- fetch plans --- */
const fetchPlans = useCallback(async () => {
if (!token) return;
setPlansLoading(true);
try {
const res = await fetch(`/api/admin/subscription-plans?token=${encodeURIComponent(token)}`);
if (!res.ok) {
if (res.status === 401) {
setError(t.invalidToken);
return;
}
throw new Error(t.requestFailed);
}
const data = await res.json();
setPlans(Array.isArray(data) ? data : (data.plans ?? []));
} catch {
setError(t.loadFailed);
} finally {
setPlansLoading(false);
}
}, [token]);
/* --- fetch groups --- */
const fetchGroups = useCallback(async () => {
if (!token) return;
try {
const res = await fetch(`/api/admin/sub2api/groups?token=${encodeURIComponent(token)}`);
if (res.ok) {
const data = await res.json();
setGroups(Array.isArray(data) ? data : (data.groups ?? []));
}
} catch {
/* ignore */
}
}, [token]);
useEffect(() => {
fetchPlans();
fetchGroups();
}, [fetchPlans, fetchGroups]);
/* auto-fetch subs when switching to subs tab */
useEffect(() => {
if (activeTab === 'subs' && !subsSearched) {
fetchSubs();
}
}, [activeTab]);
/* --- modal helpers --- */
const openCreate = () => {
setEditingPlan(null);
setFormGroupId('');
setFormName('');
setFormDescription('');
setFormPrice('');
setFormOriginalPrice('');
setFormValidDays('1');
setFormValidUnit('month');
setFormFeatures('');
setFormSortOrder('0');
setFormEnabled(true);
setFormProductName('');
setModalOpen(true);
};
const openEdit = (plan: SubscriptionPlan) => {
setEditingPlan(plan);
setFormGroupId(plan.groupId ?? '');
setFormName(plan.name);
setFormDescription(plan.description ?? '');
setFormPrice(String(plan.price));
setFormOriginalPrice(plan.originalPrice != null ? String(plan.originalPrice) : '');
setFormValidDays(String(plan.validDays));
setFormValidUnit(plan.validityUnit ?? 'day');
setFormFeatures((plan.features ?? []).join('\n'));
setFormSortOrder(String(plan.sortOrder));
setFormEnabled(plan.enabled);
setFormProductName(plan.productName ?? '');
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditingPlan(null);
};
/* --- save plan (snake_case for backend) --- */
const handleSave = async () => {
if (!formName.trim() || !formPrice || !formGroupId) return;
setSaving(true);
setError('');
const body = {
group_id: Number(formGroupId),
name: formName.trim(),
description: formDescription.trim() || null,
price: parseFloat(formPrice),
original_price: formOriginalPrice ? parseFloat(formOriginalPrice) : null,
validity_days: parseInt(formValidDays, 10) || 30,
validity_unit: formValidUnit,
features: formFeatures
.split('\n')
.map((l) => l.trim())
.filter(Boolean),
sort_order: parseInt(formSortOrder, 10) || 0,
for_sale: formEnabled,
product_name: formProductName.trim() || null,
};
try {
const url = editingPlan ? `/api/admin/subscription-plans/${editingPlan.id}` : '/api/admin/subscription-plans';
const method = editingPlan ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t.saveFailed);
}
closeModal();
fetchPlans();
} catch (e) {
// 分组被删除等错误:刷新列表使前端状态同步
setError(e instanceof Error ? e.message : t.saveFailed);
fetchPlans();
} finally {
setSaving(false);
}
};
/* --- delete plan --- */
const handleDelete = async (plan: SubscriptionPlan) => {
if (!confirm(t.deleteConfirm)) return;
try {
const res = await fetch(`/api/admin/subscription-plans/${plan.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t.deleteFailed);
}
fetchPlans();
} catch (e) {
setError(e instanceof Error ? e.message : t.deleteFailed);
}
};
/* --- toggle plan enabled --- */
const handleToggleEnabled = async (plan: SubscriptionPlan) => {
try {
const res = await fetch(`/api/admin/subscription-plans/${plan.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ for_sale: !plan.enabled }),
});
if (res.ok) {
setPlans((prev) => prev.map((p) => (p.id === plan.id ? { ...p, enabled: !p.enabled } : p)));
}
} catch {
/* ignore */
}
};
/* --- search users (R1) --- */
const handleKeywordChange = (value: string) => {
setSubsKeyword(value);
if (searchTimer) clearTimeout(searchTimer);
if (!value.trim()) {
setSubsUserId('');
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) return;
setSubsLoading(true);
setSubsSearched(true);
setSubsUser(null);
try {
const qs = new URLSearchParams({ token });
if (subsUserId.trim()) qs.set('user_id', subsUserId.trim());
const res = await fetch(`/api/admin/subscriptions?${qs}`);
if (!res.ok) {
if (res.status === 401) {
setError(t.invalidToken);
return;
}
throw new Error(t.requestFailed);
}
const data = await res.json();
setSubs(data.subscriptions ?? []);
setSubsUser(data.user ?? null);
} catch {
setError(t.loadFailed);
} finally {
setSubsLoading(false);
}
};
/* --- no token guard --- */
if (!token) {
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">{t.missingToken}</p>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{t.missingTokenHint}</p>
</div>
</div>
);
}
/* --- nav params --- */
const navParams = new URLSearchParams();
navParams.set('token', token);
if (locale === 'en') navParams.set('lang', 'en');
if (isDark) navParams.set('theme', 'dark');
if (isEmbedded) navParams.set('ui_mode', 'embedded');
const btnBase = [
'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(' ');
/* available groups for the form: only subscription type, exclude already used */
const subscriptionGroups = groups.filter((g) => g.subscription_type === 'subscription');
const usedGroupIds = new Set(
plans.filter((p) => p.id !== editingPlan?.id && p.groupId != null).map((p) => p.groupId!),
);
const availableGroups = subscriptionGroups.filter((g) => !usedGroupIds.has(String(g.id)));
/* group id → name map (all groups, for subscription display) */
const groupNameMap = new Map(groups.map((g) => [String(g.id), g.name]));
/* --- tab classes --- */
const tabCls = (active: boolean) =>
[
'flex-1 rounded-lg py-2 text-center text-sm font-medium transition-colors cursor-pointer',
active
? isDark
? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40'
: 'bg-blue-600 text-white'
: isDark
? 'text-slate-400 hover:text-slate-200'
: 'text-slate-600 hover:text-slate-800',
].join(' ');
/* --- table cell style --- */
const thCls = [
'px-4 py-3 text-left text-xs font-medium uppercase tracking-wider',
isDark ? 'text-slate-400' : 'text-slate-500',
].join(' ');
const tdCls = ['px-4 py-3 text-sm', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ');
const tableWrapCls = [
'overflow-x-auto rounded-xl border',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
].join(' ');
const rowBorderCls = isDark ? 'border-slate-700/50' : 'border-slate-100';
/* --- input classes --- */
const inputCls = [
'w-full rounded-lg border px-3 py-2 text-sm outline-none transition-colors',
isDark
? 'border-slate-600 bg-slate-700 text-slate-200 focus:border-indigo-400'
: 'border-slate-300 bg-white text-slate-800 focus:border-blue-500',
].join(' ');
const labelCls = ['block text-sm font-medium mb-1', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ');
/* --- status badge --- */
const statusBadge = (status: string) => {
const map: Record<string, { label: string; cls: string }> = {
active: {
label: t.active,
cls: isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-50 text-green-700',
},
expired: {
label: t.expired,
cls: isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-600',
},
suspended: {
label: t.suspended,
cls: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-50 text-yellow-700',
},
};
const info = map[status] ?? {
label: status,
cls: isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-100 text-gray-500',
};
return (
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${info.cls}`}>{info.label}</span>
);
};
return (
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth="full"
title={t.title}
subtitle={t.subtitle}
locale={locale}
actions={
<>
<a href={`/admin/orders?${navParams}`} className={btnBase}>
{t.orders}
</a>
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
{t.dashboard}
</a>
<button
type="button"
onClick={() => {
if (activeTab === 'plans') fetchPlans();
if (activeTab === 'subs' && subsSearched) fetchSubs();
}}
className={btnBase}
>
{t.refresh}
</button>
</>
}
>
{/* Error banner */}
{error && (
<div
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
>
{error}
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
</button>
</div>
)}
{/* Tab switcher */}
<div className={['mb-5 flex gap-1 rounded-xl p-1', isDark ? 'bg-slate-800' : 'bg-slate-100'].join(' ')}>
<button type="button" className={tabCls(activeTab === 'plans')} onClick={() => setActiveTab('plans')}>
{t.tabPlans}
</button>
<button type="button" className={tabCls(activeTab === 'subs')} onClick={() => setActiveTab('subs')}>
{t.tabSubs}
</button>
</div>
{/* ====== Tab: Plan Configuration ====== */}
{activeTab === 'plans' && (
<>
{/* New plan button */}
<div className="mb-4 flex justify-end">
<button
type="button"
onClick={openCreate}
className={[
'inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium transition-colors',
isDark
? 'bg-indigo-500/30 text-indigo-200 hover:bg-indigo-500/40'
: 'bg-blue-600 text-white hover:bg-blue-700',
].join(' ')}
>
+ {t.newPlan}
</button>
</div>
{/* Plans cards */}
{plansLoading ? (
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
) : plans.length === 0 ? (
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.noPlans}</div>
) : (
<div className="space-y-4">
{plans.map((plan) => (
<div
key={plan.id}
className={[
'rounded-xl border overflow-hidden',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
{/* ── 套餐配置(上半部分) ── */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h3
className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(
' ',
)}
>
{plan.name}
</h3>
<span
className={[
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
plan.groupExists
? isDark
? 'bg-green-500/20 text-green-300'
: 'bg-green-50 text-green-700'
: isDark
? 'bg-red-500/20 text-red-300'
: 'bg-red-50 text-red-600',
].join(' ')}
>
{plan.groupExists ? t.groupExists : t.groupMissing}
</span>
</div>
<div className="flex items-center gap-3">
{/* Toggle */}
<div className="flex items-center gap-1.5">
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{t.colEnabled}
</span>
<button
type="button"
onClick={() => handleToggleEnabled(plan)}
className={[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
plan.enabled ? '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',
plan.enabled ? 'translate-x-4.5' : 'translate-x-0.5',
].join(' ')}
/>
</button>
</div>
{/* Actions */}
<button
type="button"
onClick={() => openEdit(plan)}
className={[
'rounded px-2 py-1 text-xs font-medium transition-colors',
isDark ? 'text-indigo-300 hover:bg-indigo-500/20' : 'text-blue-600 hover:bg-blue-50',
].join(' ')}
>
{t.edit}
</button>
<button
type="button"
onClick={() => handleDelete(plan)}
className={[
'rounded px-2 py-1 text-xs font-medium transition-colors',
isDark ? 'text-red-400 hover:bg-red-500/20' : 'text-red-600 hover:bg-red-50',
].join(' ')}
>
{t.delete}
</button>
</div>
</div>
{/* Plan fields grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 text-sm">
<div>
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{t.colGroup}
</span>
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
{plan.groupId ? (
<>
<span className="font-mono text-xs">{plan.groupId}</span>
{plan.groupName && (
<span className={`ml-1 text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
({plan.groupName})
</span>
)}
</>
) : (
<span className={`text-xs ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`}>
{locale === 'en' ? 'Unbound' : '未绑定'}
</span>
)}
</div>
</div>
<div>
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{t.colPrice}
</span>
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
¥{plan.price.toFixed(2)}
{plan.originalPrice != null && (
<span
className={`ml-1 line-through text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}
>
¥{plan.originalPrice.toFixed(2)}
</span>
)}
</div>
</div>
<div>
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{t.colValidDays}
</span>
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>
{plan.validDays}{' '}
{plan.validityUnit === 'month'
? t.unitMonth
: plan.validityUnit === 'week'
? t.unitWeek
: t.unitDay}
</div>
</div>
<div>
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{t.fieldSortOrder}
</span>
<div className={isDark ? 'text-slate-200' : 'text-slate-800'}>{plan.sortOrder}</div>
</div>
</div>
</div>
{/* ── Sub2API 分组信息(嵌套只读区域) ── */}
{plan.groupExists && (
<div
className={[
'border-t px-4 py-3',
isDark ? 'border-slate-700 bg-slate-900/40' : 'border-slate-100 bg-slate-50/80',
].join(' ')}
>
<div className="flex items-center gap-2 mb-2">
<span
className={['text-xs font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}
>
{t.groupInfo}
</span>
<span className={['text-[10px]', isDark ? 'text-slate-600' : 'text-slate-400'].join(' ')}>
{t.groupInfoReadonly}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 text-xs">
{plan.groupPlatform && (
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.platform}</span>
<div className="mt-0.5">
<PlatformBadge platform={plan.groupPlatform} />
</div>
</div>
)}
{plan.groupRateMultiplier != null && (
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.rateMultiplier}</span>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{plan.groupRateMultiplier}x
</div>
</div>
)}
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.dailyLimit}</span>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{plan.groupDailyLimit != null ? `$${plan.groupDailyLimit}` : t.unlimited}
</div>
</div>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.weeklyLimit}</span>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{plan.groupWeeklyLimit != null ? `$${plan.groupWeeklyLimit}` : t.unlimited}
</div>
</div>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.monthlyLimit}</span>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{plan.groupMonthlyLimit != null ? `$${plan.groupMonthlyLimit}` : t.unlimited}
</div>
</div>
{plan.groupPlatform?.toLowerCase() === 'openai' && (
<>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>/v1/messages </span>
<div
className={[
'mt-0.5 text-xs font-medium',
plan.groupAllowMessagesDispatch
? isDark
? 'text-green-400'
: 'text-green-600'
: isDark
? 'text-slate-400'
: 'text-slate-500',
].join(' ')}
>
{plan.groupAllowMessagesDispatch ? '已启用' : '未启用'}
</div>
</div>
{plan.groupDefaultMappedModel && (
<div className="sm:col-span-2">
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}></span>
<div
className={[
'mt-0.5 font-mono text-xs',
isDark ? 'text-slate-300' : 'text-slate-600',
].join(' ')}
>
{plan.groupDefaultMappedModel}
</div>
</div>
)}
</>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</>
)}
{/* ====== Tab: User Subscriptions ====== */}
{activeTab === 'subs' && (
<>
{/* Search bar (R1: fuzzy search) */}
<div className="mb-4 flex gap-2">
<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={() => {
setSearchDropdownOpen(false);
fetchSubs();
}}
disabled={subsLoading}
className={[
'inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
isDark
? 'bg-indigo-500/30 text-indigo-200 hover:bg-indigo-500/40'
: 'bg-blue-600 text-white hover:bg-blue-700',
].join(' ')}
>
{t.search}
</button>
</div>
{/* User info card */}
{subsUser && (
<div
className={[
'mb-4 flex items-center gap-3 rounded-xl border p-3',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<div
className={[
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold',
isDark ? 'bg-indigo-500/30 text-indigo-200' : 'bg-blue-100 text-blue-700',
].join(' ')}
>
{(subsUser.email?.[0] ?? subsUser.username?.[0] ?? '?').toUpperCase()}
</div>
<div>
<div className={`text-sm font-medium ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>
{subsUser.username}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{subsUser.email}</div>
</div>
<div className={`ml-auto text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>ID: {subsUser.id}</div>
</div>
)}
{/* Subs list */}
<div className={tableWrapCls}>
{subsLoading ? (
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
) : !subsSearched ? (
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
) : subs.length === 0 ? (
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.noSubs}</div>
) : (
<table className="w-full">
<thead>
<tr className={`border-b ${rowBorderCls}`}>
<th className={thCls}>{t.group}</th>
<th className={thCls}>{t.status}</th>
<th className={thCls}>{t.usage}</th>
<th className={thCls}>{t.expiresAt}</th>
</tr>
</thead>
<tbody>
{subs.map((sub) => {
const gName = groupNameMap.get(String(sub.group_id)) ?? t.noGroup;
const remaining = daysRemaining(sub.expires_at);
const group = groups.find((g) => String(g.id) === String(sub.group_id));
const dailyLimit = group?.daily_limit_usd ?? null;
const weeklyLimit = group?.weekly_limit_usd ?? null;
const monthlyLimit = group?.monthly_limit_usd ?? null;
return (
<tr key={sub.id} className={`border-b ${rowBorderCls} last:border-b-0`}>
{/* Group */}
<td className={tdCls}>
<div className="flex items-center gap-1.5">
<span
className={`inline-block h-2 w-2 rounded-full ${sub.status === 'active' ? 'bg-green-500' : 'bg-slate-400'}`}
/>
<span className="font-medium">{gName}</span>
</div>
<div className={`mt-0.5 text-xs font-mono ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
ID: {sub.group_id}
</div>
</td>
{/* Status */}
<td className={tdCls}>{statusBadge(sub.status)}</td>
{/* Usage */}
<td className={`${tdCls} min-w-[200px]`}>
<UsageBar
label={t.daily}
usage={sub.daily_usage_usd}
limit={dailyLimit}
resetText={
sub.daily_window_start
? `${t.resetIn} ${resetCountdown(sub.daily_window_start, 1) ?? '-'}`
: null
}
isDark={isDark}
/>
<UsageBar
label={t.weekly}
usage={sub.weekly_usage_usd}
limit={weeklyLimit}
resetText={
sub.weekly_window_start
? `${t.resetIn} ${resetCountdown(sub.weekly_window_start, 7) ?? '-'}`
: null
}
isDark={isDark}
/>
<UsageBar
label={t.monthly}
usage={sub.monthly_usage_usd}
limit={monthlyLimit}
resetText={
sub.monthly_window_start
? `${t.resetIn} ${resetCountdown(sub.monthly_window_start, 30) ?? '-'}`
: null
}
isDark={isDark}
/>
</td>
{/* Expires */}
<td className={tdCls}>
<div>{formatDate(sub.expires_at)}</div>
{remaining != null && (
<div
className={`mt-0.5 text-xs ${
remaining <= 0
? 'text-red-500'
: remaining <= 7
? 'text-yellow-500'
: isDark
? 'text-slate-400'
: 'text-slate-500'
}`}
>
{remaining > 0 ? `${remaining} ${t.days} ${t.remaining}` : t.expired}
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</>
)}
{/* ====== Edit / Create Modal ====== */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
className={[
'w-full max-w-lg rounded-2xl border p-6 shadow-xl',
isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white',
].join(' ')}
>
<h2 className={['mb-5 text-lg font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{editingPlan ? t.editPlan : t.newPlan}
</h2>
<div className="max-h-[65vh] space-y-4 overflow-y-auto pr-1">
{/* Group */}
<div>
<label className={labelCls}>{t.fieldGroup}</label>
<select value={formGroupId} onChange={(e) => setFormGroupId(e.target.value)} className={inputCls}>
<option value="">{t.fieldGroupPlaceholder}</option>
{availableGroups.map((g) => (
<option key={g.id} value={g.id}>
{g.name} ({g.id})
</option>
))}
{/* If editing, ensure the current group is always visible (only if still bound) */}
{editingPlan &&
editingPlan.groupId &&
!availableGroups.some((g) => String(g.id) === editingPlan.groupId) && (
<option value={editingPlan.groupId}>
{editingPlan.groupName ?? editingPlan.groupId} ({editingPlan.groupId})
</option>
)}
</select>
</div>
{/* Selected group info card (read-only) */}
{(() => {
const selectedGroup = groups.find((g) => String(g.id) === formGroupId);
if (!selectedGroup) return null;
return (
<div
className={[
'rounded-lg border p-3 text-xs',
isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<div className="flex items-center gap-2 mb-2">
<span className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{t.groupInfo}
</span>
<span className={['text-[10px]', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{t.groupInfoReadonly}
</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{selectedGroup.platform && (
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.platform}</span>
<div className="mt-0.5">
<PlatformBadge platform={selectedGroup.platform} />
</div>
</div>
)}
{selectedGroup.rate_multiplier != null && (
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.rateMultiplier}</span>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{selectedGroup.rate_multiplier}x
</div>
</div>
)}
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.dailyLimit}</span>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{selectedGroup.daily_limit_usd != null ? `$${selectedGroup.daily_limit_usd}` : t.unlimited}
</div>
</div>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.weeklyLimit}</span>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{selectedGroup.weekly_limit_usd != null ? `$${selectedGroup.weekly_limit_usd}` : t.unlimited}
</div>
</div>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.monthlyLimit}</span>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{selectedGroup.monthly_limit_usd != null
? `$${selectedGroup.monthly_limit_usd}`
: t.unlimited}
</div>
</div>
{selectedGroup.platform?.toLowerCase() === 'openai' && (
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>/v1/messages </span>
<div
className={[
'mt-0.5 font-medium',
selectedGroup.allow_messages_dispatch
? isDark
? 'text-green-400'
: 'text-green-600'
: isDark
? 'text-slate-400'
: 'text-slate-500',
].join(' ')}
>
{selectedGroup.allow_messages_dispatch ? '已启用' : '未启用'}
</div>
</div>
)}
</div>
</div>
);
})()}
{/* Name */}
<div>
<label className={labelCls}>{t.fieldName} *</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
className={inputCls}
required
/>
</div>
{/* Description */}
<div>
<label className={labelCls}>{t.fieldDescription}</label>
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
rows={2}
className={inputCls}
/>
</div>
{/* Price */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>{t.fieldPrice} *</label>
<input
type="number"
step="0.01"
min="0.01"
max="99999999.99"
value={formPrice}
onChange={(e) => setFormPrice(e.target.value)}
className={inputCls}
required
/>
</div>
<div>
<label className={labelCls}>{t.fieldOriginalPrice}</label>
<input
type="number"
step="0.01"
min="0.01"
max="99999999.99"
value={formOriginalPrice}
onChange={(e) => setFormOriginalPrice(e.target.value)}
className={inputCls}
/>
</div>
</div>
{/* Valid days + Unit */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>{t.fieldValidDays}</label>
<input
type="number"
min="1"
value={formValidDays}
onChange={(e) => setFormValidDays(e.target.value)}
className={inputCls}
/>
</div>
<div>
<label className={labelCls}>{t.fieldValidUnit}</label>
<select
value={formValidUnit}
onChange={(e) => setFormValidUnit(e.target.value as 'day' | 'week' | 'month')}
className={inputCls}
>
<option value="day">{t.unitDay}</option>
<option value="week">{t.unitWeek}</option>
<option value="month">{t.unitMonth}</option>
</select>
</div>
</div>
{/* Sort Order */}
<div>
<label className={labelCls}>{t.fieldSortOrder}</label>
<input
type="number"
value={formSortOrder}
onChange={(e) => setFormSortOrder(e.target.value)}
className={inputCls}
/>
</div>
{/* Features */}
<div>
<label className={labelCls}>{t.fieldFeatures}</label>
<textarea
value={formFeatures}
onChange={(e) => setFormFeatures(e.target.value)}
rows={4}
className={inputCls}
/>
</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
type="button"
onClick={() => setFormEnabled(!formEnabled)}
className={[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
formEnabled ? 'bg-emerald-500' : isDark ? 'bg-slate-600' : 'bg-slate-300',
].join(' ')}
>
<span
className={[
'inline-block h-4 w-4 rounded-full bg-white transition-transform',
formEnabled ? 'translate-x-6' : 'translate-x-1',
].join(' ')}
/>
</button>
<span className={['text-sm', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
{t.fieldEnabled}
</span>
</div>
</div>
{/* Modal actions */}
<div className="mt-5 flex justify-end gap-3">
<button
type="button"
onClick={closeModal}
className={[
'rounded-lg border px-4 py-2 text-sm font-medium transition-colors',
isDark
? 'border-slate-600 text-slate-300 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-50',
].join(' ')}
>
{t.cancel}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving || !formName.trim() || !formPrice || !formGroupId}
className={[
'rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
isDark
? 'bg-indigo-500/30 text-indigo-200 hover:bg-indigo-500/40'
: 'bg-blue-600 text-white hover:bg-blue-700',
].join(' ')}
>
{saving ? t.loading : t.save}
</button>
</div>
</div>
</div>
)}
{/* ====== Extend Confirmation Modal ====== */}
</PayPageLayout>
);
}
/* ---------- fallback + export ---------- */
function SubscriptionsPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
export default function SubscriptionsPage() {
return (
<Suspense fallback={<SubscriptionsPageFallback />}>
<SubscriptionsContent />
</Suspense>
);
}