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")
|
srcHost String? @map("src_host")
|
||||||
srcUrl String? @map("src_url")
|
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[]
|
auditLogs AuditLog[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -48,6 +55,7 @@ model Order {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([paidAt])
|
@@index([paidAt])
|
||||||
@@index([paymentType, paidAt])
|
@@index([paymentType, paidAt])
|
||||||
|
@@index([orderType])
|
||||||
@@map("orders")
|
@@map("orders")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,3 +85,55 @@ model AuditLog {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("audit_logs")
|
@@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,
|
expiresAt: true,
|
||||||
paidAt: true,
|
paidAt: true,
|
||||||
completedAt: true,
|
completedAt: true,
|
||||||
|
failedReason: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,5 +46,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
paymentSuccess: derived.paymentSuccess,
|
paymentSuccess: derived.paymentSuccess,
|
||||||
rechargeSuccess: derived.rechargeSuccess,
|
rechargeSuccess: derived.rechargeSuccess,
|
||||||
rechargeStatus: derived.rechargeStatus,
|
rechargeStatus: derived.rechargeStatus,
|
||||||
|
failedReason: order.failedReason ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const createOrderSchema = z.object({
|
|||||||
src_host: z.string().max(253).optional(),
|
src_host: z.string().max(253).optional(),
|
||||||
src_url: z.string().max(2048).optional(),
|
src_url: z.string().max(2048).optional(),
|
||||||
is_mobile: z.boolean().optional(),
|
is_mobile: z.boolean().optional(),
|
||||||
|
order_type: z.enum(['balance', 'subscription']).optional(),
|
||||||
|
plan_id: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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 });
|
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 解析用户身份
|
// 通过 token 解析用户身份
|
||||||
let userId: number;
|
let userId: number;
|
||||||
@@ -36,12 +38,14 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '无效的 token,请重新登录', code: 'INVALID_TOKEN' }, { status: 401 });
|
return NextResponse.json({ error: '无效的 token,请重新登录', code: 'INVALID_TOKEN' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate amount range
|
// 订阅订单跳过金额范围校验(价格由服务端套餐决定)
|
||||||
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
|
if (order_type !== 'subscription') {
|
||||||
return NextResponse.json(
|
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
|
||||||
{ error: `充值金额需在 ${env.MIN_RECHARGE_AMOUNT} - ${env.MAX_RECHARGE_AMOUNT} 之间` },
|
return NextResponse.json(
|
||||||
{ status: 400 },
|
{ error: `充值金额需在 ${env.MIN_RECHARGE_AMOUNT} - ${env.MAX_RECHARGE_AMOUNT} 之间` },
|
||||||
);
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate payment type is enabled
|
// Validate payment type is enabled
|
||||||
@@ -60,6 +64,8 @@ export async function POST(request: NextRequest) {
|
|||||||
isMobile: is_mobile,
|
isMobile: is_mobile,
|
||||||
srcHost: src_host,
|
srcHost: src_host,
|
||||||
srcUrl: src_url,
|
srcUrl: src_url,
|
||||||
|
orderType: order_type,
|
||||||
|
planId: plan_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 不向客户端暴露 userName / userBalance 等隐私字段
|
// 不向客户端暴露 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';
|
'use client';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
import { useState, useEffect, Suspense, useCallback } from 'react';
|
||||||
import PaymentForm from '@/components/PaymentForm';
|
import PaymentForm from '@/components/PaymentForm';
|
||||||
import PaymentQRCode from '@/components/PaymentQRCode';
|
import PaymentQRCode from '@/components/PaymentQRCode';
|
||||||
import OrderStatus from '@/components/OrderStatus';
|
import OrderStatus from '@/components/OrderStatus';
|
||||||
import PayPageLayout from '@/components/PayPageLayout';
|
import PayPageLayout from '@/components/PayPageLayout';
|
||||||
import MobileOrderList from '@/components/MobileOrderList';
|
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 { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale';
|
||||||
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||||
import type { MethodLimitInfo } from '@/components/PaymentForm';
|
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 {
|
interface OrderResult {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
@@ -52,6 +62,7 @@ function PayContent() {
|
|||||||
const [step, setStep] = useState<'form' | 'paying' | 'result'>('form');
|
const [step, setStep] = useState<'form' | 'paying' | 'result'>('form');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [subscriptionError, setSubscriptionError] = useState('');
|
||||||
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
|
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
|
||||||
const [finalOrderState, setFinalOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
|
const [finalOrderState, setFinalOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
|
||||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||||
@@ -63,6 +74,15 @@ function PayContent() {
|
|||||||
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
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>({
|
const [config, setConfig] = useState<AppConfig>({
|
||||||
enabledPaymentTypes: [],
|
enabledPaymentTypes: [],
|
||||||
minAmount: 1,
|
minAmount: 1,
|
||||||
@@ -80,9 +100,13 @@ function PayContent() {
|
|||||||
const MAX_PENDING = 3;
|
const MAX_PENDING = 3;
|
||||||
const pendingBlocked = pendingCount >= MAX_PENDING;
|
const pendingBlocked = pendingCount >= MAX_PENDING;
|
||||||
|
|
||||||
|
// 是否有渠道配置(决定是直接显示充值表单还是渠道卡片+弹窗)
|
||||||
|
const hasChannels = channels.length > 0;
|
||||||
|
// 是否有可售卖套餐
|
||||||
|
const hasPlans = plans.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
setIsIframeContext(window.self !== window.top);
|
setIsIframeContext(window.self !== window.top);
|
||||||
setIsMobile(detectDeviceIsMobile());
|
setIsMobile(detectDeviceIsMobile());
|
||||||
}, []);
|
}, []);
|
||||||
@@ -96,9 +120,8 @@ function PayContent() {
|
|||||||
setActiveMobileTab('pay');
|
setActiveMobileTab('pay');
|
||||||
}, [isMobile, step, tab]);
|
}, [isMobile, step, tab]);
|
||||||
|
|
||||||
const loadUserAndOrders = async () => {
|
const loadUserAndOrders = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
setUserNotFound(false);
|
setUserNotFound(false);
|
||||||
try {
|
try {
|
||||||
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
||||||
@@ -157,7 +180,34 @@ function PayContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} 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 () => {
|
const loadMoreOrders = async () => {
|
||||||
if (!token || ordersLoadingMore || !ordersHasMore) return;
|
if (!token || ordersLoadingMore || !ordersHasMore) return;
|
||||||
@@ -182,19 +232,40 @@ function PayContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUserAndOrders();
|
loadUserAndOrders();
|
||||||
}, [token, locale]);
|
loadChannelsAndPlans();
|
||||||
|
}, [loadUserAndOrders, loadChannelsAndPlans]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 'result' || finalOrderState?.status !== 'COMPLETED') return;
|
if (step !== 'result' || finalOrderState?.status !== 'COMPLETED') return;
|
||||||
loadUserAndOrders();
|
loadUserAndOrders();
|
||||||
|
loadChannelsAndPlans();
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setStep('form');
|
setStep('form');
|
||||||
setOrderResult(null);
|
setOrderResult(null);
|
||||||
setFinalOrderState(null);
|
setFinalOrderState(null);
|
||||||
setError('');
|
setError('');
|
||||||
|
setSubscriptionError('');
|
||||||
|
setSelectedPlan(null);
|
||||||
}, 2200);
|
}, 2200);
|
||||||
return () => clearTimeout(timer);
|
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) {
|
if (!hasToken) {
|
||||||
return (
|
return (
|
||||||
@@ -202,11 +273,7 @@ function PayContent() {
|
|||||||
<div className="text-center text-red-500">
|
<div className="text-center text-red-500">
|
||||||
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
|
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
{pickLocaleText(
|
{pickLocaleText(locale, '请从 Sub2API 平台正确访问充值页面', 'Please open the recharge page from the Sub2API platform')}
|
||||||
locale,
|
|
||||||
'请从 Sub2API 平台正确访问充值页面',
|
|
||||||
'Please open the recharge page from the Sub2API platform',
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,11 +286,7 @@ function PayContent() {
|
|||||||
<div className="text-center text-red-500">
|
<div className="text-center text-red-500">
|
||||||
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
|
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
{pickLocaleText(
|
{pickLocaleText(locale, '请检查链接是否正确,或联系管理员', 'Please check whether the link is correct or contact the administrator')}
|
||||||
locale,
|
|
||||||
'请检查链接是否正确,或联系管理员',
|
|
||||||
'Please check whether the link is correct or contact the administrator',
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,6 +309,7 @@ function PayContent() {
|
|||||||
const mobileOrdersUrl = buildScopedUrl('/pay', true);
|
const mobileOrdersUrl = buildScopedUrl('/pay', true);
|
||||||
const ordersUrl = isMobile ? mobileOrdersUrl : pcOrdersUrl;
|
const ordersUrl = isMobile ? mobileOrdersUrl : pcOrdersUrl;
|
||||||
|
|
||||||
|
// ── 余额充值提交 ──
|
||||||
const handleSubmit = async (amount: number, paymentType: string) => {
|
const handleSubmit = async (amount: number, paymentType: string) => {
|
||||||
if (pendingBlocked) {
|
if (pendingBlocked) {
|
||||||
setError(
|
setError(
|
||||||
@@ -279,33 +343,15 @@ function PayContent() {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const codeMessages: Record<string, string> = {
|
const codeMessages: Record<string, string> = {
|
||||||
INVALID_TOKEN: pickLocaleText(
|
INVALID_TOKEN: pickLocaleText(locale, '认证已失效,请重新从平台进入充值页面', 'Authentication expired'),
|
||||||
locale,
|
USER_INACTIVE: pickLocaleText(locale, '账户已被禁用,无法充值', 'Account is disabled'),
|
||||||
'认证已失效,请重新从平台进入充值页面',
|
TOO_MANY_PENDING: pickLocaleText(locale, '待支付订单过多,请先处理', 'Too many pending orders'),
|
||||||
'Authentication expired. Please re-enter the recharge page from the platform',
|
USER_NOT_FOUND: pickLocaleText(locale, '用户不存在', 'User not found'),
|
||||||
),
|
|
||||||
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',
|
|
||||||
),
|
|
||||||
DAILY_LIMIT_EXCEEDED: data.error,
|
DAILY_LIMIT_EXCEEDED: data.error,
|
||||||
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
|
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
|
||||||
PAYMENT_GATEWAY_ERROR: data.error,
|
PAYMENT_GATEWAY_ERROR: data.error,
|
||||||
};
|
};
|
||||||
setError(
|
setError(codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'));
|
||||||
codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,10 +367,66 @@ function PayContent() {
|
|||||||
expiresAt: data.expiresAt,
|
expiresAt: data.expiresAt,
|
||||||
statusAccessToken: data.statusAccessToken,
|
statusAccessToken: data.statusAccessToken,
|
||||||
});
|
});
|
||||||
|
setTopUpModalOpen(false);
|
||||||
setStep('paying');
|
setStep('paying');
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -333,9 +435,7 @@ function PayContent() {
|
|||||||
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
|
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
|
||||||
setFinalOrderState(order);
|
setFinalOrderState(order);
|
||||||
setStep('result');
|
setStep('result');
|
||||||
if (isMobile) {
|
if (isMobile) setActiveMobileTab('orders');
|
||||||
setActiveMobileTab('orders');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
@@ -343,27 +443,37 @@ function PayContent() {
|
|||||||
setOrderResult(null);
|
setOrderResult(null);
|
||||||
setFinalOrderState(null);
|
setFinalOrderState(null);
|
||||||
setError('');
|
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 (
|
return (
|
||||||
<PayPageLayout
|
<PayPageLayout
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
isEmbedded={isEmbedded}
|
isEmbedded={isEmbedded}
|
||||||
maxWidth={isMobile ? 'sm' : 'lg'}
|
maxWidth={showMainTabs ? 'full' : isMobile ? 'sm' : 'lg'}
|
||||||
title={pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge')}
|
title={pageTitle}
|
||||||
subtitle={pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting')}
|
subtitle={pageSubtitle}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
actions={
|
actions={
|
||||||
!isMobile ? (
|
!isMobile ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadUserAndOrders}
|
onClick={() => { loadUserAndOrders(); loadChannelsAndPlans(); }}
|
||||||
className={[
|
className={[
|
||||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||||
isDark
|
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
|
||||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{pickLocaleText(locale, '刷新', 'Refresh')}
|
{pickLocaleText(locale, '刷新', 'Refresh')}
|
||||||
@@ -372,9 +482,7 @@ function PayContent() {
|
|||||||
href={ordersUrl}
|
href={ordersUrl}
|
||||||
className={[
|
className={[
|
||||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||||
isDark
|
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
|
||||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||||
@@ -383,72 +491,193 @@ function PayContent() {
|
|||||||
) : undefined
|
) : 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 && (
|
{error && (
|
||||||
<div
|
<div className={[
|
||||||
className={[
|
'mb-4 rounded-lg border p-3 text-sm',
|
||||||
'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',
|
||||||
isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
].join(' ')}>
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'form' && isMobile && (
|
{/* ── 表单阶段 ── */}
|
||||||
<div
|
{step === 'form' && (
|
||||||
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 && (
|
|
||||||
<>
|
<>
|
||||||
{isMobile ? (
|
{/* 移动端 Tab:充值/订单 */}
|
||||||
activeMobileTab === 'pay' ? (
|
{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
|
<PaymentForm
|
||||||
userId={resolvedUserId ?? 0}
|
userId={resolvedUserId ?? 0}
|
||||||
userName={userInfo?.username}
|
userName={userInfo?.username}
|
||||||
@@ -457,116 +686,132 @@ function PayContent() {
|
|||||||
methodLimits={config.methodLimits}
|
methodLimits={config.methodLimits}
|
||||||
minAmount={config.minAmount}
|
minAmount={config.minAmount}
|
||||||
maxAmount={config.maxAmount}
|
maxAmount={config.maxAmount}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={(_, paymentType) => handleSubmit(topUpAmount, paymentType)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dark={isDark}
|
dark={isDark}
|
||||||
pendingBlocked={pendingBlocked}
|
pendingBlocked={pendingBlocked}
|
||||||
pendingCount={pendingCount}
|
pendingCount={pendingCount}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
fixedAmount={topUpAmount}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{hasHelpContent && (
|
{/* 订阅确认页 */}
|
||||||
<div
|
{selectedPlan && step === 'form' && (
|
||||||
className={[
|
<SubscriptionConfirm
|
||||||
'rounded-2xl border p-4',
|
plan={selectedPlan}
|
||||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
paymentTypes={config.enabledPaymentTypes}
|
||||||
].join(' ')}
|
onBack={() => setSelectedPlan(null)}
|
||||||
>
|
onSubmit={handleSubscriptionSubmit}
|
||||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
loading={loading}
|
||||||
{pickLocaleText(locale, '帮助', 'Support')}
|
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>
|
</div>
|
||||||
{helpImageUrl && (
|
{hasHelpContent && (
|
||||||
<img
|
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||||
src={helpImageUrl}
|
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
alt="help"
|
{pickLocaleText(locale, '帮助', 'Support')}
|
||||||
onClick={() => setHelpImageOpen(true)}
|
</div>
|
||||||
className="mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2"
|
{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 && (
|
{helpText && (
|
||||||
<div
|
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||||
className={[
|
{helpText.split('\n').map((line, i) => (<p key={i}>{line}</p>))}
|
||||||
'mt-3 space-y-1 text-sm leading-6',
|
</div>
|
||||||
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>
|
)}
|
||||||
</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 && (
|
{step === 'paying' && orderResult && (
|
||||||
<PaymentQRCode
|
<PaymentQRCode
|
||||||
orderId={orderResult.orderId}
|
orderId={orderResult.orderId}
|
||||||
@@ -589,6 +834,7 @@ function PayContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── 结果阶段 ── */}
|
||||||
{step === 'result' && orderResult && finalOrderState && (
|
{step === 'result' && orderResult && finalOrderState && (
|
||||||
<OrderStatus
|
<OrderStatus
|
||||||
orderId={orderResult.orderId}
|
orderId={orderResult.orderId}
|
||||||
@@ -601,17 +847,10 @@ function PayContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 帮助图片放大 */}
|
||||||
{helpImageOpen && helpImageUrl && (
|
{helpImageOpen && helpImageUrl && (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm" onClick={() => setHelpImageOpen(false)}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
<img src={helpImageUrl} alt="help" className="max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl" onClick={(e) => e.stopPropagation()} />
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PayPageLayout>
|
</PayPageLayout>
|
||||||
@@ -621,7 +860,6 @@ function PayContent() {
|
|||||||
function PayPageFallback() {
|
function PayPageFallback() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const locale = resolveLocale(searchParams.get('lang'));
|
const locale = resolveLocale(searchParams.get('lang'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
<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;
|
pendingBlocked?: boolean;
|
||||||
pendingCount?: number;
|
pendingCount?: number;
|
||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
|
/** 固定金额模式:隐藏金额选择,只显示支付方式和提交按钮 */
|
||||||
|
fixedAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
|
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
|
||||||
@@ -50,10 +52,11 @@ export default function PaymentForm({
|
|||||||
pendingBlocked = false,
|
pendingBlocked = false,
|
||||||
pendingCount = 0,
|
pendingCount = 0,
|
||||||
locale = 'zh',
|
locale = 'zh',
|
||||||
|
fixedAmount,
|
||||||
}: PaymentFormProps) {
|
}: PaymentFormProps) {
|
||||||
const [amount, setAmount] = useState<number | ''>('');
|
const [amount, setAmount] = useState<number | ''>(fixedAmount ?? '');
|
||||||
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
|
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
|
||||||
const [customAmount, setCustomAmount] = useState('');
|
const [customAmount, setCustomAmount] = useState(fixedAmount ? String(fixedAmount) : '');
|
||||||
|
|
||||||
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
|
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
|
||||||
? paymentType
|
? paymentType
|
||||||
@@ -166,60 +169,76 @@ export default function PaymentForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{fixedAmount ? (
|
||||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
<div className={[
|
||||||
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
'rounded-xl border p-4 text-center',
|
||||||
</label>
|
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
|
||||||
<div className="grid grid-cols-3 gap-2">
|
].join(' ')}>
|
||||||
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
|
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
<button
|
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
||||||
key={val}
|
</div>
|
||||||
type="button"
|
<div className={['mt-1 text-3xl font-bold', dark ? 'text-emerald-400' : 'text-emerald-600'].join(' ')}>
|
||||||
onClick={() => handleQuickAmount(val)}
|
¥{fixedAmount.toFixed(2)}
|
||||||
className={`rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors ${
|
</div>
|
||||||
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>
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
<div>
|
||||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||||
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
|
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span
|
<span
|
||||||
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
|
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
|
||||||
' ',
|
' ',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
¥
|
¥
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min={minAmount}
|
min={minAmount}
|
||||||
max={effectiveMax}
|
max={effectiveMax}
|
||||||
value={customAmount}
|
value={customAmount}
|
||||||
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
||||||
placeholder={`${minAmount} - ${effectiveMax}`}
|
placeholder={`${minAmount} - ${effectiveMax}`}
|
||||||
className={[
|
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',
|
'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',
|
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{customAmount !== '' &&
|
{!fixedAmount && customAmount !== '' &&
|
||||||
!isValid &&
|
!isValid &&
|
||||||
(() => {
|
(() => {
|
||||||
const num = parseFloat(customAmount);
|
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 { getMethodFeeRate, calculatePayAmount } from './fee';
|
||||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
import type { PaymentType, PaymentNotification } 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 { Prisma } from '@prisma/client';
|
||||||
import { deriveOrderState, isRefundStatus } from './status';
|
import { deriveOrderState, isRefundStatus } from './status';
|
||||||
import { pickLocaleText, type Locale } from '@/lib/locale';
|
import { pickLocaleText, type Locale } from '@/lib/locale';
|
||||||
@@ -28,6 +28,9 @@ export interface CreateOrderInput {
|
|||||||
srcHost?: string;
|
srcHost?: string;
|
||||||
srcUrl?: string;
|
srcUrl?: string;
|
||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
|
// 订阅订单专用
|
||||||
|
orderType?: 'balance' | 'subscription';
|
||||||
|
planId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrderResult {
|
export interface CreateOrderResult {
|
||||||
@@ -50,6 +53,31 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const locale = input.locale ?? 'zh';
|
const locale = input.locale ?? 'zh';
|
||||||
const todayStart = getBizDayStartUTC();
|
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);
|
const user = await getUser(input.userId);
|
||||||
if (user.status !== 'active') {
|
if (user.status !== 'active') {
|
||||||
@@ -149,6 +177,10 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
clientIp: input.clientIp,
|
clientIp: input.clientIp,
|
||||||
srcHost: input.srcHost || null,
|
srcHost: input.srcHost || null,
|
||||||
srcUrl: input.srcUrl || 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: {
|
data: {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
action: 'ORDER_CREATED',
|
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}`,
|
operator: `user:${input.userId}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -453,10 +491,10 @@ export async function confirmPayment(input: {
|
|||||||
// FAILED 状态 — 之前充值失败,利用重试通知自动重试充值
|
// FAILED 状态 — 之前充值失败,利用重试通知自动重试充值
|
||||||
if (current.status === ORDER_STATUS.FAILED) {
|
if (current.status === ORDER_STATUS.FAILED) {
|
||||||
try {
|
try {
|
||||||
await executeRecharge(order.id);
|
await executeFulfillment(order.id);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Recharge retry failed for order:', order.id, err);
|
console.error('Fulfillment retry failed for order:', order.id, err);
|
||||||
return false; // 让支付平台继续重试
|
return false; // 让支付平台继续重试
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -485,9 +523,9 @@ export async function confirmPayment(input: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await executeRecharge(order.id);
|
await executeFulfillment(order.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Recharge failed for order:', order.id, err);
|
console.error('Fulfillment failed for order:', order.id, err);
|
||||||
return false;
|
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> {
|
export async function executeRecharge(orderId: string): Promise<void> {
|
||||||
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
||||||
if (!order) {
|
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 {
|
export interface RefundInput {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface PublicOrderStatusSnapshot extends DerivedOrderState {
|
|||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
expiresAt: Date | string;
|
expiresAt: Date | string;
|
||||||
|
failedReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderDisplayState {
|
export interface OrderDisplayState {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getEnv } from '@/lib/config';
|
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 DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
const RECHARGE_TIMEOUT_MS = 30_000;
|
const RECHARGE_TIMEOUT_MS = 30_000;
|
||||||
@@ -101,6 +101,103 @@ export async function createAndRedeem(
|
|||||||
throw lastError instanceof Error ? lastError : new Error('Recharge failed');
|
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(
|
export async function subtractBalance(
|
||||||
userId: number,
|
userId: number,
|
||||||
amount: number,
|
amount: number,
|
||||||
|
|||||||
@@ -22,3 +22,43 @@ export interface Sub2ApiResponse<T> {
|
|||||||
data?: T;
|
data?: T;
|
||||||
message?: string;
|
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