From 687336cfd831538c3d556f85764887d7f4c23a62 Mon Sep 17 00:00:00 2001 From: erio Date: Fri, 13 Mar 2026 21:19:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A5=97=E9=A4=90=E6=9C=89=E6=95=88?= =?UTF-8?q?=E6=9C=9F=E6=94=AF=E6=8C=81=E6=97=A5/=E5=91=A8/=E6=9C=88?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=EF=BC=8C=E8=AE=A2=E9=98=85=E5=B1=A5=E7=BA=A6?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=85=91=E6=8D=A2=E7=A0=81=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=EF=BC=8CUI=E5=B1=82=E6=AC=A1=E6=84=9F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prisma: SubscriptionPlan 新增 validityUnit 字段 (day/week/month) - 新增 subscription-utils.ts 计算实际天数及格式化显示 - Sub2API client createAndRedeem 支持 subscription 类型 (group_id, validity_days) - 订阅履约从 assignSubscription 改为 createAndRedeem,在 Sub2API 留痕 - 订单创建动态计算天数(月单位按自然月差值) - 管理后台表单支持有效期数值+单位下拉 - 前端 ChannelCard 渠道卡片视觉层次优化(模型标签渐变、倍率突出、闪电图标) - 按量付费 banner 改为渐变背景+底部倍率说明标签 - 帮助/客服信息区块添加到充值、订阅、支付全流程页面 - 移除系统配置独立页面入口,subscriptions API 返回用户信息 --- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/app/admin/layout.tsx | 1 - src/app/admin/settings/page.tsx | 788 ------------------ src/app/admin/subscriptions/page.tsx | 403 +++++++-- .../admin/subscription-plans/[id]/route.ts | 3 + src/app/api/admin/subscription-plans/route.ts | 3 +- src/app/api/admin/subscriptions/route.ts | 12 +- src/app/api/subscription-plans/route.ts | 1 + src/app/pay/page.tsx | 202 +++-- src/components/ChannelCard.tsx | 145 ++-- src/components/SubscriptionConfirm.tsx | 6 +- src/components/SubscriptionPlanCard.tsx | 14 +- src/lib/order/service.ts | 21 +- src/lib/sub2api/client.ts | 7 +- src/lib/subscription-utils.ts | 90 ++ 16 files changed, 672 insertions(+), 1027 deletions(-) create mode 100644 prisma/migrations/20260313100000_add_validity_unit/migration.sql delete mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/lib/subscription-utils.ts 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 ( -
- -