diff --git a/prisma/migrations/20260313100000_add_validity_unit/migration.sql b/prisma/migrations/20260313100000_add_validity_unit/migration.sql new file mode 100644 index 0000000..b205901 --- /dev/null +++ b/prisma/migrations/20260313100000_add_validity_unit/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "subscription_plans" ADD COLUMN "validity_unit" TEXT NOT NULL DEFAULT 'day'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 01aacd3..f121bf5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -114,6 +114,7 @@ model SubscriptionPlan { price Decimal @db.Decimal(10, 2) originalPrice Decimal? @db.Decimal(10, 2) @map("original_price") validityDays Int @default(30) @map("validity_days") + validityUnit String @default("day") @map("validity_unit") // day | week | month features String? @db.Text forSale Boolean @default(false) @map("for_sale") sortOrder Int @default(0) @map("sort_order") diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 3cc74c2..32e3312 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -9,7 +9,6 @@ const NAV_ITEMS = [ { 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() { diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx deleted file mode 100644 index dd1352c..0000000 --- a/src/app/admin/settings/page.tsx +++ /dev/null @@ -1,788 +0,0 @@ -'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 ( - - - - ); -} - -/* ------------------------------------------------------------------ */ -/* 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 ( - - - - ); - } - return ( - - - - - ); -} - -/* ------------------------------------------------------------------ */ -/* 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; - 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>({}); - - 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 ( -
-
setOpen((v) => !v)}> -

- {locale === 'en' ? group.titleEn : group.title} -

- -
- - {open && ( -
- {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 ( -
- -
- {field.options.map((opt) => { - const checked = selected.includes(opt); - return ( - - ); - })} -
-
- ); - } - - if (field.type === 'textarea') { - return ( -
- -