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:
BIN
docs/refrence/channel-conf.png
Normal file
BIN
docs/refrence/channel-conf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/refrence/subscribe-main.png
Normal file
BIN
docs/refrence/subscribe-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/refrence/subscribe.png
Normal file
BIN
docs/refrence/subscribe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/refrence/top-up-main.png
Normal file
BIN
docs/refrence/top-up-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
BIN
docs/refrence/top-up.png
Normal file
BIN
docs/refrence/top-up.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
958
src/app/admin/channels/page.tsx
Normal file
958
src/app/admin/channels/page.tsx
Normal 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 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 Vision 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
74
src/app/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
788
src/app/admin/settings/page.tsx
Normal file
788
src/app/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
907
src/app/admin/subscriptions/page.tsx
Normal file
907
src/app/admin/subscriptions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/app/api/admin/channels/[id]/route.ts
Normal file
74
src/app/api/admin/channels/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
84
src/app/api/admin/channels/route.ts
Normal file
84
src/app/api/admin/channels/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
63
src/app/api/admin/config/route.ts
Normal file
63
src/app/api/admin/config/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
15
src/app/api/admin/sub2api/groups/route.ts
Normal file
15
src/app/api/admin/sub2api/groups/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
90
src/app/api/admin/subscription-plans/[id]/route.ts
Normal file
90
src/app/api/admin/subscription-plans/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
89
src/app/api/admin/subscription-plans/route.ts
Normal file
89
src/app/api/admin/subscription-plans/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/admin/subscriptions/route.ts
Normal file
34
src/app/api/admin/subscriptions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
56
src/app/api/channels/route.ts
Normal file
56
src/app/api/channels/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,12 +38,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '无效的 token,请重新登录', code: 'INVALID_TOKEN' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validate amount range
|
||||
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 },
|
||||
);
|
||||
// 订阅订单跳过金额范围校验(价格由服务端套餐决定)
|
||||
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
|
||||
@@ -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 等隐私字段
|
||||
|
||||
63
src/app/api/subscription-plans/route.ts
Normal file
63
src/app/api/subscription-plans/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
25
src/app/api/subscriptions/my/route.ts
Normal file
25
src/app/api/subscriptions/my/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useState, useEffect, Suspense, useCallback } from 'react';
|
||||
import PaymentForm from '@/components/PaymentForm';
|
||||
import PaymentQRCode from '@/components/PaymentQRCode';
|
||||
import OrderStatus from '@/components/OrderStatus';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import MobileOrderList from '@/components/MobileOrderList';
|
||||
import MainTabs from '@/components/MainTabs';
|
||||
import ChannelGrid from '@/components/ChannelGrid';
|
||||
import TopUpModal from '@/components/TopUpModal';
|
||||
import SubscriptionPlanCard from '@/components/SubscriptionPlanCard';
|
||||
import SubscriptionConfirm from '@/components/SubscriptionConfirm';
|
||||
import UserSubscriptions from '@/components/UserSubscriptions';
|
||||
import PurchaseFlow from '@/components/PurchaseFlow';
|
||||
import { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale';
|
||||
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import type { MethodLimitInfo } from '@/components/PaymentForm';
|
||||
import type { ChannelInfo } from '@/components/ChannelGrid';
|
||||
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
|
||||
import type { UserSub } from '@/components/UserSubscriptions';
|
||||
|
||||
interface OrderResult {
|
||||
orderId: string;
|
||||
@@ -52,6 +62,7 @@ function PayContent() {
|
||||
const [step, setStep] = useState<'form' | 'paying' | 'result'>('form');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [subscriptionError, setSubscriptionError] = useState('');
|
||||
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
|
||||
const [finalOrderState, setFinalOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
@@ -63,6 +74,15 @@ function PayContent() {
|
||||
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
// 新增状态
|
||||
const [mainTab, setMainTab] = useState<'topup' | 'subscribe'>('topup');
|
||||
const [channels, setChannels] = useState<ChannelInfo[]>([]);
|
||||
const [plans, setPlans] = useState<PlanInfo[]>([]);
|
||||
const [userSubscriptions, setUserSubscriptions] = useState<UserSub[]>([]);
|
||||
const [topUpModalOpen, setTopUpModalOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanInfo | null>(null);
|
||||
const [channelsLoaded, setChannelsLoaded] = useState(false);
|
||||
|
||||
const [config, setConfig] = useState<AppConfig>({
|
||||
enabledPaymentTypes: [],
|
||||
minAmount: 1,
|
||||
@@ -80,9 +100,13 @@ function PayContent() {
|
||||
const MAX_PENDING = 3;
|
||||
const pendingBlocked = pendingCount >= MAX_PENDING;
|
||||
|
||||
// 是否有渠道配置(决定是直接显示充值表单还是渠道卡片+弹窗)
|
||||
const hasChannels = channels.length > 0;
|
||||
// 是否有可售卖套餐
|
||||
const hasPlans = plans.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
setIsIframeContext(window.self !== window.top);
|
||||
setIsMobile(detectDeviceIsMobile());
|
||||
}, []);
|
||||
@@ -96,9 +120,8 @@ function PayContent() {
|
||||
setActiveMobileTab('pay');
|
||||
}, [isMobile, step, tab]);
|
||||
|
||||
const loadUserAndOrders = async () => {
|
||||
const loadUserAndOrders = useCallback(async () => {
|
||||
if (!token) return;
|
||||
|
||||
setUserNotFound(false);
|
||||
try {
|
||||
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
||||
@@ -157,7 +180,34 @@ function PayContent() {
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
}, [token, locale]);
|
||||
|
||||
// 加载渠道和订阅套餐
|
||||
const loadChannelsAndPlans = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const [chRes, plRes, subRes] = await Promise.all([
|
||||
fetch(`/api/channels?token=${encodeURIComponent(token)}`),
|
||||
fetch(`/api/subscription-plans?token=${encodeURIComponent(token)}`),
|
||||
fetch(`/api/subscriptions/my?token=${encodeURIComponent(token)}`),
|
||||
]);
|
||||
|
||||
if (chRes.ok) {
|
||||
const chData = await chRes.json();
|
||||
setChannels(chData.channels ?? []);
|
||||
}
|
||||
if (plRes.ok) {
|
||||
const plData = await plRes.json();
|
||||
setPlans(plData.plans ?? []);
|
||||
}
|
||||
if (subRes.ok) {
|
||||
const subData = await subRes.json();
|
||||
setUserSubscriptions(subData.subscriptions ?? []);
|
||||
}
|
||||
} catch {} finally {
|
||||
setChannelsLoaded(true);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const loadMoreOrders = async () => {
|
||||
if (!token || ordersLoadingMore || !ordersHasMore) return;
|
||||
@@ -182,19 +232,40 @@ function PayContent() {
|
||||
|
||||
useEffect(() => {
|
||||
loadUserAndOrders();
|
||||
}, [token, locale]);
|
||||
loadChannelsAndPlans();
|
||||
}, [loadUserAndOrders, loadChannelsAndPlans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'result' || finalOrderState?.status !== 'COMPLETED') return;
|
||||
loadUserAndOrders();
|
||||
loadChannelsAndPlans();
|
||||
const timer = setTimeout(() => {
|
||||
setStep('form');
|
||||
setOrderResult(null);
|
||||
setFinalOrderState(null);
|
||||
setError('');
|
||||
setSubscriptionError('');
|
||||
setSelectedPlan(null);
|
||||
}, 2200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [step, finalOrderState]);
|
||||
}, [step, finalOrderState, loadUserAndOrders, loadChannelsAndPlans]);
|
||||
|
||||
// 检查订单完成后是否是订阅分组消失的情况
|
||||
useEffect(() => {
|
||||
if (step !== 'result' || !finalOrderState) return;
|
||||
if (
|
||||
finalOrderState.status === 'FAILED' &&
|
||||
finalOrderState.failedReason?.includes('SUBSCRIPTION_GROUP_GONE')
|
||||
) {
|
||||
setSubscriptionError(
|
||||
pickLocaleText(
|
||||
locale,
|
||||
'您已成功支付,但订阅分组已下架,无法自动开通。请联系客服处理,提供订单号。',
|
||||
'Payment successful, but the subscription group has been removed. Please contact support with your order ID.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [step, finalOrderState, locale]);
|
||||
|
||||
if (!hasToken) {
|
||||
return (
|
||||
@@ -202,11 +273,7 @@ function PayContent() {
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'请从 Sub2API 平台正确访问充值页面',
|
||||
'Please open the recharge page from the Sub2API platform',
|
||||
)}
|
||||
{pickLocaleText(locale, '请从 Sub2API 平台正确访问充值页面', 'Please open the recharge page from the Sub2API platform')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,11 +286,7 @@ function PayContent() {
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'请检查链接是否正确,或联系管理员',
|
||||
'Please check whether the link is correct or contact the administrator',
|
||||
)}
|
||||
{pickLocaleText(locale, '请检查链接是否正确,或联系管理员', 'Please check whether the link is correct or contact the administrator')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,6 +309,7 @@ function PayContent() {
|
||||
const mobileOrdersUrl = buildScopedUrl('/pay', true);
|
||||
const ordersUrl = isMobile ? mobileOrdersUrl : pcOrdersUrl;
|
||||
|
||||
// ── 余额充值提交 ──
|
||||
const handleSubmit = async (amount: number, paymentType: string) => {
|
||||
if (pendingBlocked) {
|
||||
setError(
|
||||
@@ -279,33 +343,15 @@ function PayContent() {
|
||||
|
||||
if (!res.ok) {
|
||||
const codeMessages: Record<string, string> = {
|
||||
INVALID_TOKEN: pickLocaleText(
|
||||
locale,
|
||||
'认证已失效,请重新从平台进入充值页面',
|
||||
'Authentication expired. Please re-enter the recharge page from the platform',
|
||||
),
|
||||
USER_INACTIVE: pickLocaleText(
|
||||
locale,
|
||||
'账户已被禁用,无法充值,请联系管理员',
|
||||
'This account is disabled and cannot be recharged. Please contact the administrator',
|
||||
),
|
||||
TOO_MANY_PENDING: pickLocaleText(
|
||||
locale,
|
||||
'您有过多待支付订单,请先完成或取消现有订单后再试',
|
||||
'You have too many pending orders. Please complete or cancel existing orders first',
|
||||
),
|
||||
USER_NOT_FOUND: pickLocaleText(
|
||||
locale,
|
||||
'用户不存在,请检查链接是否正确',
|
||||
'User not found. Please check whether the link is correct',
|
||||
),
|
||||
INVALID_TOKEN: pickLocaleText(locale, '认证已失效,请重新从平台进入充值页面', 'Authentication expired'),
|
||||
USER_INACTIVE: pickLocaleText(locale, '账户已被禁用,无法充值', 'Account is disabled'),
|
||||
TOO_MANY_PENDING: pickLocaleText(locale, '待支付订单过多,请先处理', 'Too many pending orders'),
|
||||
USER_NOT_FOUND: pickLocaleText(locale, '用户不存在', 'User not found'),
|
||||
DAILY_LIMIT_EXCEEDED: data.error,
|
||||
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
|
||||
PAYMENT_GATEWAY_ERROR: data.error,
|
||||
};
|
||||
setError(
|
||||
codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'),
|
||||
);
|
||||
setError(codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -321,10 +367,66 @@ function PayContent() {
|
||||
expiresAt: data.expiresAt,
|
||||
statusAccessToken: data.statusAccessToken,
|
||||
});
|
||||
|
||||
setTopUpModalOpen(false);
|
||||
setStep('paying');
|
||||
} catch {
|
||||
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error. Please try again later'));
|
||||
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 充值弹窗确认 → 进入支付方式选择(复用 PaymentForm) ──
|
||||
const [topUpAmount, setTopUpAmount] = useState<number | null>(null);
|
||||
|
||||
const handleTopUpConfirm = (amount: number) => {
|
||||
setTopUpAmount(amount);
|
||||
setTopUpModalOpen(false);
|
||||
};
|
||||
|
||||
// ── 订阅下单 ──
|
||||
const handleSubscriptionSubmit = async (paymentType: string) => {
|
||||
if (!selectedPlan) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
amount: selectedPlan.price,
|
||||
payment_type: paymentType,
|
||||
is_mobile: isMobile,
|
||||
src_host: srcHost,
|
||||
src_url: srcUrl,
|
||||
order_type: 'subscription',
|
||||
plan_id: selectedPlan.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || pickLocaleText(locale, '创建订阅订单失败', 'Failed to create subscription order'));
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderResult({
|
||||
orderId: data.orderId,
|
||||
amount: data.amount,
|
||||
payAmount: data.payAmount,
|
||||
status: data.status,
|
||||
paymentType: data.paymentType || paymentType,
|
||||
payUrl: data.payUrl,
|
||||
qrCode: data.qrCode,
|
||||
clientSecret: data.clientSecret,
|
||||
expiresAt: data.expiresAt,
|
||||
statusAccessToken: data.statusAccessToken,
|
||||
});
|
||||
setStep('paying');
|
||||
} catch {
|
||||
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -333,9 +435,7 @@ function PayContent() {
|
||||
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
|
||||
setFinalOrderState(order);
|
||||
setStep('result');
|
||||
if (isMobile) {
|
||||
setActiveMobileTab('orders');
|
||||
}
|
||||
if (isMobile) setActiveMobileTab('orders');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -343,27 +443,37 @@ function PayContent() {
|
||||
setOrderResult(null);
|
||||
setFinalOrderState(null);
|
||||
setError('');
|
||||
setSubscriptionError('');
|
||||
setSelectedPlan(null);
|
||||
setTopUpAmount(null);
|
||||
};
|
||||
|
||||
// ── 渲染 ──
|
||||
const showMainTabs = channelsLoaded && (hasChannels || hasPlans);
|
||||
const pageTitle = showMainTabs
|
||||
? pickLocaleText(locale, '选择适合你的 订阅套餐', 'Choose Your Plan')
|
||||
: pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge');
|
||||
const pageSubtitle = showMainTabs
|
||||
? pickLocaleText(locale, '通过支付购买或兑换码激活获取订阅服务', 'Subscribe via payment or activation code')
|
||||
: pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting');
|
||||
|
||||
return (
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
maxWidth={isMobile ? 'sm' : 'lg'}
|
||||
title={pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge')}
|
||||
subtitle={pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting')}
|
||||
maxWidth={showMainTabs ? 'full' : isMobile ? 'sm' : 'lg'}
|
||||
title={pageTitle}
|
||||
subtitle={pageSubtitle}
|
||||
locale={locale}
|
||||
actions={
|
||||
!isMobile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadUserAndOrders}
|
||||
onClick={() => { loadUserAndOrders(); loadChannelsAndPlans(); }}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '刷新', 'Refresh')}
|
||||
@@ -372,9 +482,7 @@ function PayContent() {
|
||||
href={ordersUrl}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||
@@ -383,72 +491,193 @@ function PayContent() {
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{/* 订阅分组消失的常驻错误 */}
|
||||
{subscriptionError && (
|
||||
<div className={[
|
||||
'mb-4 rounded-lg border-2 p-4 text-sm',
|
||||
isDark ? 'border-red-600 bg-red-900/40 text-red-300' : 'border-red-400 bg-red-50 text-red-700',
|
||||
].join(' ')}>
|
||||
<div className="font-semibold mb-1">{pickLocaleText(locale, '订阅开通失败', 'Subscription Failed')}</div>
|
||||
<div>{subscriptionError}</div>
|
||||
{orderResult && (
|
||||
<div className="mt-2 text-xs opacity-80">
|
||||
{pickLocaleText(locale, '订单号', 'Order ID')}: {orderResult.orderId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className={[
|
||||
'mb-4 rounded-lg border p-3 text-sm',
|
||||
isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={[
|
||||
'mb-4 rounded-lg border p-3 text-sm',
|
||||
isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
||||
].join(' ')}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && isMobile && (
|
||||
<div
|
||||
className={[
|
||||
'mb-4 grid grid-cols-2 rounded-xl border p-1',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-300 bg-slate-100/90',
|
||||
].join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMobileTab('pay')}
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'pay'
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '充值', 'Recharge')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMobileTab('orders')}
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'orders'
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && config.enabledPaymentTypes.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && config.enabledPaymentTypes.length > 0 && (
|
||||
{/* ── 表单阶段 ── */}
|
||||
{step === 'form' && (
|
||||
<>
|
||||
{isMobile ? (
|
||||
activeMobileTab === 'pay' ? (
|
||||
{/* 移动端 Tab:充值/订单 */}
|
||||
{isMobile && (
|
||||
<div className={[
|
||||
'mb-4 grid grid-cols-2 rounded-xl border p-1',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-300 bg-slate-100/90',
|
||||
].join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMobileTab('pay')}
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'pay'
|
||||
? isDark ? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm' : 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '充值', 'Recharge')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMobileTab('orders')}
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'orders'
|
||||
? isDark ? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm' : 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{!channelsLoaded && config.enabledPaymentTypes.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 有渠道配置:新版UI ── */}
|
||||
{channelsLoaded && showMainTabs && (activeMobileTab === 'pay' || !isMobile) && !selectedPlan && !topUpAmount && (
|
||||
<>
|
||||
<MainTabs activeTab={mainTab} onTabChange={setMainTab} showSubscribeTab={hasPlans} isDark={isDark} locale={locale} />
|
||||
|
||||
{mainTab === 'topup' && (
|
||||
<div className="mt-6">
|
||||
{/* 按量付费说明 banner */}
|
||||
<div className={[
|
||||
'mb-6 rounded-xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={['text-2xl'].join(' ')}>💰</div>
|
||||
<div>
|
||||
<div className={['font-semibold', isDark ? 'text-emerald-400' : 'text-emerald-600'].join(' ')}>
|
||||
{pickLocaleText(locale, '按量付费模式', 'Pay-as-you-go')}
|
||||
</div>
|
||||
<div className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'无需订阅,充值即用,按实际消耗扣费,余额所有渠道通用。',
|
||||
'No subscription needed. Top up and use. Charged by actual usage. Balance works across all channels.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChannelGrid
|
||||
channels={channels}
|
||||
onTopUp={() => setTopUpModalOpen(true)}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
userBalance={userInfo?.balance}
|
||||
/>
|
||||
|
||||
{/* 用户已有订阅 */}
|
||||
{userSubscriptions.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className={['text-lg font-semibold mb-3', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
{pickLocaleText(locale, '我的订阅', 'My Subscriptions')}
|
||||
</h3>
|
||||
<UserSubscriptions
|
||||
subscriptions={userSubscriptions}
|
||||
onRenew={(groupId) => {
|
||||
const plan = plans.find((p) => p.groupId === groupId);
|
||||
if (plan) {
|
||||
setSelectedPlan(plan);
|
||||
setMainTab('subscribe');
|
||||
}
|
||||
}}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mainTab === 'subscribe' && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{plans.map((plan) => (
|
||||
<SubscriptionPlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onSubscribe={() => setSelectedPlan(plan)}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 用户已有订阅 */}
|
||||
{userSubscriptions.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3 className={['text-lg font-semibold mb-3', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
{pickLocaleText(locale, '我的订阅', 'My Subscriptions')}
|
||||
</h3>
|
||||
<UserSubscriptions
|
||||
subscriptions={userSubscriptions}
|
||||
onRenew={(groupId) => {
|
||||
const plan = plans.find((p) => p.groupId === groupId);
|
||||
if (plan) setSelectedPlan(plan);
|
||||
}}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PurchaseFlow isDark={isDark} locale={locale} />
|
||||
|
||||
<TopUpModal
|
||||
open={topUpModalOpen}
|
||||
onClose={() => setTopUpModalOpen(false)}
|
||||
onConfirm={handleTopUpConfirm}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 充值弹窗确认后:选择支付方式 */}
|
||||
{topUpAmount && step === 'form' && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTopUpAmount(null)}
|
||||
className={['mb-4 text-sm', isDark ? 'text-emerald-400 hover:text-emerald-300' : 'text-emerald-600 hover:text-emerald-500'].join(' ')}
|
||||
>
|
||||
← {pickLocaleText(locale, '返回', 'Back')}
|
||||
</button>
|
||||
<PaymentForm
|
||||
userId={resolvedUserId ?? 0}
|
||||
userName={userInfo?.username}
|
||||
@@ -457,116 +686,132 @@ function PayContent() {
|
||||
methodLimits={config.methodLimits}
|
||||
minAmount={config.minAmount}
|
||||
maxAmount={config.maxAmount}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={(_, paymentType) => handleSubmit(topUpAmount, paymentType)}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
fixedAmount={topUpAmount}
|
||||
/>
|
||||
) : (
|
||||
<MobileOrderList
|
||||
isDark={isDark}
|
||||
hasToken={hasToken}
|
||||
orders={myOrders}
|
||||
hasMore={ordersHasMore}
|
||||
loadingMore={ordersLoadingMore}
|
||||
onRefresh={loadUserAndOrders}
|
||||
onLoadMore={loadMoreOrders}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(300px,0.8fr)]">
|
||||
<div className="min-w-0">
|
||||
<PaymentForm
|
||||
userId={resolvedUserId ?? 0}
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
methodLimits={config.methodLimits}
|
||||
minAmount={config.minAmount}
|
||||
maxAmount={config.maxAmount}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
|
||||
</div>
|
||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<li>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'订单完成后会自动到账',
|
||||
'Balance will be credited automatically after the order completes',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'如需历史记录请查看「我的订单」',
|
||||
'Check "My Orders" for payment history',
|
||||
)}
|
||||
</li>
|
||||
{config.maxDailyAmount > 0 && (
|
||||
<li>
|
||||
{pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥
|
||||
{config.maxDailyAmount.toFixed(2)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasHelpContent && (
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '帮助', 'Support')}
|
||||
{/* 订阅确认页 */}
|
||||
{selectedPlan && step === 'form' && (
|
||||
<SubscriptionConfirm
|
||||
plan={selectedPlan}
|
||||
paymentTypes={config.enabledPaymentTypes}
|
||||
onBack={() => setSelectedPlan(null)}
|
||||
onSubmit={handleSubscriptionSubmit}
|
||||
loading={loading}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 无渠道配置:传统充值UI ── */}
|
||||
{channelsLoaded && !showMainTabs && config.enabledPaymentTypes.length > 0 && !topUpAmount && !selectedPlan && (
|
||||
<>
|
||||
{isMobile ? (
|
||||
activeMobileTab === 'pay' ? (
|
||||
<PaymentForm
|
||||
userId={resolvedUserId ?? 0}
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
methodLimits={config.methodLimits}
|
||||
minAmount={config.minAmount}
|
||||
maxAmount={config.maxAmount}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
/>
|
||||
) : (
|
||||
<MobileOrderList
|
||||
isDark={isDark}
|
||||
hasToken={hasToken}
|
||||
orders={myOrders}
|
||||
hasMore={ordersHasMore}
|
||||
loadingMore={ordersLoadingMore}
|
||||
onRefresh={loadUserAndOrders}
|
||||
onLoadMore={loadMoreOrders}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(300px,0.8fr)]">
|
||||
<div className="min-w-0">
|
||||
<PaymentForm
|
||||
userId={resolvedUserId ?? 0}
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
methodLimits={config.methodLimits}
|
||||
minAmount={config.minAmount}
|
||||
maxAmount={config.maxAmount}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
|
||||
</div>
|
||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<li>{pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically')}</li>
|
||||
<li>{pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for history')}</li>
|
||||
{config.maxDailyAmount > 0 && (
|
||||
<li>{pickLocaleText(locale, '每日最大充值', 'Max daily recharge')} ¥{config.maxDailyAmount.toFixed(2)}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{helpImageUrl && (
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt="help"
|
||||
onClick={() => setHelpImageOpen(true)}
|
||||
className="mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2"
|
||||
/>
|
||||
)}
|
||||
{helpText && (
|
||||
<div
|
||||
className={[
|
||||
'mt-3 space-y-1 text-sm leading-6',
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{helpText.split('\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
{hasHelpContent && (
|
||||
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '帮助', 'Support')}
|
||||
</div>
|
||||
{helpImageUrl && (
|
||||
<img src={helpImageUrl} alt="help" onClick={() => setHelpImageOpen(true)} className="mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2" />
|
||||
)}
|
||||
{helpText && (
|
||||
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{helpText.split('\n').map((line, i) => (<p key={i}>{line}</p>))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 移动端订单列表 */}
|
||||
{isMobile && activeMobileTab === 'orders' && showMainTabs && (
|
||||
<MobileOrderList
|
||||
isDark={isDark}
|
||||
hasToken={hasToken}
|
||||
orders={myOrders}
|
||||
hasMore={ordersHasMore}
|
||||
loadingMore={ordersLoadingMore}
|
||||
onRefresh={loadUserAndOrders}
|
||||
onLoadMore={loadMoreOrders}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 支付阶段 ── */}
|
||||
{step === 'paying' && orderResult && (
|
||||
<PaymentQRCode
|
||||
orderId={orderResult.orderId}
|
||||
@@ -589,6 +834,7 @@ function PayContent() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 结果阶段 ── */}
|
||||
{step === 'result' && orderResult && finalOrderState && (
|
||||
<OrderStatus
|
||||
orderId={orderResult.orderId}
|
||||
@@ -601,17 +847,10 @@ function PayContent() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 帮助图片放大 */}
|
||||
{helpImageOpen && helpImageUrl && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
||||
onClick={() => setHelpImageOpen(false)}
|
||||
>
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt="help"
|
||||
className="max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm" onClick={() => setHelpImageOpen(false)}>
|
||||
<img src={helpImageUrl} alt="help" className="max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl" onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
)}
|
||||
</PayPageLayout>
|
||||
@@ -621,7 +860,6 @@ function PayContent() {
|
||||
function PayPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
|
||||
156
src/components/ChannelCard.tsx
Normal file
156
src/components/ChannelCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/ChannelGrid.tsx
Normal file
33
src/components/ChannelGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/MainTabs.tsx
Normal file
54
src/components/MainTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,60 +169,76 @@ export default function PaymentForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => handleQuickAmount(val)}
|
||||
className={`rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors ${
|
||||
amount === val
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
¥{val}
|
||||
</button>
|
||||
))}
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => handleQuickAmount(val)}
|
||||
className={`rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors ${
|
||||
amount === val
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
¥{val}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
¥
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={minAmount}
|
||||
max={effectiveMax}
|
||||
value={customAmount}
|
||||
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
||||
placeholder={`${minAmount} - ${effectiveMax}`}
|
||||
className={[
|
||||
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
|
||||
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
||||
].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
¥
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={minAmount}
|
||||
max={effectiveMax}
|
||||
value={customAmount}
|
||||
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
||||
placeholder={`${minAmount} - ${effectiveMax}`}
|
||||
className={[
|
||||
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
|
||||
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
||||
].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{customAmount !== '' &&
|
||||
{!fixedAmount && customAmount !== '' &&
|
||||
!isValid &&
|
||||
(() => {
|
||||
const num = parseFloat(customAmount);
|
||||
|
||||
134
src/components/PurchaseFlow.tsx
Normal file
134
src/components/PurchaseFlow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
src/components/SubscriptionConfirm.tsx
Normal file
188
src/components/SubscriptionConfirm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/SubscriptionPlanCard.tsx
Normal file
130
src/components/SubscriptionPlanCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/components/TopUpModal.tsx
Normal file
113
src/components/TopUpModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/components/UserSubscriptions.tsx
Normal file
172
src/components/UserSubscriptions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface PublicOrderStatusSnapshot extends DerivedOrderState {
|
||||
id: string;
|
||||
status: string;
|
||||
expiresAt: Date | string;
|
||||
failedReason?: string | null;
|
||||
}
|
||||
|
||||
export interface OrderDisplayState {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
119
src/lib/system-config.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user