diff --git a/docs/refrence/channel-conf.png b/docs/refrence/channel-conf.png new file mode 100644 index 0000000..3f8bcd0 Binary files /dev/null and b/docs/refrence/channel-conf.png differ diff --git a/docs/refrence/subscribe-main.png b/docs/refrence/subscribe-main.png new file mode 100644 index 0000000..2c6fa2d Binary files /dev/null and b/docs/refrence/subscribe-main.png differ diff --git a/docs/refrence/subscribe.png b/docs/refrence/subscribe.png new file mode 100644 index 0000000..db98ab1 Binary files /dev/null and b/docs/refrence/subscribe.png differ diff --git a/docs/refrence/top-up-main.png b/docs/refrence/top-up-main.png new file mode 100644 index 0000000..46701d1 Binary files /dev/null and b/docs/refrence/top-up-main.png differ diff --git a/docs/refrence/top-up.png b/docs/refrence/top-up.png new file mode 100644 index 0000000..882c07a Binary files /dev/null and b/docs/refrence/top-up.png differ diff --git a/prisma/migrations/20260313000000_add_channels_subscriptions_config/migration.sql b/prisma/migrations/20260313000000_add_channels_subscriptions_config/migration.sql new file mode 100644 index 0000000..fc3e8a9 --- /dev/null +++ b/prisma/migrations/20260313000000_add_channels_subscriptions_config/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 02ea896..01aacd3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,6 +40,13 @@ model Order { srcHost String? @map("src_host") srcUrl String? @map("src_url") + // ── 订单类型 & 订阅相关 ── + orderType String @default("balance") @map("order_type") + planId String? @map("plan_id") + plan SubscriptionPlan? @relation(fields: [planId], references: [id]) + subscriptionGroupId Int? @map("subscription_group_id") + subscriptionDays Int? @map("subscription_days") + auditLogs AuditLog[] @@index([userId]) @@ -48,6 +55,7 @@ model Order { @@index([createdAt]) @@index([paidAt]) @@index([paymentType, paidAt]) + @@index([orderType]) @@map("orders") } @@ -77,3 +85,55 @@ model AuditLog { @@index([createdAt]) @@map("audit_logs") } + +// ── 渠道展示配置 ── +model Channel { + id String @id @default(cuid()) + groupId Int @unique @map("group_id") + name String + platform String @default("claude") + rateMultiplier Decimal @db.Decimal(10, 4) @map("rate_multiplier") + description String? @db.Text + models String? @db.Text + features String? @db.Text + sortOrder Int @default(0) @map("sort_order") + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([sortOrder]) + @@map("channels") +} + +// ── 订阅套餐配置 ── +model SubscriptionPlan { + id String @id @default(cuid()) + groupId Int @unique @map("group_id") + name String + description String? @db.Text + price Decimal @db.Decimal(10, 2) + originalPrice Decimal? @db.Decimal(10, 2) @map("original_price") + validityDays Int @default(30) @map("validity_days") + features String? @db.Text + forSale Boolean @default(false) @map("for_sale") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + orders Order[] + + @@index([forSale, sortOrder]) + @@map("subscription_plans") +} + +// ── 系统配置 ── +model SystemConfig { + key String @id + value String @db.Text + group String @default("general") + label String? + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([group]) + @@map("system_configs") +} diff --git a/src/app/admin/channels/page.tsx b/src/app/admin/channels/page.tsx new file mode 100644 index 0000000..275d5b6 --- /dev/null +++ b/src/app/admin/channels/page.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + // Edit modal state + const [editModalOpen, setEditModalOpen] = useState(false); + const [editingChannel, setEditingChannel] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + + // Sync modal state + const [syncModalOpen, setSyncModalOpen] = useState(false); + const [syncGroups, setSyncGroups] = useState([]); + const [syncLoading, setSyncLoading] = useState(false); + const [syncSelected, setSyncSelected] = useState>(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 ( +
+
+

{t.missingToken}

+

{t.missingTokenHint}

+
+
+ ); + } + + // ── 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 ( + + + {t.orders} + + + + + + } + > + {/* Error banner */} + {error && ( +
+ {error} + +
+ )} + + {/* Channel table */} +
+ {loading ? ( +
{t.loading}
+ ) : channels.length === 0 ? ( +
+

{t.noChannels}

+

{t.noChannelsHint}

+
+ ) : ( + + + + + + + + + + + + + + {channels.map((channel) => { + const pc = PLATFORM_COLORS[channel.platform] ?? PLATFORM_COLORS.claude; + return ( + + + + + + + + + + ); + })} + +
{t.colName}{t.colPlatform}{t.colRate}{t.colSub2ApiStatus}{t.colSortOrder}{t.colEnabled}{t.colActions}
+
{channel.name}
+
+ Group #{channel.groupId} +
+
+ + {channel.platform} + + + {channel.rateMultiplier}x + + {channel.groupExists ? ( + + + + + + ) : ( + + + + + + )} + + {channel.sortOrder} + + + +
+ + +
+
+ )} +
+ + {/* ── Edit / Create Modal ── */} + {editModalOpen && ( +
+
e.stopPropagation()} + > +

+ {editingChannel ? t.editChannel : t.newChannel} +

+ +
+ {/* Group ID (only for create) */} + {!editingChannel && ( +
+ + setForm({ ...form, group_id: e.target.value ? Number(e.target.value) : '' })} + className={inputCls} + required + /> +
+ )} + + {/* Name */} +
+ + setForm({ ...form, name: e.target.value })} + className={inputCls} + required + /> +
+ + {/* Platform */} +
+ + +
+ + {/* Rate Multiplier */} +
+ + setForm({ ...form, rate_multiplier: e.target.value })} + className={inputCls} + required + /> +

{t.fieldRateHint}

+
+ + {/* Description */} +
+ +