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

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
docs/refrence/subscribe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

BIN
docs/refrence/top-up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@@ -0,0 +1,66 @@
-- CreateTable: channels
CREATE TABLE "channels" (
"id" TEXT NOT NULL,
"group_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"platform" TEXT NOT NULL DEFAULT 'claude',
"rate_multiplier" DECIMAL(10,4) NOT NULL,
"description" TEXT,
"models" TEXT,
"features" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "channels_pkey" PRIMARY KEY ("id")
);
-- CreateTable: subscription_plans
CREATE TABLE "subscription_plans" (
"id" TEXT NOT NULL,
"group_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"price" DECIMAL(10,2) NOT NULL,
"original_price" DECIMAL(10,2),
"validity_days" INTEGER NOT NULL DEFAULT 30,
"features" TEXT,
"for_sale" BOOLEAN NOT NULL DEFAULT false,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscription_plans_pkey" PRIMARY KEY ("id")
);
-- CreateTable: system_configs
CREATE TABLE "system_configs" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"group" TEXT NOT NULL DEFAULT 'general',
"label" TEXT,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "system_configs_pkey" PRIMARY KEY ("key")
);
-- AlterTable: orders - add subscription fields
ALTER TABLE "orders" ADD COLUMN "order_type" TEXT NOT NULL DEFAULT 'balance';
ALTER TABLE "orders" ADD COLUMN "plan_id" TEXT;
ALTER TABLE "orders" ADD COLUMN "subscription_group_id" INTEGER;
ALTER TABLE "orders" ADD COLUMN "subscription_days" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "channels_group_id_key" ON "channels"("group_id");
CREATE INDEX "channels_sort_order_idx" ON "channels"("sort_order");
CREATE UNIQUE INDEX "subscription_plans_group_id_key" ON "subscription_plans"("group_id");
CREATE INDEX "subscription_plans_for_sale_sort_order_idx" ON "subscription_plans"("for_sale", "sort_order");
CREATE INDEX "system_configs_group_idx" ON "system_configs"("group");
CREATE INDEX "orders_order_type_idx" ON "orders"("order_type");
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "subscription_plans"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -40,6 +40,13 @@ model Order {
srcHost String? @map("src_host")
srcUrl String? @map("src_url")
// ── 订单类型 & 订阅相关 ──
orderType String @default("balance") @map("order_type")
planId String? @map("plan_id")
plan SubscriptionPlan? @relation(fields: [planId], references: [id])
subscriptionGroupId Int? @map("subscription_group_id")
subscriptionDays Int? @map("subscription_days")
auditLogs AuditLog[]
@@index([userId])
@@ -48,6 +55,7 @@ model Order {
@@index([createdAt])
@@index([paidAt])
@@index([paymentType, paidAt])
@@index([orderType])
@@map("orders")
}
@@ -77,3 +85,55 @@ model AuditLog {
@@index([createdAt])
@@map("audit_logs")
}
// ── 渠道展示配置 ──
model Channel {
id String @id @default(cuid())
groupId Int @unique @map("group_id")
name String
platform String @default("claude")
rateMultiplier Decimal @db.Decimal(10, 4) @map("rate_multiplier")
description String? @db.Text
models String? @db.Text
features String? @db.Text
sortOrder Int @default(0) @map("sort_order")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([sortOrder])
@@map("channels")
}
// ── 订阅套餐配置 ──
model SubscriptionPlan {
id String @id @default(cuid())
groupId Int @unique @map("group_id")
name String
description String? @db.Text
price Decimal @db.Decimal(10, 2)
originalPrice Decimal? @db.Decimal(10, 2) @map("original_price")
validityDays Int @default(30) @map("validity_days")
features String? @db.Text
forSale Boolean @default(false) @map("for_sale")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[]
@@index([forSale, sortOrder])
@@map("subscription_plans")
}
// ── 系统配置 ──
model SystemConfig {
key String @id
value String @db.Text
group String @default("general")
label String?
updatedAt DateTime @updatedAt @map("updated_at")
@@index([group])
@@map("system_configs")
}

View File

@@ -0,0 +1,958 @@
'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';
// ── Types ──
interface Channel {
id: string;
groupId: number;
name: string;
platform: string;
rateMultiplier: number;
description: string | null;
models: string | null;
features: string | null;
sortOrder: number;
enabled: boolean;
groupExists: boolean;
createdAt: string;
updatedAt: string;
}
interface Sub2ApiGroup {
id: number;
name: string;
description: string;
platform: string;
status: string;
rate_multiplier: number;
}
interface ChannelFormData {
group_id: number | '';
name: string;
platform: string;
rate_multiplier: string;
description: string;
models: string;
features: string;
sort_order: string;
enabled: boolean;
}
const PLATFORMS = ['claude', 'openai', 'gemini', 'codex', 'sora'] as const;
const PLATFORM_COLORS: Record<string, { bg: string; text: string }> = {
claude: { bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300' },
openai: { bg: 'bg-green-100 dark:bg-green-900/40', text: 'text-green-700 dark:text-green-300' },
gemini: { bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300' },
codex: { bg: 'bg-purple-100 dark:bg-purple-900/40', text: 'text-purple-700 dark:text-purple-300' },
sora: { bg: 'bg-pink-100 dark:bg-pink-900/40', text: 'text-pink-700 dark:text-pink-300' },
};
// ── i18n ──
function getTexts(locale: Locale) {
return locale === 'en'
? {
missingToken: 'Missing admin token',
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
invalidToken: 'Invalid admin token',
title: 'Channel Management',
subtitle: 'Configure and manage subscription channels',
orders: 'Orders',
refresh: 'Refresh',
loading: 'Loading...',
noChannels: 'No channels found',
noChannelsHint: 'Click "Sync from Sub2API" or "New Channel" to get started.',
syncFromSub2Api: 'Sync from Sub2API',
newChannel: 'New Channel',
editChannel: 'Edit Channel',
colName: 'Name',
colPlatform: 'Platform',
colRate: 'Rate',
colSub2ApiStatus: 'Sub2API Status',
colSortOrder: 'Sort',
colEnabled: 'Enabled',
colActions: 'Actions',
edit: 'Edit',
delete: 'Delete',
deleteConfirm: 'Are you sure you want to delete this channel?',
fieldName: 'Channel Name',
fieldPlatform: 'Category',
fieldRate: 'Rate Multiplier',
fieldRateHint: 'e.g. 0.15 means 0.15x',
fieldDescription: 'Description',
fieldModels: 'Supported Models (one per line)',
fieldFeatures: 'Features (one per line)',
fieldSortOrder: 'Sort Order',
fieldEnabled: 'Enable Channel',
fieldGroupId: 'Sub2API Group ID',
cancel: 'Cancel',
save: 'Save',
saving: 'Saving...',
syncTitle: 'Sync from Sub2API',
syncHint: 'Select groups to import as channels',
syncLoading: 'Loading groups...',
syncNoGroups: 'No groups found in Sub2API',
syncAlreadyExists: 'Already imported',
syncImport: 'Import Selected',
syncImporting: 'Importing...',
loadFailed: 'Failed to load channels',
saveFailed: 'Failed to save channel',
deleteFailed: 'Failed to delete channel',
syncFetchFailed: 'Failed to fetch Sub2API groups',
syncImportFailed: 'Failed to import groups',
syncImportSuccess: (n: number) => `Successfully imported ${n} channel(s)`,
yes: 'Yes',
no: 'No',
}
: {
missingToken: '缺少管理员凭证',
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
invalidToken: '管理员凭证无效',
title: '渠道管理',
subtitle: '配置和管理订阅渠道',
orders: '订单管理',
refresh: '刷新',
loading: '加载中...',
noChannels: '暂无渠道',
noChannelsHint: '点击「从 Sub2API 同步」或「新建渠道」开始创建。',
syncFromSub2Api: '从 Sub2API 同步',
newChannel: '新建渠道',
editChannel: '编辑渠道',
colName: '名称',
colPlatform: '平台',
colRate: '倍率',
colSub2ApiStatus: 'Sub2API 状态',
colSortOrder: '排序',
colEnabled: '启用',
colActions: '操作',
edit: '编辑',
delete: '删除',
deleteConfirm: '确定要删除该渠道吗?',
fieldName: '渠道名称',
fieldPlatform: '分类',
fieldRate: '倍率',
fieldRateHint: '如 0.15 表示 0.15 倍',
fieldDescription: '描述',
fieldModels: '支持模型(每行一个)',
fieldFeatures: '功能特性(每行一个)',
fieldSortOrder: '排序',
fieldEnabled: '启用渠道',
fieldGroupId: 'Sub2API 分组 ID',
cancel: '取消',
save: '保存',
saving: '保存中...',
syncTitle: '从 Sub2API 同步',
syncHint: '选择要导入为渠道的分组',
syncLoading: '加载分组中...',
syncNoGroups: 'Sub2API 中没有找到分组',
syncAlreadyExists: '已导入',
syncImport: '导入所选',
syncImporting: '导入中...',
loadFailed: '加载渠道列表失败',
saveFailed: '保存渠道失败',
deleteFailed: '删除渠道失败',
syncFetchFailed: '获取 Sub2API 分组列表失败',
syncImportFailed: '导入分组失败',
syncImportSuccess: (n: number) => `成功导入 ${n} 个渠道`,
yes: '是',
no: '否',
};
}
// ── Helpers ──
function parseJsonArray(value: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function arrayToLines(value: string | null): string {
return parseJsonArray(value).join('\n');
}
function linesToJsonString(lines: string): string {
const arr = lines
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
return JSON.stringify(arr);
}
const emptyForm: ChannelFormData = {
group_id: '',
name: '',
platform: 'claude',
rate_multiplier: '1',
description: '',
models: '',
features: '',
sort_order: '0',
enabled: true,
};
// ── Main Content ──
function ChannelsContent() {
const searchParams = useSearchParams();
const token = searchParams.get('token') || '';
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const isEmbedded = uiMode === 'embedded';
const t = getTexts(locale);
const [channels, setChannels] = useState<Channel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Edit modal state
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
const [form, setForm] = useState<ChannelFormData>(emptyForm);
const [saving, setSaving] = useState(false);
// Sync modal state
const [syncModalOpen, setSyncModalOpen] = useState(false);
const [syncGroups, setSyncGroups] = useState<Sub2ApiGroup[]>([]);
const [syncLoading, setSyncLoading] = useState(false);
const [syncSelected, setSyncSelected] = useState<Set<number>>(new Set());
const [syncImporting, setSyncImporting] = useState(false);
// ── Fetch channels ──
const fetchChannels = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const res = await fetch(`/api/admin/channels?token=${encodeURIComponent(token)}`);
if (!res.ok) {
if (res.status === 401) {
setError(t.invalidToken);
return;
}
throw new Error();
}
const data = await res.json();
setChannels(data.channels);
} catch {
setError(t.loadFailed);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
// ── Missing token ──
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 text-gray-500">{t.missingTokenHint}</p>
</div>
</div>
);
}
// ── Edit modal handlers ──
const openCreateModal = () => {
setEditingChannel(null);
setForm(emptyForm);
setEditModalOpen(true);
};
const openEditModal = (channel: Channel) => {
setEditingChannel(channel);
setForm({
group_id: channel.groupId,
name: channel.name,
platform: channel.platform,
rate_multiplier: String(channel.rateMultiplier),
description: channel.description ?? '',
models: arrayToLines(channel.models),
features: arrayToLines(channel.features),
sort_order: String(channel.sortOrder),
enabled: channel.enabled,
});
setEditModalOpen(true);
};
const closeEditModal = () => {
setEditModalOpen(false);
setEditingChannel(null);
};
const handleSave = async () => {
if (!form.name.trim() || form.group_id === '' || !form.rate_multiplier) return;
setSaving(true);
setError('');
const body = {
group_id: Number(form.group_id),
name: form.name.trim(),
platform: form.platform,
rate_multiplier: parseFloat(form.rate_multiplier),
description: form.description.trim() || null,
models: form.models.trim() ? linesToJsonString(form.models) : null,
features: form.features.trim() ? linesToJsonString(form.features) : null,
sort_order: parseInt(form.sort_order, 10) || 0,
enabled: form.enabled,
};
try {
const url = editingChannel ? `/api/admin/channels/${editingChannel.id}` : '/api/admin/channels';
const method = editingChannel ? '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(() => ({}));
setError(data.error || t.saveFailed);
return;
}
closeEditModal();
fetchChannels();
} catch {
setError(t.saveFailed);
} finally {
setSaving(false);
}
};
// ── Delete handler ──
const handleDelete = async (channel: Channel) => {
if (!confirm(t.deleteConfirm)) return;
try {
const res = await fetch(`/api/admin/channels/${channel.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || t.deleteFailed);
return;
}
fetchChannels();
} catch {
setError(t.deleteFailed);
}
};
// ── Toggle enabled ──
const handleToggleEnabled = async (channel: Channel) => {
try {
const res = await fetch(`/api/admin/channels/${channel.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ enabled: !channel.enabled }),
});
if (res.ok) {
setChannels((prev) => prev.map((c) => (c.id === channel.id ? { ...c, enabled: !c.enabled } : c)));
}
} catch {
/* ignore */
}
};
// ── Sync modal handlers ──
const openSyncModal = async () => {
setSyncModalOpen(true);
setSyncLoading(true);
setSyncSelected(new Set());
try {
const res = await fetch(`/api/admin/sub2api/groups?token=${encodeURIComponent(token)}`);
if (!res.ok) throw new Error();
const data = await res.json();
setSyncGroups(data.groups ?? []);
} catch {
setError(t.syncFetchFailed);
setSyncModalOpen(false);
} finally {
setSyncLoading(false);
}
};
const closeSyncModal = () => {
setSyncModalOpen(false);
setSyncGroups([]);
setSyncSelected(new Set());
};
const existingGroupIds = new Set(channels.map((c) => c.groupId));
const toggleSyncGroup = (id: number) => {
setSyncSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSyncImport = async () => {
if (syncSelected.size === 0) return;
setSyncImporting(true);
setError('');
let successCount = 0;
for (const groupId of syncSelected) {
const group = syncGroups.find((g) => g.id === groupId);
if (!group) continue;
try {
const res = await fetch('/api/admin/channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
group_id: group.id,
name: group.name,
platform: group.platform || 'claude',
rate_multiplier: group.rate_multiplier ?? 1,
description: group.description || null,
sort_order: 0,
enabled: true,
}),
});
if (res.ok) successCount++;
} catch {
/* continue with remaining */
}
}
setSyncImporting(false);
closeSyncModal();
if (successCount > 0) {
fetchChannels();
} else {
setError(t.syncImportFailed);
}
};
// ── Nav params ──
const navParams = new URLSearchParams();
if (token) 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(' ');
// ── Shared input classes ──
const inputCls = [
'w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/50',
isDark
? 'border-slate-600 bg-slate-700 text-slate-100 placeholder-slate-400'
: 'border-slate-300 bg-white text-slate-900 placeholder-slate-400',
].join(' ');
const labelCls = ['block text-sm font-medium mb-1', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ');
// ── Render ──
return (
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth="full"
title={t.title}
subtitle={t.subtitle}
locale={locale}
actions={
<>
<a href={`/admin?${navParams}`} className={btnBase}>
{t.orders}
</a>
<button type="button" onClick={fetchChannels} className={btnBase}>
{t.refresh}
</button>
<button
type="button"
onClick={openSyncModal}
className="inline-flex items-center rounded-lg border border-indigo-500 bg-indigo-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-600"
>
{t.syncFromSub2Api}
</button>
<button
type="button"
onClick={openCreateModal}
className="inline-flex items-center rounded-lg border border-emerald-500 bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600"
>
{t.newChannel}
</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>
)}
{/* Channel table */}
<div
className={[
'overflow-x-auto rounded-xl border',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
{loading ? (
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
) : channels.length === 0 ? (
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<p className="text-base font-medium">{t.noChannels}</p>
<p className="mt-1 text-sm opacity-70">{t.noChannelsHint}</p>
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className={isDark ? 'border-b border-slate-700 text-slate-400' : 'border-b border-slate-200 text-slate-500'}>
<th className="px-4 py-3 text-left font-medium">{t.colName}</th>
<th className="px-4 py-3 text-left font-medium">{t.colPlatform}</th>
<th className="px-4 py-3 text-left font-medium">{t.colRate}</th>
<th className="px-4 py-3 text-center font-medium">{t.colSub2ApiStatus}</th>
<th className="px-4 py-3 text-center font-medium">{t.colSortOrder}</th>
<th className="px-4 py-3 text-center font-medium">{t.colEnabled}</th>
<th className="px-4 py-3 text-right font-medium">{t.colActions}</th>
</tr>
</thead>
<tbody>
{channels.map((channel) => {
const pc = PLATFORM_COLORS[channel.platform] ?? PLATFORM_COLORS.claude;
return (
<tr
key={channel.id}
className={[
'border-b transition-colors',
isDark ? 'border-slate-700/50 hover:bg-slate-700/30' : 'border-slate-100 hover:bg-slate-50',
].join(' ')}
>
<td className={`px-4 py-3 font-medium ${isDark ? 'text-slate-100' : 'text-slate-900'}`}>
<div>{channel.name}</div>
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
Group #{channel.groupId}
</div>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${isDark ? pc.bg.replace('dark:', '') : pc.bg.split(' ')[0]} ${isDark ? pc.text.replace('dark:', '') : pc.text.split(' ')[0]}`}
>
{channel.platform}
</span>
</td>
<td className={`px-4 py-3 ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>
{channel.rateMultiplier}x
</td>
<td className="px-4 py-3 text-center">
{channel.groupExists ? (
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-400">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</span>
) : (
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
)}
</td>
<td className={`px-4 py-3 text-center ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
{channel.sortOrder}
</td>
<td className="px-4 py-3 text-center">
<button
type="button"
onClick={() => handleToggleEnabled(channel)}
className={[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
channel.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',
channel.enabled ? 'translate-x-4.5' : 'translate-x-0.5',
].join(' ')}
/>
</button>
</td>
<td className="px-4 py-3 text-right">
<div className="inline-flex gap-1">
<button
type="button"
onClick={() => openEditModal(channel)}
className={[
'rounded-md px-2 py-1 text-xs font-medium transition-colors',
isDark ? 'text-indigo-400 hover:bg-indigo-500/20' : 'text-indigo-600 hover:bg-indigo-50',
].join(' ')}
>
{t.edit}
</button>
<button
type="button"
onClick={() => handleDelete(channel)}
className={[
'rounded-md 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>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* ── Edit / Create Modal ── */}
{editModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={closeEditModal}>
<div
className={[
'relative w-full max-w-lg overflow-y-auto rounded-2xl border p-6 shadow-2xl',
isDark ? 'border-slate-700 bg-slate-800' : 'border-slate-200 bg-white',
].join(' ')}
style={{ maxHeight: '90vh' }}
onClick={(e) => e.stopPropagation()}
>
<h2 className={`mb-5 text-lg font-semibold ${isDark ? 'text-slate-100' : 'text-slate-900'}`}>
{editingChannel ? t.editChannel : t.newChannel}
</h2>
<div className="space-y-4">
{/* Group ID (only for create) */}
{!editingChannel && (
<div>
<label className={labelCls}>{t.fieldGroupId}</label>
<input
type="number"
value={form.group_id}
onChange={(e) => setForm({ ...form, group_id: e.target.value ? Number(e.target.value) : '' })}
className={inputCls}
required
/>
</div>
)}
{/* Name */}
<div>
<label className={labelCls}>{t.fieldName}</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputCls}
required
/>
</div>
{/* Platform */}
<div>
<label className={labelCls}>{t.fieldPlatform}</label>
<select
value={form.platform}
onChange={(e) => setForm({ ...form, platform: e.target.value })}
className={inputCls}
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
{/* Rate Multiplier */}
<div>
<label className={labelCls}>{t.fieldRate}</label>
<input
type="number"
step="0.0001"
min="0"
value={form.rate_multiplier}
onChange={(e) => setForm({ ...form, rate_multiplier: e.target.value })}
className={inputCls}
required
/>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>{t.fieldRateHint}</p>
</div>
{/* Description */}
<div>
<label className={labelCls}>{t.fieldDescription}</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
className={inputCls}
/>
</div>
{/* Models */}
<div>
<label className={labelCls}>{t.fieldModels}</label>
<textarea
value={form.models}
onChange={(e) => setForm({ ...form, models: e.target.value })}
rows={4}
className={[inputCls, 'font-mono text-xs'].join(' ')}
placeholder="claude-sonnet-4-20250514&#10;claude-opus-4-20250514"
/>
</div>
{/* Features */}
<div>
<label className={labelCls}>{t.fieldFeatures}</label>
<textarea
value={form.features}
onChange={(e) => setForm({ ...form, features: e.target.value })}
rows={3}
className={[inputCls, 'font-mono text-xs'].join(' ')}
placeholder="Extended thinking&#10;Vision&#10;Tool use"
/>
</div>
{/* Sort Order */}
<div>
<label className={labelCls}>{t.fieldSortOrder}</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: e.target.value })}
className={inputCls}
/>
</div>
{/* Enabled */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setForm({ ...form, enabled: !form.enabled })}
className={[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
form.enabled ? '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',
form.enabled ? 'translate-x-6' : 'translate-x-1',
].join(' ')}
/>
</button>
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>{t.fieldEnabled}</span>
</div>
</div>
{/* Actions */}
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={closeEditModal}
className={[
'rounded-lg px-4 py-2 text-sm font-medium transition-colors',
isDark ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100',
].join(' ')}
>
{t.cancel}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving || !form.name.trim() || form.group_id === '' || !form.rate_multiplier}
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? t.saving : t.save}
</button>
</div>
</div>
</div>
)}
{/* ── Sync from Sub2API Modal ── */}
{syncModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={closeSyncModal}>
<div
className={[
'relative w-full max-w-lg overflow-y-auto rounded-2xl border p-6 shadow-2xl',
isDark ? 'border-slate-700 bg-slate-800' : 'border-slate-200 bg-white',
].join(' ')}
style={{ maxHeight: '80vh' }}
onClick={(e) => e.stopPropagation()}
>
<h2 className={`mb-1 text-lg font-semibold ${isDark ? 'text-slate-100' : 'text-slate-900'}`}>
{t.syncTitle}
</h2>
<p className={`mb-4 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{t.syncHint}</p>
{syncLoading ? (
<div className={`py-8 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.syncLoading}</div>
) : syncGroups.length === 0 ? (
<div className={`py-8 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.syncNoGroups}</div>
) : (
<div className="space-y-2">
{syncGroups.map((group) => {
const alreadyImported = existingGroupIds.has(group.id);
return (
<label
key={group.id}
className={[
'flex items-start gap-3 rounded-lg border p-3 transition-colors',
alreadyImported
? isDark
? 'border-slate-700 bg-slate-700/30 opacity-60'
: 'border-slate-200 bg-slate-50 opacity-60'
: syncSelected.has(group.id)
? isDark
? 'border-indigo-500/50 bg-indigo-500/10'
: 'border-indigo-300 bg-indigo-50'
: isDark
? 'border-slate-700 hover:border-slate-600'
: 'border-slate-200 hover:border-slate-300',
alreadyImported ? 'cursor-not-allowed' : 'cursor-pointer',
].join(' ')}
>
<input
type="checkbox"
disabled={alreadyImported}
checked={syncSelected.has(group.id)}
onChange={() => toggleSyncGroup(group.id)}
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-indigo-500 focus:ring-indigo-500"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-slate-900'}`}>
{group.name}
</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
#{group.id}
</span>
{(() => {
const gpc = PLATFORM_COLORS[group.platform] ?? PLATFORM_COLORS.claude;
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${isDark ? gpc.bg.replace('dark:', '') : gpc.bg.split(' ')[0]} ${isDark ? gpc.text.replace('dark:', '') : gpc.text.split(' ')[0]}`}
>
{group.platform}
</span>
);
})()}
{alreadyImported && (
<span className="text-[10px] text-amber-500 font-medium">{t.syncAlreadyExists}</span>
)}
</div>
{group.description && (
<p className={`mt-0.5 text-xs truncate ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
{group.description}
</p>
)}
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
{t.colRate}: {group.rate_multiplier}x
</p>
</div>
</label>
);
})}
</div>
)}
{/* Sync actions */}
<div className="mt-5 flex justify-end gap-3">
<button
type="button"
onClick={closeSyncModal}
className={[
'rounded-lg px-4 py-2 text-sm font-medium transition-colors',
isDark ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100',
].join(' ')}
>
{t.cancel}
</button>
<button
type="button"
onClick={handleSyncImport}
disabled={syncImporting || syncSelected.size === 0}
className="rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{syncImporting ? t.syncImporting : `${t.syncImport} (${syncSelected.size})`}
</button>
</div>
</div>
</div>
)}
</PayPageLayout>
);
}
function ChannelsPageFallback() {
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">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
export default function ChannelsPage() {
return (
<Suspense fallback={<ChannelsPageFallback />}>
<ChannelsContent />
</Suspense>
);
}

74
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,74 @@
'use client';
import { useSearchParams, usePathname } from 'next/navigation';
import { Suspense } from 'react';
import { resolveLocale } from '@/lib/locale';
const NAV_ITEMS = [
{ path: '/admin', label: { zh: '订单管理', en: 'Orders' } },
{ path: '/admin/dashboard', label: { zh: '数据概览', en: 'Dashboard' } },
{ path: '/admin/channels', label: { zh: '渠道管理', en: 'Channels' } },
{ path: '/admin/subscriptions', label: { zh: '订阅管理', en: 'Subscriptions' } },
{ path: '/admin/settings', label: { zh: '系统配置', en: 'Settings' } },
];
function AdminNav() {
const searchParams = useSearchParams();
const pathname = usePathname();
const token = searchParams.get('token') || '';
const theme = searchParams.get('theme') || 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const buildUrl = (path: string) => {
const params = new URLSearchParams();
if (token) params.set('token', token);
params.set('theme', theme);
params.set('ui_mode', uiMode);
if (locale !== 'zh') params.set('lang', locale);
return `${path}?${params.toString()}`;
};
const isActive = (navPath: string) => {
if (navPath === '/admin') return pathname === '/admin';
return pathname.startsWith(navPath);
};
return (
<nav
className={[
'mb-4 flex flex-wrap gap-1 rounded-xl border p-1',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-100/90',
].join(' ')}
>
{NAV_ITEMS.map((item) => (
<a
key={item.path}
href={buildUrl(item.path)}
className={[
'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
isActive(item.path)
? isDark
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35'
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-sm'
: isDark
? 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/50'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/70',
].join(' ')}
>
{item.label[locale]}
</a>
))}
</nav>
);
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<AdminNav />
{children}
</Suspense>
);
}

View File

@@ -0,0 +1,788 @@
'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';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface ConfigItem {
key: string;
value: string;
group?: string;
label?: string;
}
interface ConfigGroup {
id: string;
title: string;
titleEn: string;
fields: ConfigField[];
}
interface ConfigField {
key: string;
label: string;
labelEn: string;
type: 'text' | 'number' | 'textarea' | 'password' | 'checkbox-group';
options?: string[]; // for checkbox-group
group: string;
}
/* ------------------------------------------------------------------ */
/* Sensitive field helpers */
/* ------------------------------------------------------------------ */
const SENSITIVE_PATTERNS = ['KEY', 'SECRET', 'PASSWORD', 'PRIVATE'];
function isSensitiveKey(key: string): boolean {
return SENSITIVE_PATTERNS.some((p) => key.toUpperCase().includes(p));
}
function isMaskedValue(value: string): boolean {
return /^\*+/.test(value);
}
/* ------------------------------------------------------------------ */
/* Config field definitions */
/* ------------------------------------------------------------------ */
const CONFIG_GROUPS: ConfigGroup[] = [
{
id: 'payment',
title: '支付渠道',
titleEn: 'Payment Providers',
fields: [
{
key: 'PAYMENT_PROVIDERS',
label: '启用的支付服务商',
labelEn: 'Enabled Providers',
type: 'checkbox-group',
options: ['easypay', 'alipay', 'wxpay', 'stripe'],
group: 'payment',
},
// EasyPay
{ key: 'EASY_PAY_PID', label: 'EasyPay 商户ID', labelEn: 'EasyPay PID', type: 'text', group: 'payment' },
{ key: 'EASY_PAY_PKEY', label: 'EasyPay 密钥', labelEn: 'EasyPay Key', type: 'password', group: 'payment' },
{
key: 'EASY_PAY_API_BASE',
label: 'EasyPay API 地址',
labelEn: 'EasyPay API Base',
type: 'text',
group: 'payment',
},
{
key: 'EASY_PAY_NOTIFY_URL',
label: 'EasyPay 回调地址',
labelEn: 'EasyPay Notify URL',
type: 'text',
group: 'payment',
},
{
key: 'EASY_PAY_RETURN_URL',
label: 'EasyPay 返回地址',
labelEn: 'EasyPay Return URL',
type: 'text',
group: 'payment',
},
// Alipay
{ key: 'ALIPAY_APP_ID', label: '支付宝 App ID', labelEn: 'Alipay App ID', type: 'text', group: 'payment' },
{
key: 'ALIPAY_PRIVATE_KEY',
label: '支付宝应用私钥',
labelEn: 'Alipay Private Key',
type: 'password',
group: 'payment',
},
{
key: 'ALIPAY_PUBLIC_KEY',
label: '支付宝公钥',
labelEn: 'Alipay Public Key',
type: 'password',
group: 'payment',
},
{
key: 'ALIPAY_NOTIFY_URL',
label: '支付宝回调地址',
labelEn: 'Alipay Notify URL',
type: 'text',
group: 'payment',
},
// Wxpay
{ key: 'WXPAY_APP_ID', label: '微信支付 App ID', labelEn: 'Wxpay App ID', type: 'text', group: 'payment' },
{ key: 'WXPAY_MCH_ID', label: '微信支付商户号', labelEn: 'Wxpay Merchant ID', type: 'text', group: 'payment' },
{
key: 'WXPAY_PRIVATE_KEY',
label: '微信支付私钥',
labelEn: 'Wxpay Private Key',
type: 'password',
group: 'payment',
},
{
key: 'WXPAY_API_V3_KEY',
label: '微信支付 APIv3 密钥',
labelEn: 'Wxpay APIv3 Key',
type: 'password',
group: 'payment',
},
{
key: 'WXPAY_PUBLIC_KEY',
label: '微信支付公钥',
labelEn: 'Wxpay Public Key',
type: 'password',
group: 'payment',
},
{
key: 'WXPAY_CERT_SERIAL',
label: '微信支付证书序列号',
labelEn: 'Wxpay Cert Serial',
type: 'text',
group: 'payment',
},
{
key: 'WXPAY_NOTIFY_URL',
label: '微信支付回调地址',
labelEn: 'Wxpay Notify URL',
type: 'text',
group: 'payment',
},
// Stripe
{
key: 'STRIPE_SECRET_KEY',
label: 'Stripe 密钥',
labelEn: 'Stripe Secret Key',
type: 'password',
group: 'payment',
},
{
key: 'STRIPE_PUBLISHABLE_KEY',
label: 'Stripe 公钥',
labelEn: 'Stripe Publishable Key',
type: 'password',
group: 'payment',
},
{
key: 'STRIPE_WEBHOOK_SECRET',
label: 'Stripe Webhook 密钥',
labelEn: 'Stripe Webhook Secret',
type: 'password',
group: 'payment',
},
],
},
{
id: 'limits',
title: '业务参数',
titleEn: 'Business Parameters',
fields: [
{
key: 'ORDER_TIMEOUT_MINUTES',
label: '订单超时时间 (分钟)',
labelEn: 'Order Timeout (minutes)',
type: 'number',
group: 'limits',
},
{
key: 'MIN_RECHARGE_AMOUNT',
label: '最小充值金额',
labelEn: 'Min Recharge Amount',
type: 'number',
group: 'limits',
},
{
key: 'MAX_RECHARGE_AMOUNT',
label: '最大充值金额',
labelEn: 'Max Recharge Amount',
type: 'number',
group: 'limits',
},
{
key: 'MAX_DAILY_RECHARGE_AMOUNT',
label: '每日最大充值金额',
labelEn: 'Max Daily Recharge Amount',
type: 'number',
group: 'limits',
},
{
key: 'RECHARGE_AMOUNTS',
label: '快捷充值金额选项 (逗号分隔)',
labelEn: 'Quick Recharge Amounts (comma-separated)',
type: 'text',
group: 'limits',
},
],
},
{
id: 'display',
title: '显示配置',
titleEn: 'Display Settings',
fields: [
{
key: 'PAY_HELP_IMAGE_URL',
label: '支付帮助图片 URL',
labelEn: 'Pay Help Image URL',
type: 'text',
group: 'display',
},
{
key: 'PAY_HELP_TEXT',
label: '支付帮助文本',
labelEn: 'Pay Help Text',
type: 'textarea',
group: 'display',
},
{
key: 'PAYMENT_SUBLABEL_ALIPAY',
label: '支付宝副标签',
labelEn: 'Alipay Sub-label',
type: 'text',
group: 'display',
},
{
key: 'PAYMENT_SUBLABEL_WXPAY',
label: '微信支付副标签',
labelEn: 'Wxpay Sub-label',
type: 'text',
group: 'display',
},
{
key: 'PAYMENT_SUBLABEL_STRIPE',
label: 'Stripe 副标签',
labelEn: 'Stripe Sub-label',
type: 'text',
group: 'display',
},
{
key: 'PAYMENT_SUBLABEL_EASYPAY_ALIPAY',
label: 'EasyPay 支付宝副标签',
labelEn: 'EasyPay Alipay Sub-label',
type: 'text',
group: 'display',
},
{
key: 'PAYMENT_SUBLABEL_EASYPAY_WXPAY',
label: 'EasyPay 微信支付副标签',
labelEn: 'EasyPay Wxpay Sub-label',
type: 'text',
group: 'display',
},
{
key: 'SUPPORT_EMAIL',
label: '客服邮箱',
labelEn: 'Support Email',
type: 'text',
group: 'display',
},
{
key: 'SITE_NAME',
label: '站点名称',
labelEn: 'Site Name',
type: 'text',
group: 'display',
},
],
},
];
/* ------------------------------------------------------------------ */
/* Chevron SVG */
/* ------------------------------------------------------------------ */
function ChevronIcon({ open, isDark }: { open: boolean; isDark: boolean }) {
return (
<svg
className={[
'h-5 w-5 shrink-0 transition-transform duration-200',
open ? 'rotate-180' : '',
isDark ? 'text-slate-400' : 'text-slate-500',
].join(' ')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* Eye toggle SVG */
/* ------------------------------------------------------------------ */
function EyeIcon({ visible, isDark }: { visible: boolean; isDark: boolean }) {
const cls = ['h-4 w-4 cursor-pointer', isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'].join(' ');
if (visible) {
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.875 18.825A10.05 10.05 0 0112 19c-5 0-9.27-3.11-11-7.5a11.72 11.72 0 013.168-4.477M6.343 6.343A9.97 9.97 0 0112 5c5 0 9.27 3.11 11 7.5a11.72 11.72 0 01-4.168 4.477M6.343 6.343L3 3m3.343 3.343l2.829 2.829m4.486 4.486l2.829 2.829M6.343 6.343l11.314 11.314M14.121 14.121A3 3 0 009.879 9.879"
/>
</svg>
);
}
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
);
}
/* ------------------------------------------------------------------ */
/* i18n text */
/* ------------------------------------------------------------------ */
function getText(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',
loadFailed: 'Failed to load configs',
title: 'System Settings',
subtitle: 'Manage system configuration and parameters',
loading: 'Loading...',
save: 'Save',
saving: 'Saving...',
saved: 'Saved',
saveFailed: 'Save failed',
orders: 'Order Management',
dashboard: 'Dashboard',
refresh: 'Refresh',
noChanges: 'No changes to save',
}
: {
missingToken: '缺少管理员凭证',
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
invalidToken: '管理员凭证无效',
requestFailed: '请求失败',
loadFailed: '加载配置失败',
title: '系统配置',
subtitle: '管理系统配置项与业务参数',
loading: '加载中...',
save: '保存',
saving: '保存中...',
saved: '已保存',
saveFailed: '保存失败',
orders: '订单管理',
dashboard: '数据概览',
refresh: '刷新',
noChanges: '没有需要保存的更改',
};
}
/* ------------------------------------------------------------------ */
/* ConfigGroupCard component */
/* ------------------------------------------------------------------ */
function ConfigGroupCard({
group,
values,
onChange,
onSave,
savingGroup,
savedGroup,
saveError,
isDark,
locale,
}: {
group: ConfigGroup;
values: Record<string, string>;
onChange: (key: string, value: string) => void;
onSave: () => void;
savingGroup: boolean;
savedGroup: boolean;
saveError: string;
isDark: boolean;
locale: Locale;
}) {
const text = getText(locale);
const [open, setOpen] = useState(true);
const [visibleFields, setVisibleFields] = useState<Record<string, boolean>>({});
const toggleVisible = (key: string) => {
setVisibleFields((prev) => ({ ...prev, [key]: !prev[key] }));
};
const cardCls = [
'rounded-xl border transition-colors',
isDark ? 'border-slate-700/60 bg-slate-800/50' : 'border-slate-200 bg-white',
].join(' ');
const headerCls = [
'flex cursor-pointer select-none items-center justify-between px-4 py-3 sm:px-5',
isDark ? 'hover:bg-slate-700/30' : 'hover:bg-slate-50',
'rounded-xl transition-colors',
].join(' ');
const labelCls = ['block text-sm font-medium mb-1', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ');
const inputCls = [
'w-full rounded-lg border px-3 py-2 text-sm outline-none transition-colors',
isDark
? 'border-slate-600 bg-slate-700/60 text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:ring-1 focus:ring-indigo-400/30'
: 'border-slate-300 bg-white text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30',
].join(' ');
const textareaCls = [
'w-full rounded-lg border px-3 py-2 text-sm outline-none transition-colors resize-y min-h-[80px]',
isDark
? 'border-slate-600 bg-slate-700/60 text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:ring-1 focus:ring-indigo-400/30'
: 'border-slate-300 bg-white text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30',
].join(' ');
return (
<div className={cardCls}>
<div className={headerCls} onClick={() => setOpen((v) => !v)}>
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{locale === 'en' ? group.titleEn : group.title}
</h3>
<ChevronIcon open={open} isDark={isDark} />
</div>
{open && (
<div className="space-y-4 px-4 pb-4 sm:px-5 sm:pb-5">
{group.fields.map((field) => {
const value = values[field.key] ?? '';
if (field.type === 'checkbox-group' && field.options) {
const selected = value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return (
<div key={field.key}>
<label className={labelCls}>{locale === 'en' ? field.labelEn : field.label}</label>
<div className="flex flex-wrap gap-3">
{field.options.map((opt) => {
const checked = selected.includes(opt);
return (
<label
key={opt}
className={[
'inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-colors',
checked
? isDark
? 'border-indigo-400/50 bg-indigo-500/20 text-indigo-200'
: 'border-blue-400 bg-blue-50 text-blue-700'
: isDark
? 'border-slate-600 text-slate-400 hover:border-slate-500'
: 'border-slate-300 text-slate-600 hover:border-slate-400',
].join(' ')}
>
<input
type="checkbox"
className="accent-blue-600"
checked={checked}
onChange={() => {
const next = checked ? selected.filter((s) => s !== opt) : [...selected, opt];
onChange(field.key, next.join(','));
}}
/>
{opt}
</label>
);
})}
</div>
</div>
);
}
if (field.type === 'textarea') {
return (
<div key={field.key}>
<label className={labelCls}>{locale === 'en' ? field.labelEn : field.label}</label>
<textarea className={textareaCls} value={value} onChange={(e) => onChange(field.key, e.target.value)} rows={3} />
</div>
);
}
if (field.type === 'password' || isSensitiveKey(field.key)) {
const isVisible = visibleFields[field.key] ?? false;
return (
<div key={field.key}>
<label className={labelCls}>{locale === 'en' ? field.labelEn : field.label}</label>
<div className="relative">
<input
type={isVisible ? 'text' : 'password'}
className={inputCls + ' pr-10'}
value={value}
onChange={(e) => onChange(field.key, e.target.value)}
placeholder={isMaskedValue(value) ? '' : undefined}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2"
onClick={() => toggleVisible(field.key)}
tabIndex={-1}
>
<EyeIcon visible={isVisible} isDark={isDark} />
</button>
</div>
</div>
);
}
return (
<div key={field.key}>
<label className={labelCls}>{locale === 'en' ? field.labelEn : field.label}</label>
<input
type={field.type === 'number' ? 'number' : 'text'}
className={inputCls}
value={value}
onChange={(e) => onChange(field.key, e.target.value)}
/>
</div>
);
})}
{/* Save button + status */}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={onSave}
disabled={savingGroup}
className={[
'inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors',
savingGroup ? 'cursor-not-allowed bg-green-400 opacity-70' : 'bg-green-600 hover:bg-green-700 active:bg-green-800',
].join(' ')}
>
{savingGroup ? text.saving : text.save}
</button>
{savedGroup && (
<span className={['text-sm', isDark ? 'text-green-400' : 'text-green-600'].join(' ')}>{text.saved}</span>
)}
{saveError && <span className="text-sm text-red-500">{saveError}</span>}
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main content */
/* ------------------------------------------------------------------ */
function SettingsContent() {
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 text = getText(locale);
// State: original values from API, and local edited values
const [originalValues, setOriginalValues] = useState<Record<string, string>>({});
const [editedValues, setEditedValues] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Per-group save state
const [savingGroups, setSavingGroups] = useState<Record<string, boolean>>({});
const [savedGroups, setSavedGroups] = useState<Record<string, boolean>>({});
const [saveErrors, setSaveErrors] = useState<Record<string, string>>({});
const fetchConfigs = useCallback(async () => {
if (!token) return;
setLoading(true);
setError('');
try {
const res = await fetch(`/api/admin/config?token=${encodeURIComponent(token)}`);
if (!res.ok) {
if (res.status === 401) {
setError(text.invalidToken);
return;
}
throw new Error(text.requestFailed);
}
const data = await res.json();
const configMap: Record<string, string> = {};
(data.configs as ConfigItem[]).forEach((c) => {
configMap[c.key] = c.value;
});
setOriginalValues(configMap);
setEditedValues(configMap);
} catch {
setError(text.loadFailed);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
fetchConfigs();
}, [fetchConfigs]);
const handleChange = (key: string, value: string) => {
setEditedValues((prev) => ({ ...prev, [key]: value }));
// Clear saved status for the group this key belongs to
const group = CONFIG_GROUPS.find((g) => g.fields.some((f) => f.key === key));
if (group) {
setSavedGroups((prev) => ({ ...prev, [group.id]: false }));
setSaveErrors((prev) => ({ ...prev, [group.id]: '' }));
}
};
const handleSaveGroup = async (group: ConfigGroup) => {
// Collect only changed, non-masked fields in this group
const changes: ConfigItem[] = [];
for (const field of group.fields) {
const edited = editedValues[field.key] ?? '';
const original = originalValues[field.key] ?? '';
if (edited === original) continue;
// Skip if user didn't actually change a masked value
if (isSensitiveKey(field.key) && isMaskedValue(edited)) continue;
changes.push({ key: field.key, value: edited, group: field.group, label: locale === 'en' ? field.labelEn : field.label });
}
if (changes.length === 0) {
setSaveErrors((prev) => ({ ...prev, [group.id]: text.noChanges }));
return;
}
setSavingGroups((prev) => ({ ...prev, [group.id]: true }));
setSaveErrors((prev) => ({ ...prev, [group.id]: '' }));
setSavedGroups((prev) => ({ ...prev, [group.id]: false }));
try {
const res = await fetch('/api/admin/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ configs: changes }),
});
if (!res.ok) {
throw new Error(text.saveFailed);
}
// Update original values for saved keys
setOriginalValues((prev) => {
const next = { ...prev };
changes.forEach((c) => {
next[c.key] = c.value;
});
return next;
});
setSavedGroups((prev) => ({ ...prev, [group.id]: true }));
// Re-fetch to get properly masked values
await fetchConfigs();
} catch {
setSaveErrors((prev) => ({ ...prev, [group.id]: text.saveFailed }));
} finally {
setSavingGroups((prev) => ({ ...prev, [group.id]: false }));
}
};
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">{text.missingToken}</p>
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
</div>
</div>
);
}
const navParams = new URLSearchParams();
navParams.set('token', token);
if (locale === 'en') navParams.set('lang', 'en');
if (theme === 'dark') 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(' ');
return (
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth="full"
title={text.title}
subtitle={text.subtitle}
locale={locale}
actions={
<>
<a href={`/admin?${navParams}`} className={btnBase}>
{text.orders}
</a>
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
{text.dashboard}
</a>
<button type="button" onClick={fetchConfigs} className={btnBase}>
{text.refresh}
</button>
</>
}
>
{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>
)}
{loading ? (
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
) : (
<div className="space-y-4">
{CONFIG_GROUPS.map((group) => (
<ConfigGroupCard
key={group.id}
group={group}
values={editedValues}
onChange={handleChange}
onSave={() => handleSaveGroup(group)}
savingGroup={savingGroups[group.id] ?? false}
savedGroup={savedGroups[group.id] ?? false}
saveError={saveErrors[group.id] ?? ''}
isDark={isDark}
locale={locale}
/>
))}
</div>
)}
</PayPageLayout>
);
}
/* ------------------------------------------------------------------ */
/* Page export with Suspense */
/* ------------------------------------------------------------------ */
function SettingsPageFallback() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
export default function SettingsPage() {
return (
<Suspense fallback={<SettingsPageFallback />}>
<SettingsContent />
</Suspense>
);
}

View File

@@ -0,0 +1,907 @@
'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';
/* ---------- types ---------- */
interface SubscriptionPlan {
id: string;
name: string;
description: string | null;
price: number;
originalPrice: number | null;
validDays: number;
features: string[];
groupId: string;
groupName: string | null;
sortOrder: number;
enabled: boolean;
groupExists: boolean;
}
interface Sub2ApiGroup {
id: string;
name: string;
}
interface UserSubscription {
userId: number;
groupId: string;
status: string;
startsAt: string | null;
expiresAt: string | null;
dailyUsage: number | null;
weeklyUsage: number | null;
monthlyUsage: number | null;
}
/* ---------- 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 (days)',
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: 'Group Status',
colActions: 'Actions',
edit: 'Edit',
delete: 'Delete',
enabled: 'Yes',
disabled: 'No',
groupExists: 'Exists',
groupMissing: 'Missing',
noPlans: 'No plans configured',
searchUserId: 'Search by user ID',
search: 'Search',
colUserId: 'User ID',
colStatus: 'Status',
colStartsAt: 'Starts At',
colExpiresAt: 'Expires At',
colDailyUsage: 'Daily Usage',
colWeeklyUsage: 'Weekly Usage',
colMonthlyUsage: 'Monthly Usage',
noSubs: 'No subscription records found',
enterUserId: 'Enter a user ID to search',
saveFailed: 'Failed to save plan',
deleteFailed: 'Failed to delete plan',
loadFailed: 'Failed to load data',
days: 'days',
}
: {
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: '有效天数',
fieldFeatures: '特性描述(每行一个)',
fieldSortOrder: '排序',
fieldEnabled: '启用售卖',
colName: '名称',
colGroup: '分组 ID',
colPrice: '价格',
colOriginalPrice: '原价',
colValidDays: '有效期',
colEnabled: '售卖',
colGroupStatus: '分组状态',
colActions: '操作',
edit: '编辑',
delete: '删除',
enabled: '是',
disabled: '否',
groupExists: '存在',
groupMissing: '缺失',
noPlans: '暂无套餐配置',
searchUserId: '按用户 ID 搜索',
search: '搜索',
colUserId: '用户 ID',
colStatus: '状态',
colStartsAt: '开始时间',
colExpiresAt: '到期时间',
colDailyUsage: '日用量',
colWeeklyUsage: '周用量',
colMonthlyUsage: '月用量',
noSubs: '未找到订阅记录',
enterUserId: '请输入用户 ID 进行搜索',
saveFailed: '保存套餐失败',
deleteFailed: '删除套餐失败',
loadFailed: '加载数据失败',
days: '天',
};
}
/* ---------- 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 [formFeatures, setFormFeatures] = useState('');
const [formSortOrder, setFormSortOrder] = useState('0');
const [formEnabled, setFormEnabled] = useState(true);
const [saving, setSaving] = useState(false);
/* --- subs state --- */
const [subsUserId, setSubsUserId] = useState('');
const [subs, setSubs] = useState<UserSubscription[]>([]);
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]);
/* --- modal helpers --- */
const openCreate = () => {
setEditingPlan(null);
setFormGroupId('');
setFormName('');
setFormDescription('');
setFormPrice('');
setFormOriginalPrice('');
setFormValidDays('30');
setFormFeatures('');
setFormSortOrder('0');
setFormEnabled(true);
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));
setFormFeatures((plan.features ?? []).join('\n'));
setFormSortOrder(String(plan.sortOrder));
setFormEnabled(plan.enabled);
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditingPlan(null);
};
/* --- save plan --- */
const handleSave = async () => {
if (!formName.trim() || !formPrice) return;
setSaving(true);
setError('');
const body = {
groupId: formGroupId || undefined,
name: formName.trim(),
description: formDescription.trim() || null,
price: parseFloat(formPrice),
originalPrice: formOriginalPrice ? parseFloat(formOriginalPrice) : null,
validDays: parseInt(formValidDays, 10) || 30,
features: formFeatures
.split('\n')
.map((l) => l.trim())
.filter(Boolean),
sortOrder: parseInt(formSortOrder, 10) || 0,
enabled: formEnabled,
};
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);
} 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);
}
};
/* --- fetch user subs --- */
const fetchSubs = async () => {
if (!token || !subsUserId.trim()) return;
setSubsLoading(true);
setSubsSearched(true);
try {
const res = await fetch(
`/api/admin/subscriptions?token=${encodeURIComponent(token)}&user_id=${encodeURIComponent(subsUserId.trim())}`,
);
if (!res.ok) {
if (res.status === 401) {
setError(t.invalidToken);
return;
}
throw new Error(t.requestFailed);
}
const data = await res.json();
setSubs(Array.isArray(data) ? data : data.subscriptions ?? []);
} 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 text-gray-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 (exclude groups already used by other plans, unless editing that plan) */
const usedGroupIds = new Set(plans.filter((p) => p.id !== editingPlan?.id).map((p) => p.groupId));
const availableGroups = groups.filter((g) => !usedGroupIds.has(g.id));
/* --- 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(' ');
return (
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth="full"
title={t.title}
subtitle={t.subtitle}
locale={locale}
actions={
<>
<a href={`/admin?${navParams}`} className={btnBase}>
{t.orders}
</a>
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
{t.dashboard}
</a>
<button
type="button"
onClick={() => {
if (activeTab === 'plans') fetchPlans();
}}
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 table */}
<div className={tableWrapCls}>
{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>
) : (
<table className="w-full">
<thead>
<tr className={`border-b ${rowBorderCls}`}>
<th className={thCls}>{t.colName}</th>
<th className={thCls}>{t.colGroup}</th>
<th className={thCls}>{t.colPrice}</th>
<th className={thCls}>{t.colOriginalPrice}</th>
<th className={thCls}>{t.colValidDays}</th>
<th className={thCls}>{t.colEnabled}</th>
<th className={thCls}>{t.colGroupStatus}</th>
<th className={thCls}>{t.colActions}</th>
</tr>
</thead>
<tbody>
{plans.map((plan) => (
<tr key={plan.id} className={`border-b ${rowBorderCls} last:border-b-0`}>
<td className={tdCls}>{plan.name}</td>
<td className={tdCls}>
<span className="font-mono text-xs">{plan.groupId}</span>
{plan.groupName && (
<span className={`ml-1 text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
({plan.groupName})
</span>
)}
</td>
<td className={tdCls}>{plan.price.toFixed(2)}</td>
<td className={tdCls}>
{plan.originalPrice != null ? plan.originalPrice.toFixed(2) : '-'}
</td>
<td className={tdCls}>
{plan.validDays} {t.days}
</td>
<td className={tdCls}>
<span
className={[
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
plan.enabled
? isDark
? 'bg-green-500/20 text-green-300'
: 'bg-green-50 text-green-700'
: isDark
? 'bg-slate-700 text-slate-400'
: 'bg-gray-100 text-gray-500',
].join(' ')}
>
{plan.enabled ? t.enabled : t.disabled}
</span>
</td>
<td className={tdCls}>
<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>
</td>
<td className={tdCls}>
<div className="flex gap-2">
<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>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
{/* ====== Tab: User Subscriptions ====== */}
{activeTab === 'subs' && (
<>
{/* Search bar */}
<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(' ')}
/>
<button
type="button"
onClick={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',
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>
{/* Subs table */}
<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.enterUserId}
</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.colUserId}</th>
<th className={thCls}>{t.colGroup}</th>
<th className={thCls}>{t.colStatus}</th>
<th className={thCls}>{t.colStartsAt}</th>
<th className={thCls}>{t.colExpiresAt}</th>
<th className={thCls}>{t.colDailyUsage}</th>
<th className={thCls}>{t.colWeeklyUsage}</th>
<th className={thCls}>{t.colMonthlyUsage}</th>
</tr>
</thead>
<tbody>
{subs.map((sub, idx) => (
<tr key={`${sub.userId}-${sub.groupId}-${idx}`} className={`border-b ${rowBorderCls} last:border-b-0`}>
<td className={tdCls}>{sub.userId}</td>
<td className={tdCls}>
<span className="font-mono text-xs">{sub.groupId}</span>
</td>
<td className={tdCls}>
<span
className={[
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
sub.status === 'active'
? isDark
? 'bg-green-500/20 text-green-300'
: 'bg-green-50 text-green-700'
: isDark
? 'bg-slate-700 text-slate-400'
: 'bg-gray-100 text-gray-500',
].join(' ')}
>
{sub.status}
</span>
</td>
<td className={tdCls}>{sub.startsAt ?? '-'}</td>
<td className={tdCls}>{sub.expiresAt ?? '-'}</td>
<td className={tdCls}>{sub.dailyUsage ?? '-'}</td>
<td className={tdCls}>{sub.weeklyUsage ?? '-'}</td>
<td className={tdCls}>{sub.monthlyUsage ?? '-'}</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 */}
{editingPlan && !availableGroups.some((g) => g.id === editingPlan.groupId) && (
<option value={editingPlan.groupId}>
{editingPlan.groupName ?? editingPlan.groupId} ({editingPlan.groupId})
</option>
)}
</select>
</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"
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"
value={formOriginalPrice}
onChange={(e) => setFormOriginalPrice(e.target.value)}
className={inputCls}
/>
</div>
</div>
{/* Valid days + Sort */}
<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.fieldSortOrder}</label>
<input
type="number"
value={formSortOrder}
onChange={(e) => setFormSortOrder(e.target.value)}
className={inputCls}
/>
</div>
</div>
{/* Features */}
<div>
<label className={labelCls}>{t.fieldFeatures}</label>
<textarea
value={formFeatures}
onChange={(e) => setFormFeatures(e.target.value)}
rows={4}
className={inputCls}
/>
</div>
{/* Enabled */}
<div className="flex items-center gap-2">
<input
id="form-enabled"
type="checkbox"
checked={formEnabled}
onChange={(e) => setFormEnabled(e.target.checked)}
className="h-4 w-4 rounded border-slate-300"
/>
<label
htmlFor="form-enabled"
className={['text-sm', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}
>
{t.fieldEnabled}
</label>
</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}
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>
)}
</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-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
export default function SubscriptionsPage() {
return (
<Suspense fallback={<SubscriptionsPageFallback />}>
<SubscriptionsContent />
</Suspense>
);
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const { id } = await params;
const body = await request.json();
const existing = await prisma.channel.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: '渠道不存在' }, { status: 404 });
}
// 如果更新了 group_id检查唯一性
if (body.group_id !== undefined && Number(body.group_id) !== existing.groupId) {
const conflict = await prisma.channel.findUnique({
where: { groupId: Number(body.group_id) },
});
if (conflict) {
return NextResponse.json(
{ error: `分组 ID ${body.group_id} 已被渠道「${conflict.name}」使用` },
{ status: 409 },
);
}
}
const data: Record<string, unknown> = {};
if (body.group_id !== undefined) data.groupId = Number(body.group_id);
if (body.name !== undefined) data.name = body.name;
if (body.platform !== undefined) data.platform = body.platform;
if (body.rate_multiplier !== undefined) data.rateMultiplier = body.rate_multiplier;
if (body.description !== undefined) data.description = body.description;
if (body.models !== undefined) data.models = body.models;
if (body.features !== undefined) data.features = body.features;
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
if (body.enabled !== undefined) data.enabled = body.enabled;
const channel = await prisma.channel.update({
where: { id },
data,
});
return NextResponse.json({
...channel,
rateMultiplier: Number(channel.rateMultiplier),
});
} catch (error) {
console.error('Failed to update channel:', error);
return NextResponse.json({ error: '更新渠道失败' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const { id } = await params;
const existing = await prisma.channel.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: '渠道不存在' }, { status: 404 });
}
await prisma.channel.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete channel:', error);
return NextResponse.json({ error: '删除渠道失败' }, { status: 500 });
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
import { getGroup } from '@/lib/sub2api/client';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const channels = await prisma.channel.findMany({
orderBy: { sortOrder: 'asc' },
});
// 并发检查每个渠道对应的 Sub2API 分组是否仍然存在
const results = await Promise.all(
channels.map(async (channel) => {
let groupExists = false;
try {
const group = await getGroup(channel.groupId);
groupExists = group !== null;
} catch {
groupExists = false;
}
return {
...channel,
rateMultiplier: Number(channel.rateMultiplier),
groupExists,
};
}),
);
return NextResponse.json({ channels: results });
} catch (error) {
console.error('Failed to list channels:', error);
return NextResponse.json({ error: '获取渠道列表失败' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const body = await request.json();
const { group_id, name, platform, rate_multiplier, description, models, features, sort_order, enabled } = body;
if (!group_id || !name || !platform || rate_multiplier === undefined) {
return NextResponse.json({ error: '缺少必填字段: group_id, name, platform, rate_multiplier' }, { status: 400 });
}
// 验证 group_id 唯一性
const existing = await prisma.channel.findUnique({
where: { groupId: Number(group_id) },
});
if (existing) {
return NextResponse.json({ error: `分组 ID ${group_id} 已被渠道「${existing.name}」使用` }, { status: 409 });
}
const channel = await prisma.channel.create({
data: {
groupId: Number(group_id),
name,
platform,
rateMultiplier: rate_multiplier,
description: description ?? null,
models: models ?? null,
features: features ?? null,
sortOrder: sort_order ?? 0,
enabled: enabled ?? true,
},
});
return NextResponse.json(
{
...channel,
rateMultiplier: Number(channel.rateMultiplier),
},
{ status: 201 },
);
} catch (error) {
console.error('Failed to create channel:', error);
return NextResponse.json({ error: '创建渠道失败' }, { status: 500 });
}
}

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { getAllSystemConfigs, setSystemConfigs } from '@/lib/system-config';
const SENSITIVE_PATTERNS = ['KEY', 'SECRET', 'PASSWORD', 'PRIVATE'];
function maskSensitiveValue(key: string, value: string): string {
const isSensitive = SENSITIVE_PATTERNS.some((pattern) => key.toUpperCase().includes(pattern));
if (!isSensitive || value.length <= 4) return value;
return '*'.repeat(value.length - 4) + value.slice(-4);
}
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const configs = await getAllSystemConfigs();
const masked = configs.map((config) => ({
...config,
value: maskSensitiveValue(config.key, config.value),
}));
return NextResponse.json({ configs: masked });
} catch (error) {
console.error('Failed to get system configs:', error);
return NextResponse.json({ error: '获取系统配置失败' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const body = await request.json();
const { configs } = body;
if (!Array.isArray(configs) || configs.length === 0) {
return NextResponse.json({ error: '缺少必填字段: configs 数组' }, { status: 400 });
}
// 校验每条配置
for (const config of configs) {
if (!config.key || config.value === undefined) {
return NextResponse.json({ error: '每条配置必须包含 key 和 value' }, { status: 400 });
}
}
await setSystemConfigs(
configs.map((c: { key: string; value: string; group?: string; label?: string }) => ({
key: c.key,
value: c.value,
group: c.group,
label: c.label,
})),
);
return NextResponse.json({ success: true, updated: configs.length });
} catch (error) {
console.error('Failed to update system configs:', error);
return NextResponse.json({ error: '更新系统配置失败' }, { status: 500 });
}
}

View File

@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { getAllGroups } from '@/lib/sub2api/client';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const groups = await getAllGroups();
return NextResponse.json({ groups });
} catch (error) {
console.error('Failed to fetch Sub2API groups:', error);
return NextResponse.json({ error: '获取 Sub2API 分组列表失败' }, { status: 500 });
}
}

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const { id } = await params;
const body = await request.json();
const existing = await prisma.subscriptionPlan.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: '订阅套餐不存在' }, { status: 404 });
}
// 如果更新了 group_id检查唯一性
if (body.group_id !== undefined && Number(body.group_id) !== existing.groupId) {
const conflict = await prisma.subscriptionPlan.findUnique({
where: { groupId: Number(body.group_id) },
});
if (conflict) {
return NextResponse.json(
{ error: `分组 ID ${body.group_id} 已被套餐「${conflict.name}」使用` },
{ status: 409 },
);
}
}
const data: Record<string, unknown> = {};
if (body.group_id !== undefined) data.groupId = Number(body.group_id);
if (body.name !== undefined) data.name = body.name;
if (body.description !== undefined) data.description = body.description;
if (body.price !== undefined) data.price = body.price;
if (body.original_price !== undefined) data.originalPrice = body.original_price;
if (body.validity_days !== undefined) data.validityDays = body.validity_days;
if (body.features !== undefined) data.features = body.features;
if (body.for_sale !== undefined) data.forSale = body.for_sale;
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
const plan = await prisma.subscriptionPlan.update({
where: { id },
data,
});
return NextResponse.json({
...plan,
price: Number(plan.price),
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
});
} catch (error) {
console.error('Failed to update subscription plan:', error);
return NextResponse.json({ error: '更新订阅套餐失败' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const { id } = await params;
const existing = await prisma.subscriptionPlan.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: '订阅套餐不存在' }, { status: 404 });
}
// 检查是否有活跃订单引用此套餐
const activeOrderCount = await prisma.order.count({
where: {
planId: id,
status: { in: ['PENDING', 'PAID', 'RECHARGING'] },
},
});
if (activeOrderCount > 0) {
return NextResponse.json(
{ error: `该套餐仍有 ${activeOrderCount} 个活跃订单,无法删除` },
{ status: 409 },
);
}
await prisma.subscriptionPlan.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete subscription plan:', error);
return NextResponse.json({ error: '删除订阅套餐失败' }, { status: 500 });
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
import { getGroup } from '@/lib/sub2api/client';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const plans = await prisma.subscriptionPlan.findMany({
orderBy: { sortOrder: 'asc' },
});
// 并发检查每个套餐对应的 Sub2API 分组是否仍然存在
const results = await Promise.all(
plans.map(async (plan) => {
let groupExists = false;
try {
const group = await getGroup(plan.groupId);
groupExists = group !== null;
} catch {
groupExists = false;
}
return {
...plan,
price: Number(plan.price),
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
groupExists,
};
}),
);
return NextResponse.json({ plans: results });
} catch (error) {
console.error('Failed to list subscription plans:', error);
return NextResponse.json({ error: '获取订阅套餐列表失败' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const body = await request.json();
const { group_id, name, description, price, original_price, validity_days, features, for_sale, sort_order } = body;
if (!group_id || !name || price === undefined) {
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
}
// 验证 group_id 唯一性
const existing = await prisma.subscriptionPlan.findUnique({
where: { groupId: Number(group_id) },
});
if (existing) {
return NextResponse.json(
{ error: `分组 ID ${group_id} 已被套餐「${existing.name}」使用` },
{ status: 409 },
);
}
const plan = await prisma.subscriptionPlan.create({
data: {
groupId: Number(group_id),
name,
description: description ?? null,
price,
originalPrice: original_price ?? null,
validityDays: validity_days ?? 30,
features: features ?? null,
forSale: for_sale ?? false,
sortOrder: sort_order ?? 0,
},
});
return NextResponse.json(
{
...plan,
price: Number(plan.price),
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
},
{ status: 201 },
);
} catch (error) {
console.error('Failed to create subscription plan:', error);
return NextResponse.json({ error: '创建订阅套餐失败' }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { getUserSubscriptions } from '@/lib/sub2api/client';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const searchParams = request.nextUrl.searchParams;
const userId = searchParams.get('user_id');
if (!userId) {
return NextResponse.json({ error: '缺少必填参数: user_id' }, { status: 400 });
}
const parsedUserId = Number(userId);
if (!Number.isFinite(parsedUserId) || parsedUserId <= 0) {
return NextResponse.json({ error: '无效的 user_id' }, { status: 400 });
}
const subscriptions = await getUserSubscriptions(parsedUserId);
// 如果提供了 group_id 筛选,过滤结果
const groupId = searchParams.get('group_id');
const filtered = groupId
? subscriptions.filter((s) => s.group_id === Number(groupId))
: subscriptions;
return NextResponse.json({ subscriptions: filtered });
} catch (error) {
console.error('Failed to query subscriptions:', error);
return NextResponse.json({ error: '查询订阅信息失败' }, { status: 500 });
}
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentUserByToken } from '@/lib/sub2api/client';
import { getGroup } from '@/lib/sub2api/client';
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')?.trim();
if (!token) {
return NextResponse.json({ error: '缺少 token' }, { status: 401 });
}
try {
await getCurrentUserByToken(token);
} catch {
return NextResponse.json({ error: '无效的 token' }, { status: 401 });
}
try {
const channels = await prisma.channel.findMany({
where: { enabled: true },
orderBy: { sortOrder: 'asc' },
});
// 并发校验每个渠道对应的 Sub2API 分组是否存在
const results = await Promise.all(
channels.map(async (ch) => {
let groupActive = false;
try {
const group = await getGroup(ch.groupId);
groupActive = group !== null && group.status === 'active';
} catch {
groupActive = false;
}
if (!groupActive) return null; // 过滤掉分组不存在的渠道
return {
id: ch.id,
groupId: ch.groupId,
name: ch.name,
platform: ch.platform,
rateMultiplier: Number(ch.rateMultiplier),
description: ch.description,
models: ch.models ? JSON.parse(ch.models) : [],
features: ch.features ? JSON.parse(ch.features) : [],
sortOrder: ch.sortOrder,
};
}),
);
return NextResponse.json({ channels: results.filter(Boolean) });
} catch (error) {
console.error('Failed to list channels:', error);
return NextResponse.json({ error: '获取渠道列表失败' }, { status: 500 });
}
}

View File

@@ -29,6 +29,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
expiresAt: true,
paidAt: true,
completedAt: true,
failedReason: true,
},
});
@@ -45,5 +46,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
paymentSuccess: derived.paymentSuccess,
rechargeSuccess: derived.rechargeSuccess,
rechargeStatus: derived.rechargeStatus,
failedReason: order.failedReason ?? null,
});
}

View File

@@ -13,6 +13,8 @@ const createOrderSchema = z.object({
src_host: z.string().max(253).optional(),
src_url: z.string().max(2048).optional(),
is_mobile: z.boolean().optional(),
order_type: z.enum(['balance', 'subscription']).optional(),
plan_id: z.string().optional(),
});
export async function POST(request: NextRequest) {
@@ -25,7 +27,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
}
const { token, amount, payment_type, src_host, src_url, is_mobile } = parsed.data;
const { token, amount, payment_type, src_host, src_url, is_mobile, order_type, plan_id } = parsed.data;
// 通过 token 解析用户身份
let userId: number;
@@ -36,13 +38,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '无效的 token请重新登录', code: 'INVALID_TOKEN' }, { status: 401 });
}
// Validate amount range
// 订阅订单跳过金额范围校验(价格由服务端套餐决定)
if (order_type !== 'subscription') {
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
return NextResponse.json(
{ error: `充值金额需在 ${env.MIN_RECHARGE_AMOUNT} - ${env.MAX_RECHARGE_AMOUNT} 之间` },
{ status: 400 },
);
}
}
// Validate payment type is enabled
if (!paymentRegistry.getSupportedTypes().includes(payment_type)) {
@@ -60,6 +64,8 @@ export async function POST(request: NextRequest) {
isMobile: is_mobile,
srcHost: src_host,
srcUrl: src_url,
orderType: order_type,
planId: plan_id,
});
// 不向客户端暴露 userName / userBalance 等隐私字段

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getCurrentUserByToken, getGroup } from '@/lib/sub2api/client';
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')?.trim();
if (!token) {
return NextResponse.json({ error: '缺少 token' }, { status: 401 });
}
try {
await getCurrentUserByToken(token);
} catch {
return NextResponse.json({ error: '无效的 token' }, { status: 401 });
}
try {
const plans = await prisma.subscriptionPlan.findMany({
where: { forSale: true },
orderBy: { sortOrder: 'asc' },
});
// 并发校验每个套餐对应的 Sub2API 分组是否存在
const results = await Promise.all(
plans.map(async (plan) => {
let groupActive = false;
let groupInfo: { daily_limit_usd: number | null; weekly_limit_usd: number | null; monthly_limit_usd: number | null } | null = null;
try {
const group = await getGroup(plan.groupId);
groupActive = group !== null && group.status === 'active';
if (group) {
groupInfo = {
daily_limit_usd: group.daily_limit_usd,
weekly_limit_usd: group.weekly_limit_usd,
monthly_limit_usd: group.monthly_limit_usd,
};
}
} catch {
groupActive = false;
}
if (!groupActive) return null;
return {
id: plan.id,
groupId: plan.groupId,
name: plan.name,
description: plan.description,
price: Number(plan.price),
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
validityDays: plan.validityDays,
features: plan.features ? JSON.parse(plan.features) : [],
limits: groupInfo,
};
}),
);
return NextResponse.json({ plans: results.filter(Boolean) });
} catch (error) {
console.error('Failed to list subscription plans:', error);
return NextResponse.json({ error: '获取订阅套餐失败' }, { status: 500 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUserByToken, getUserSubscriptions } from '@/lib/sub2api/client';
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')?.trim();
if (!token) {
return NextResponse.json({ error: '缺少 token' }, { status: 401 });
}
let userId: number;
try {
const user = await getCurrentUserByToken(token);
userId = user.id;
} catch {
return NextResponse.json({ error: '无效的 token' }, { status: 401 });
}
try {
const subscriptions = await getUserSubscriptions(userId);
return NextResponse.json({ subscriptions });
} catch (error) {
console.error('Failed to get user subscriptions:', error);
return NextResponse.json({ error: '获取订阅信息失败' }, { status: 500 });
}
}

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,36 +491,48 @@ 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={[
<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(' ')}
>
].join(' ')}>
{error}
</div>
)}
{step === 'form' && isMobile && (
<div
className={[
{/* ── 表单阶段 ── */}
{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(' ')}
>
].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',
? 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')}
@@ -423,12 +543,8 @@ function PayContent() {
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',
? 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')}
@@ -436,7 +552,8 @@ function PayContent() {
</div>
)}
{step === 'form' && config.enabledPaymentTypes.length === 0 && (
{/* 加载中 */}
{!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(' ')}>
@@ -445,7 +562,156 @@ function PayContent() {
</div>
)}
{step === 'form' && config.enabledPaymentTypes.length > 0 && (
{/* ── 有渠道配置新版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}
userBalance={userInfo?.balance}
enabledPaymentTypes={config.enabledPaymentTypes}
methodLimits={config.methodLimits}
minAmount={config.minAmount}
maxAmount={config.maxAmount}
onSubmit={(_, paymentType) => handleSubmit(topUpAmount, paymentType)}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
locale={locale}
fixedAmount={topUpAmount}
/>
</div>
)}
{/* 订阅确认页 */}
{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' ? (
@@ -496,67 +762,29 @@ function PayContent() {
/>
</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={['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>
<li>{pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically')}</li>
<li>{pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for history')}</li>
{config.maxDailyAmount > 0 && (
<li>
{pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥
{config.maxDailyAmount.toFixed(2)}
</li>
<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={['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"
/>
<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 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>
@@ -567,6 +795,23 @@ function PayContent() {
</>
)}
{/* 移动端订单列表 */}
{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>

View File

@@ -0,0 +1,156 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
export interface ChannelInfo {
id: string;
groupId: number;
name: string;
platform: string;
rateMultiplier: number;
description: string | null;
models: string[];
features: string[];
}
interface ChannelCardProps {
channel: ChannelInfo;
onTopUp: () => void;
isDark: boolean;
locale: Locale;
userBalance?: number;
}
const PLATFORM_STYLES: Record<string, { bg: string; text: string }> = {
claude: { bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300' },
openai: { bg: 'bg-green-100 dark:bg-green-900/40', text: 'text-green-700 dark:text-green-300' },
gemini: { bg: 'bg-purple-100 dark:bg-purple-900/40', text: 'text-purple-700 dark:text-purple-300' },
codex: { bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300' },
sora: { bg: 'bg-pink-100 dark:bg-pink-900/40', text: 'text-pink-700 dark:text-pink-300' },
};
function getPlatformStyle(platform: string, isDark: boolean): { bg: string; text: string } {
const key = platform.toLowerCase();
const match = PLATFORM_STYLES[key];
if (match) {
return {
bg: isDark ? match.bg.split(' ')[1]?.replace('dark:', '') || match.bg.split(' ')[0] : match.bg.split(' ')[0],
text: isDark
? match.text.split(' ')[1]?.replace('dark:', '') || match.text.split(' ')[0]
: match.text.split(' ')[0],
};
}
return {
bg: isDark ? 'bg-slate-700' : 'bg-slate-100',
text: isDark ? 'text-slate-300' : 'text-slate-600',
};
}
export default function ChannelCard({ channel, onTopUp, isDark, locale, userBalance }: ChannelCardProps) {
const platformStyle = getPlatformStyle(channel.platform, isDark);
const usableQuota = (1 / channel.rateMultiplier).toFixed(2);
return (
<div
className={[
'flex flex-col rounded-2xl border p-5 transition-shadow hover:shadow-lg',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
].join(' ')}
>
{/* Header: Platform badge + Name */}
<div className="mb-3 flex items-center gap-2">
<span className={['rounded-full px-2.5 py-0.5 text-xs font-medium', platformStyle.bg, platformStyle.text].join(' ')}>
{channel.platform}
</span>
<h3 className={['text-lg font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{channel.name}
</h3>
</div>
{/* Rate display */}
<div className="mb-1 flex items-baseline gap-1.5">
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, '当前倍率', 'Rate')}
</span>
<span className="text-base font-semibold text-emerald-500">
1 : {channel.rateMultiplier}
</span>
</div>
{userBalance !== undefined && (
<p className={['mb-3 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{pickLocaleText(
locale,
`1元可用约${usableQuota}美元额度`,
`1 CNY ≈ ${usableQuota} USD quota`,
)}
</p>
)}
{/* Description */}
{channel.description && (
<p className={['mb-3 text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{channel.description}
</p>
)}
{/* Models */}
{channel.models.length > 0 && (
<div className="mb-3">
<p className={['mb-1.5 text-xs font-medium uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{pickLocaleText(locale, '支持模型', 'Supported Models')}
</p>
<div className="flex flex-wrap gap-1.5">
{channel.models.map((model) => (
<span
key={model}
className={[
'rounded-md px-2 py-0.5 text-xs',
isDark ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600',
].join(' ')}
>
{model}
</span>
))}
</div>
</div>
)}
{/* Features */}
{channel.features.length > 0 && (
<div className="mb-4">
<p className={['mb-1.5 text-xs font-medium uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{pickLocaleText(locale, '功能特性', 'Features')}
</p>
<div className="flex flex-wrap gap-1.5">
{channel.features.map((feature) => (
<span
key={feature}
className={[
'rounded-md px-2 py-0.5 text-xs',
isDark ? 'bg-emerald-900/30 text-emerald-300' : 'bg-emerald-50 text-emerald-700',
].join(' ')}
>
{feature}
</span>
))}
</div>
</div>
)}
{/* Spacer to push button to bottom */}
<div className="flex-1" />
{/* Top-up button */}
<button
type="button"
onClick={onTopUp}
className="mt-2 w-full rounded-xl bg-emerald-500 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-emerald-600 active:bg-emerald-700"
>
{pickLocaleText(locale, '立即充值', 'Top Up Now')}
</button>
</div>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import ChannelCard from '@/components/ChannelCard';
import type { ChannelInfo } from '@/components/ChannelCard';
interface ChannelGridProps {
channels: ChannelInfo[];
onTopUp: () => void;
isDark: boolean;
locale: Locale;
userBalance?: number;
}
export type { ChannelInfo };
export default function ChannelGrid({ channels, onTopUp, isDark, locale, userBalance }: ChannelGridProps) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{channels.map((channel) => (
<ChannelCard
key={channel.id}
channel={channel}
onTopUp={onTopUp}
isDark={isDark}
locale={locale}
userBalance={userBalance}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
interface MainTabsProps {
activeTab: 'topup' | 'subscribe';
onTabChange: (tab: 'topup' | 'subscribe') => void;
showSubscribeTab: boolean;
isDark: boolean;
locale: Locale;
}
export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, isDark, locale }: MainTabsProps) {
if (!showSubscribeTab) return null;
const tabs: { key: 'topup' | 'subscribe'; label: string }[] = [
{ key: 'topup', label: pickLocaleText(locale, '按量付费', 'Pay-as-you-go') },
{ key: 'subscribe', label: pickLocaleText(locale, '包月套餐', 'Subscription') },
];
return (
<div
className={[
'inline-flex rounded-xl p-1',
isDark ? 'bg-slate-800' : 'bg-slate-100',
].join(' ')}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
className={[
'rounded-lg px-5 py-2 text-sm font-medium transition-all',
isActive
? isDark
? 'bg-slate-700 text-slate-100 shadow-sm'
: 'bg-white text-slate-900 shadow-sm'
: isDark
? 'text-slate-400 hover:text-slate-200'
: 'text-slate-500 hover:text-slate-700',
].join(' ')}
>
{tab.label}
</button>
);
})}
</div>
);
}

View File

@@ -27,6 +27,8 @@ interface PaymentFormProps {
pendingBlocked?: boolean;
pendingCount?: number;
locale?: Locale;
/** 固定金额模式:隐藏金额选择,只显示支付方式和提交按钮 */
fixedAmount?: number;
}
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
@@ -50,10 +52,11 @@ export default function PaymentForm({
pendingBlocked = false,
pendingCount = 0,
locale = 'zh',
fixedAmount,
}: PaymentFormProps) {
const [amount, setAmount] = useState<number | ''>('');
const [amount, setAmount] = useState<number | ''>(fixedAmount ?? '');
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
const [customAmount, setCustomAmount] = useState('');
const [customAmount, setCustomAmount] = useState(fixedAmount ? String(fixedAmount) : '');
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
? paymentType
@@ -166,6 +169,20 @@ export default function PaymentForm({
)}
</div>
{fixedAmount ? (
<div className={[
'rounded-xl border p-4 text-center',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
].join(' ')}>
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
</div>
<div className={['mt-1 text-3xl font-bold', dark ? 'text-emerald-400' : 'text-emerald-600'].join(' ')}>
¥{fixedAmount.toFixed(2)}
</div>
</div>
) : (
<>
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
@@ -218,8 +235,10 @@ export default function PaymentForm({
/>
</div>
</div>
</>
)}
{customAmount !== '' &&
{!fixedAmount && customAmount !== '' &&
!isValid &&
(() => {
const num = parseFloat(customAmount);

View File

@@ -0,0 +1,134 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
interface PurchaseFlowProps {
isDark: boolean;
locale: Locale;
}
interface Step {
icon: React.ReactNode;
zh: string;
en: string;
}
const STEPS: Step[] = [
{
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
zh: '选择套餐',
en: 'Select Plan',
},
{
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
),
zh: '完成支付',
en: 'Complete Payment',
},
{
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
),
zh: '获取激活码',
en: 'Get Activation',
},
{
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
zh: '激活使用',
en: 'Start Using',
},
];
export default function PurchaseFlow({ isDark, locale }: PurchaseFlowProps) {
return (
<div
className={[
'rounded-2xl border p-6',
isDark ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<h3 className={['mb-5 text-center text-sm font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, '购买流程', 'How It Works')}
</h3>
{/* Desktop: horizontal */}
<div className="hidden items-center justify-center sm:flex">
{STEPS.map((step, idx) => (
<React.Fragment key={idx}>
{/* Step */}
<div className="flex flex-col items-center gap-2">
<div
className={[
'flex h-12 w-12 items-center justify-center rounded-full',
isDark ? 'bg-emerald-900/40 text-emerald-400' : 'bg-emerald-100 text-emerald-600',
].join(' ')}
>
{step.icon}
</div>
<span className={['text-xs font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{pickLocaleText(locale, step.zh, step.en)}
</span>
</div>
{/* Connector */}
{idx < STEPS.length - 1 && (
<div
className={[
'mx-4 h-px w-12 flex-shrink-0',
isDark ? 'bg-slate-700' : 'bg-slate-300',
].join(' ')}
/>
)}
</React.Fragment>
))}
</div>
{/* Mobile: vertical */}
<div className="flex flex-col items-start gap-0 sm:hidden">
{STEPS.map((step, idx) => (
<React.Fragment key={idx}>
{/* Step */}
<div className="flex items-center gap-3">
<div
className={[
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
isDark ? 'bg-emerald-900/40 text-emerald-400' : 'bg-emerald-100 text-emerald-600',
].join(' ')}
>
{step.icon}
</div>
<span className={['text-sm font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{pickLocaleText(locale, step.zh, step.en)}
</span>
</div>
{/* Connector */}
{idx < STEPS.length - 1 && (
<div
className={[
'ml-5 h-6 w-px',
isDark ? 'bg-slate-700' : 'bg-slate-300',
].join(' ')}
/>
)}
</React.Fragment>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
import { getPaymentTypeLabel, getPaymentIconSrc } from '@/lib/pay-utils';
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
interface SubscriptionConfirmProps {
plan: PlanInfo;
paymentTypes: string[];
onBack: () => void;
onSubmit: (paymentType: string) => void;
loading: boolean;
isDark: boolean;
locale: Locale;
}
export default function SubscriptionConfirm({
plan,
paymentTypes,
onBack,
onSubmit,
loading,
isDark,
locale,
}: SubscriptionConfirmProps) {
const [selectedPayment, setSelectedPayment] = useState(paymentTypes[0] || '');
const periodLabel =
plan.validityDays === 30
? pickLocaleText(locale, '包月', 'Monthly')
: pickLocaleText(locale, `${plan.validityDays}`, `${plan.validityDays} Days`);
const handleSubmit = () => {
if (selectedPayment && !loading) {
onSubmit(selectedPayment);
}
};
return (
<div className="mx-auto max-w-lg space-y-6">
{/* Back link */}
<button
type="button"
onClick={onBack}
className={[
'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 to Plans')}
</button>
{/* Title */}
<h2 className={['text-xl font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{pickLocaleText(locale, '确认订单', 'Confirm Order')}
</h2>
{/* Plan info card */}
<div
className={[
'rounded-xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{plan.name}
</span>
<span
className={[
'rounded-full px-2 py-0.5 text-xs font-medium',
isDark ? 'bg-emerald-900/40 text-emerald-300' : 'bg-emerald-50 text-emerald-700',
].join(' ')}
>
{periodLabel}
</span>
</div>
</div>
{plan.features.length > 0 && (
<ul className="space-y-1">
{plan.features.map((feature) => (
<li key={feature} className={['flex items-center gap-1.5 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
<svg className="h-3.5 w-3.5 shrink-0 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{feature}
</li>
))}
</ul>
)}
</div>
{/* Payment method selector */}
<div>
<label className={['mb-2 block text-sm font-medium', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{pickLocaleText(locale, '支付方式', 'Payment Method')}
</label>
<div className="space-y-2">
{paymentTypes.map((type) => {
const isSelected = selectedPayment === type;
const iconSrc = getPaymentIconSrc(type);
return (
<button
key={type}
type="button"
onClick={() => setSelectedPayment(type)}
className={[
'flex w-full items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all',
isSelected
? 'border-emerald-500 ring-1 ring-emerald-500/30'
: isDark
? 'border-slate-700 hover:border-slate-600'
: 'border-slate-200 hover:border-slate-300',
isSelected
? isDark
? 'bg-emerald-950/30'
: 'bg-emerald-50/50'
: isDark
? 'bg-slate-800/60'
: 'bg-white',
].join(' ')}
>
{/* Radio indicator */}
<span
className={[
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2',
isSelected ? 'border-emerald-500' : isDark ? 'border-slate-600' : 'border-slate-300',
].join(' ')}
>
{isSelected && <span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />}
</span>
{/* Icon */}
{iconSrc && (
<Image src={iconSrc} alt="" width={24} height={24} className="h-6 w-6 shrink-0 object-contain" />
)}
{/* Label */}
<span className={['text-sm font-medium', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{getPaymentTypeLabel(type, locale)}
</span>
</button>
);
})}
</div>
</div>
{/* Amount to pay */}
<div
className={[
'flex items-center justify-between rounded-xl border px-4 py-3',
isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<span className={['text-sm font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{pickLocaleText(locale, '应付金额', 'Amount Due')}
</span>
<span className="text-xl font-bold text-emerald-500">¥{plan.price}</span>
</div>
{/* Submit button */}
<button
type="button"
disabled={!selectedPayment || loading}
onClick={handleSubmit}
className={[
'w-full rounded-xl py-3 text-sm font-bold text-white transition-colors',
selectedPayment && !loading
? 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700'
: isDark
? 'cursor-not-allowed bg-slate-700 text-slate-400'
: 'cursor-not-allowed bg-slate-200 text-slate-400',
].join(' ')}
>
{loading
? pickLocaleText(locale, '处理中...', 'Processing...')
: pickLocaleText(locale, '立即购买', 'Buy Now')}
</button>
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
export interface PlanInfo {
id: string;
groupId: number;
name: string;
price: number;
originalPrice: number | null;
validityDays: number;
features: string[];
description: string | null;
limits: {
daily_limit_usd: number | null;
weekly_limit_usd: number | null;
monthly_limit_usd: number | null;
} | null;
}
interface SubscriptionPlanCardProps {
plan: PlanInfo;
onSubscribe: (planId: string) => void;
isDark: boolean;
locale: Locale;
}
export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale }: SubscriptionPlanCardProps) {
const periodLabel =
plan.validityDays === 30
? pickLocaleText(locale, '包月', 'Monthly')
: pickLocaleText(locale, `${plan.validityDays}`, `${plan.validityDays} Days`);
const periodSuffix =
plan.validityDays === 30
? pickLocaleText(locale, '/月', '/mo')
: pickLocaleText(locale, `/${plan.validityDays}`, `/${plan.validityDays}d`);
return (
<div
className={[
'flex flex-col rounded-2xl border p-5 transition-shadow hover:shadow-lg',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
].join(' ')}
>
{/* Name + Period badge */}
<div className="mb-3 flex items-center gap-2">
<h3 className={['text-lg font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{plan.name}
</h3>
<span
className={[
'rounded-full px-2.5 py-0.5 text-xs font-medium',
isDark ? 'bg-emerald-900/40 text-emerald-300' : 'bg-emerald-50 text-emerald-700',
].join(' ')}
>
{periodLabel}
</span>
</div>
{/* Price */}
<div className="mb-4 flex items-baseline gap-2">
{plan.originalPrice !== null && (
<span className={['text-sm line-through', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
¥{plan.originalPrice}
</span>
)}
<span className="text-3xl font-bold text-emerald-500">¥{plan.price}</span>
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{periodSuffix}
</span>
</div>
{/* Description */}
{plan.description && (
<p className={['mb-3 text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{plan.description}
</p>
)}
{/* Features */}
{plan.features.length > 0 && (
<ul className="mb-4 space-y-2">
{plan.features.map((feature) => (
<li key={feature} className={['flex items-start gap-2 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
<svg className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{feature}
</li>
))}
</ul>
)}
{/* Limits */}
{plan.limits && (
<div className={['mb-4 rounded-lg p-3 text-xs', isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-slate-50 text-slate-500'].join(' ')}>
<p className="mb-1 font-medium uppercase tracking-wide">
{pickLocaleText(locale, '用量限制', 'Usage Limits')}
</p>
<div className="space-y-0.5">
{plan.limits.daily_limit_usd !== null && (
<p>{pickLocaleText(locale, `每日: $${plan.limits.daily_limit_usd}`, `Daily: $${plan.limits.daily_limit_usd}`)}</p>
)}
{plan.limits.weekly_limit_usd !== null && (
<p>{pickLocaleText(locale, `每周: $${plan.limits.weekly_limit_usd}`, `Weekly: $${plan.limits.weekly_limit_usd}`)}</p>
)}
{plan.limits.monthly_limit_usd !== null && (
<p>{pickLocaleText(locale, `每月: $${plan.limits.monthly_limit_usd}`, `Monthly: $${plan.limits.monthly_limit_usd}`)}</p>
)}
</div>
</div>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Subscribe button */}
<button
type="button"
onClick={() => onSubscribe(plan.id)}
className="mt-2 w-full rounded-xl bg-emerald-500 py-2.5 text-sm font-bold text-white transition-colors hover:bg-emerald-600 active:bg-emerald-700"
>
{pickLocaleText(locale, '立即开通', 'Subscribe Now')}
</button>
</div>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import React, { useState } from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
interface TopUpModalProps {
open: boolean;
onClose: () => void;
onConfirm: (amount: number) => void;
amounts?: number[];
isDark: boolean;
locale: Locale;
}
const DEFAULT_AMOUNTS = [50, 100, 500, 1000];
export default function TopUpModal({ open, onClose, onConfirm, amounts, isDark, locale }: TopUpModalProps) {
const amountOptions = amounts ?? DEFAULT_AMOUNTS;
const [selected, setSelected] = useState<number | null>(null);
if (!open) return null;
const handleConfirm = () => {
if (selected !== null) {
onConfirm(selected);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div
className={[
'relative mx-4 w-full max-w-md rounded-2xl border p-6 shadow-2xl',
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-slate-200 bg-white text-slate-900',
].join(' ')}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold">
{pickLocaleText(locale, '选择充值金额', 'Select Amount')}
</h2>
<button
type="button"
onClick={onClose}
className={[
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
isDark ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600',
].join(' ')}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Amount grid */}
<div className="mb-6 grid grid-cols-2 gap-3">
{amountOptions.map((amount) => {
const isSelected = selected === amount;
return (
<button
key={amount}
type="button"
onClick={() => setSelected(amount)}
className={[
'flex flex-col items-center rounded-xl border-2 px-4 py-4 transition-all',
isSelected
? 'border-emerald-500 ring-2 ring-emerald-500/30'
: isDark
? 'border-slate-700 hover:border-slate-600'
: 'border-slate-200 hover:border-slate-300',
isSelected
? isDark
? 'bg-emerald-950/40'
: 'bg-emerald-50'
: isDark
? 'bg-slate-800/60'
: 'bg-slate-50',
].join(' ')}
>
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, `余额充值${amount}$`, `Balance +${amount}$`)}
</span>
<span className="mt-1 text-2xl font-bold text-emerald-500">
¥{amount}
</span>
</button>
);
})}
</div>
{/* Confirm button */}
<button
type="button"
disabled={selected === null}
onClick={handleConfirm}
className={[
'w-full rounded-xl py-3 text-sm font-semibold text-white transition-colors',
selected !== null
? 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700'
: isDark
? 'cursor-not-allowed bg-slate-700 text-slate-400'
: 'cursor-not-allowed bg-slate-200 text-slate-400',
].join(' ')}
>
{pickLocaleText(locale, '确认充值', 'Confirm')}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
export interface UserSub {
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;
}
interface UserSubscriptionsProps {
subscriptions: UserSub[];
onRenew: (groupId: number) => void;
isDark: boolean;
locale: Locale;
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
function daysUntil(iso: string): number {
const now = new Date();
const target = new Date(iso);
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
function getStatusBadge(status: string, isDark: boolean, locale: Locale): { text: string; className: string } {
const statusMap: Record<string, { zh: string; en: string; cls: string; clsDark: string }> = {
active: { zh: '生效中', en: 'Active', cls: 'bg-emerald-100 text-emerald-700', clsDark: 'bg-emerald-900/40 text-emerald-300' },
expired: { zh: '已过期', en: 'Expired', cls: 'bg-slate-100 text-slate-600', clsDark: 'bg-slate-700 text-slate-400' },
cancelled: { zh: '已取消', en: 'Cancelled', cls: 'bg-red-100 text-red-700', clsDark: 'bg-red-900/40 text-red-300' },
};
const entry = statusMap[status] || { zh: status, en: status, cls: 'bg-slate-100 text-slate-600', clsDark: 'bg-slate-700 text-slate-400' };
return {
text: pickLocaleText(locale, entry.zh, entry.en),
className: isDark ? entry.clsDark : entry.cls,
};
}
export default function UserSubscriptions({ subscriptions, onRenew, isDark, locale }: UserSubscriptionsProps) {
if (subscriptions.length === 0) {
return (
<div
className={[
'flex flex-col items-center justify-center rounded-2xl border py-16',
isDark ? 'border-slate-700 bg-slate-800/50 text-slate-400' : 'border-slate-200 bg-slate-50 text-slate-500',
].join(' ')}
>
<svg className="mb-3 h-12 w-12 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm">{pickLocaleText(locale, '暂无订阅', 'No Subscriptions')}</p>
</div>
);
}
return (
<div className="space-y-4">
{subscriptions.map((sub) => {
const remaining = daysUntil(sub.expires_at);
const isExpiringSoon = remaining > 0 && remaining <= 7;
const badge = getStatusBadge(sub.status, isDark, locale);
return (
<div
key={sub.id}
className={[
'rounded-2xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
].join(' ')}
>
{/* Header */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{pickLocaleText(locale, `渠道 #${sub.group_id}`, `Channel #${sub.group_id}`)}
</span>
<span className={['rounded-full px-2 py-0.5 text-xs font-medium', badge.className].join(' ')}>
{badge.text}
</span>
</div>
{sub.status === 'active' && (
<button
type="button"
onClick={() => onRenew(sub.group_id)}
className="rounded-lg bg-emerald-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-emerald-600 active:bg-emerald-700"
>
{pickLocaleText(locale, '续费', 'Renew')}
</button>
)}
</div>
{/* Dates */}
<div className={['mb-3 grid grid-cols-2 gap-3 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
<div>
<span className="text-xs uppercase tracking-wide">{pickLocaleText(locale, '开始', 'Start')}</span>
<p className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
{formatDate(sub.starts_at)}
</p>
</div>
<div>
<span className="text-xs uppercase tracking-wide">{pickLocaleText(locale, '到期', 'Expires')}</span>
<p className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
{formatDate(sub.expires_at)}
</p>
</div>
</div>
{/* Expiry warning */}
{isExpiringSoon && (
<div
className={[
'mb-3 rounded-lg px-3 py-2 text-xs font-medium',
isDark ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700',
].join(' ')}
>
{pickLocaleText(
locale,
`即将到期,剩余 ${remaining}`,
`Expiring soon, ${remaining} days remaining`,
)}
</div>
)}
{/* Usage stats */}
<div
className={[
'grid grid-cols-3 gap-2 rounded-lg p-3 text-center text-xs',
isDark ? 'bg-slate-900/60' : 'bg-slate-50',
].join(' ')}
>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
{pickLocaleText(locale, '日用量', 'Daily')}
</span>
<p className={['mt-0.5 font-semibold', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
${sub.daily_usage_usd.toFixed(2)}
</p>
</div>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
{pickLocaleText(locale, '周用量', 'Weekly')}
</span>
<p className={['mt-0.5 font-semibold', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
${sub.weekly_usage_usd.toFixed(2)}
</p>
</div>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
{pickLocaleText(locale, '月用量', 'Monthly')}
</span>
<p className={['mt-0.5 font-semibold', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
${sub.monthly_usage_usd.toFixed(2)}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { getMethodDailyLimit } from './limits';
import { getMethodFeeRate, calculatePayAmount } from './fee';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import type { PaymentType, PaymentNotification } from '@/lib/payment';
import { getUser, createAndRedeem, subtractBalance, addBalance } from '@/lib/sub2api/client';
import { getUser, createAndRedeem, subtractBalance, addBalance, getGroup, assignSubscription } from '@/lib/sub2api/client';
import { Prisma } from '@prisma/client';
import { deriveOrderState, isRefundStatus } from './status';
import { pickLocaleText, type Locale } from '@/lib/locale';
@@ -28,6 +28,9 @@ export interface CreateOrderInput {
srcHost?: string;
srcUrl?: string;
locale?: Locale;
// 订阅订单专用
orderType?: 'balance' | 'subscription';
planId?: string;
}
export interface CreateOrderResult {
@@ -50,6 +53,31 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
const env = getEnv();
const locale = input.locale ?? 'zh';
const todayStart = getBizDayStartUTC();
const orderType = input.orderType ?? 'balance';
// ── 订阅订单前置校验 ──
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; name: string } | null = null;
if (orderType === 'subscription') {
if (!input.planId) {
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
}
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: input.planId } });
if (!plan || !plan.forSale) {
throw new OrderError('PLAN_NOT_AVAILABLE', message(locale, '该套餐不存在或未上架', 'Plan not found or not for sale'), 404);
}
// 校验 Sub2API 分组仍然存在
const group = await getGroup(plan.groupId);
if (!group || group.status !== 'active') {
throw new OrderError(
'GROUP_NOT_FOUND',
message(locale, '订阅分组已下架,无法购买', 'Subscription group is no longer available'),
410,
);
}
subscriptionPlan = plan;
// 订阅订单金额使用服务端套餐价格,不信任客户端
input.amount = Number(plan.price);
}
const user = await getUser(input.userId);
if (user.status !== 'active') {
@@ -149,6 +177,10 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
clientIp: input.clientIp,
srcHost: input.srcHost || null,
srcUrl: input.srcUrl || null,
orderType,
planId: subscriptionPlan?.id ?? null,
subscriptionGroupId: subscriptionPlan?.groupId ?? null,
subscriptionDays: subscriptionPlan?.validityDays ?? null,
},
});
@@ -200,7 +232,13 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
data: {
orderId: order.id,
action: 'ORDER_CREATED',
detail: JSON.stringify({ userId: input.userId, amount: input.amount, paymentType: input.paymentType }),
detail: JSON.stringify({
userId: input.userId,
amount: input.amount,
paymentType: input.paymentType,
orderType,
...(subscriptionPlan && { planId: subscriptionPlan.id, planName: subscriptionPlan.name, groupId: subscriptionPlan.groupId }),
}),
operator: `user:${input.userId}`,
},
});
@@ -453,10 +491,10 @@ export async function confirmPayment(input: {
// FAILED 状态 — 之前充值失败,利用重试通知自动重试充值
if (current.status === ORDER_STATUS.FAILED) {
try {
await executeRecharge(order.id);
await executeFulfillment(order.id);
return true;
} catch (err) {
console.error('Recharge retry failed for order:', order.id, err);
console.error('Fulfillment retry failed for order:', order.id, err);
return false; // 让支付平台继续重试
}
}
@@ -485,9 +523,9 @@ export async function confirmPayment(input: {
});
try {
await executeRecharge(order.id);
await executeFulfillment(order.id);
} catch (err) {
console.error('Recharge failed for order:', order.id, err);
console.error('Fulfillment failed for order:', order.id, err);
return false;
}
@@ -512,6 +550,107 @@ export async function handlePaymentNotify(notification: PaymentNotification, pro
});
}
/**
* 统一履约入口 — 根据 orderType 分派到余额充值或订阅分配。
*/
export async function executeFulfillment(orderId: string): Promise<void> {
const order = await prisma.order.findUnique({
where: { id: orderId },
select: { orderType: true },
});
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
if (order.orderType === 'subscription') {
await executeSubscriptionFulfillment(orderId);
} else {
await executeRecharge(orderId);
}
}
/**
* 订阅履约 — 支付成功后调用 Sub2API 分配订阅。
*/
export async function executeSubscriptionFulfillment(orderId: string): Promise<void> {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
if (order.status === ORDER_STATUS.COMPLETED) return;
if (isRefundStatus(order.status)) {
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot fulfill', 400);
}
if (order.status !== ORDER_STATUS.PAID && order.status !== ORDER_STATUS.FAILED) {
throw new OrderError('INVALID_STATUS', `Order cannot fulfill in status ${order.status}`, 400);
}
if (!order.subscriptionGroupId || !order.subscriptionDays) {
throw new OrderError('INVALID_STATUS', 'Missing subscription info on order', 400);
}
// CAS 锁
const lockResult = await prisma.order.updateMany({
where: { id: orderId, status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.FAILED] } },
data: { status: ORDER_STATUS.RECHARGING },
});
if (lockResult.count === 0) return;
try {
// 校验分组是否仍然存在
const group = await getGroup(order.subscriptionGroupId);
if (!group || group.status !== 'active') {
throw new Error(`Subscription group ${order.subscriptionGroupId} no longer exists or inactive`);
}
await assignSubscription(
order.userId,
order.subscriptionGroupId,
order.subscriptionDays,
`sub2apipay subscription order:${orderId}`,
`sub2apipay:subscription:${order.rechargeCode}`,
);
await prisma.order.updateMany({
where: { id: orderId, status: ORDER_STATUS.RECHARGING },
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
});
await prisma.auditLog.create({
data: {
orderId,
action: 'SUBSCRIPTION_SUCCESS',
detail: JSON.stringify({
groupId: order.subscriptionGroupId,
days: order.subscriptionDays,
amount: Number(order.amount),
}),
operator: 'system',
},
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
const isGroupGone = reason.includes('no longer exists');
await prisma.order.update({
where: { id: orderId },
data: {
status: ORDER_STATUS.FAILED,
failedAt: new Date(),
failedReason: isGroupGone
? `SUBSCRIPTION_GROUP_GONE: ${reason}`
: reason,
},
});
await prisma.auditLog.create({
data: {
orderId,
action: 'SUBSCRIPTION_FAILED',
detail: reason,
operator: 'system',
},
});
throw error;
}
}
export async function executeRecharge(orderId: string): Promise<void> {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) {
@@ -698,7 +837,7 @@ export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Pro
},
});
await executeRecharge(orderId);
await executeFulfillment(orderId);
}
export interface RefundInput {

View File

@@ -18,6 +18,7 @@ export interface PublicOrderStatusSnapshot extends DerivedOrderState {
id: string;
status: string;
expiresAt: Date | string;
failedReason?: string | null;
}
export interface OrderDisplayState {

View File

@@ -1,5 +1,5 @@
import { getEnv } from '@/lib/config';
import type { Sub2ApiUser, Sub2ApiRedeemCode } from './types';
import type { Sub2ApiUser, Sub2ApiRedeemCode, Sub2ApiGroup, Sub2ApiSubscription } from './types';
const DEFAULT_TIMEOUT_MS = 10_000;
const RECHARGE_TIMEOUT_MS = 30_000;
@@ -101,6 +101,103 @@ export async function createAndRedeem(
throw lastError instanceof Error ? lastError : new Error('Recharge failed');
}
// ── 分组 API ──
export async function getAllGroups(): Promise<Sub2ApiGroup[]> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/groups/all`, {
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Failed to get groups: ${response.status}`);
}
const data = await response.json();
return (data.data ?? []) as Sub2ApiGroup[];
}
export async function getGroup(groupId: number): Promise<Sub2ApiGroup | null> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/groups/${groupId}`, {
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Failed to get group ${groupId}: ${response.status}`);
}
const data = await response.json();
return data.data as Sub2ApiGroup;
}
// ── 订阅 API ──
export async function assignSubscription(
userId: number,
groupId: number,
validityDays: number,
notes?: string,
idempotencyKey?: string,
): Promise<Sub2ApiSubscription> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/subscriptions/assign`, {
method: 'POST',
headers: getHeaders(idempotencyKey),
body: JSON.stringify({
user_id: userId,
group_id: groupId,
validity_days: validityDays,
notes: notes || `Sub2ApiPay subscription order`,
}),
signal: AbortSignal.timeout(RECHARGE_TIMEOUT_MS),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Assign subscription failed (${response.status}): ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return data.data as Sub2ApiSubscription;
}
export async function getUserSubscriptions(userId: number): Promise<Sub2ApiSubscription[]> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/subscriptions`, {
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
if (response.status === 404) return [];
throw new Error(`Failed to get user subscriptions: ${response.status}`);
}
const data = await response.json();
return (data.data ?? []) as Sub2ApiSubscription[];
}
export async function extendSubscription(subscriptionId: number, days: number): Promise<void> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/subscriptions/${subscriptionId}/extend`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ days }),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Extend subscription failed (${response.status}): ${JSON.stringify(errorData)}`);
}
}
// ── 余额 API ──
export async function subtractBalance(
userId: number,
amount: number,

View File

@@ -22,3 +22,43 @@ export interface Sub2ApiResponse<T> {
data?: T;
message?: string;
}
// ── 分组 ──
export interface Sub2ApiGroup {
id: number;
name: string;
description: string;
platform: string;
status: string;
rate_multiplier: number;
subscription_type: string; // "standard" | "subscription"
daily_limit_usd: number | null;
weekly_limit_usd: number | null;
monthly_limit_usd: number | null;
default_validity_days: number;
sort_order: number;
supported_model_scopes: string[] | null;
}
// ── 订阅 ──
export interface Sub2ApiSubscription {
id: number;
user_id: number;
group_id: number;
starts_at: string;
expires_at: string;
status: string; // "active" | "expired" | "suspended"
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;
assigned_by: number;
assigned_at: string;
notes: string | null;
created_at: string;
updated_at: string;
}

119
src/lib/system-config.ts Normal file
View File

@@ -0,0 +1,119 @@
import { prisma } from '@/lib/db';
// 内存缓存key → { value, expiresAt }
const cache = new Map<string, { value: string; expiresAt: number }>();
const CACHE_TTL_MS = 30_000; // 30 秒
function getCached(key: string): string | undefined {
const entry = cache.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return undefined;
}
return entry.value;
}
function setCache(key: string, value: string): void {
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
}
export function invalidateConfigCache(key?: string): void {
if (key) {
cache.delete(key);
} else {
cache.clear();
}
}
export async function getSystemConfig(key: string): Promise<string | undefined> {
const cached = getCached(key);
if (cached !== undefined) return cached;
const row = await prisma.systemConfig.findUnique({ where: { key } });
if (row) {
setCache(key, row.value);
return row.value;
}
// 回退到环境变量
const envVal = process.env[key];
if (envVal !== undefined) {
setCache(key, envVal);
}
return envVal;
}
export async function getSystemConfigs(keys: string[]): Promise<Record<string, string>> {
const result: Record<string, string> = {};
const missing: string[] = [];
for (const key of keys) {
const cached = getCached(key);
if (cached !== undefined) {
result[key] = cached;
} else {
missing.push(key);
}
}
if (missing.length > 0) {
const rows = await prisma.systemConfig.findMany({
where: { key: { in: missing } },
});
const dbMap = new Map(rows.map((r) => [r.key, r.value]));
for (const key of missing) {
const val = dbMap.get(key) ?? process.env[key];
if (val !== undefined) {
result[key] = val;
setCache(key, val);
}
}
}
return result;
}
export async function setSystemConfig(key: string, value: string, group?: string, label?: string): Promise<void> {
await prisma.systemConfig.upsert({
where: { key },
update: { value, ...(group !== undefined && { group }), ...(label !== undefined && { label }) },
create: { key, value, group: group ?? 'general', label },
});
invalidateConfigCache(key);
}
export async function setSystemConfigs(configs: { key: string; value: string; group?: string; label?: string }[]): Promise<void> {
await prisma.$transaction(
configs.map((c) =>
prisma.systemConfig.upsert({
where: { key: c.key },
update: { value: c.value, ...(c.group !== undefined && { group: c.group }), ...(c.label !== undefined && { label: c.label }) },
create: { key: c.key, value: c.value, group: c.group ?? 'general', label: c.label },
}),
),
);
invalidateConfigCache();
}
export async function getSystemConfigsByGroup(group: string): Promise<{ key: string; value: string; label: string | null }[]> {
return prisma.systemConfig.findMany({
where: { group },
select: { key: true, value: true, label: true },
orderBy: { key: 'asc' },
});
}
export async function getAllSystemConfigs(): Promise<{ key: string; value: string; group: string; label: string | null }[]> {
return prisma.systemConfig.findMany({
select: { key: true, value: true, group: true, label: true },
orderBy: [{ group: 'asc' }, { key: 'asc' }],
});
}
export async function deleteSystemConfig(key: string): Promise<void> {
await prisma.systemConfig.delete({ where: { key } }).catch(() => {});
invalidateConfigCache(key);
}