feat: 套餐有效期支持日/周/月单位,订阅履约改用兑换码流程,UI层次感优化
- Prisma: SubscriptionPlan 新增 validityUnit 字段 (day/week/month) - 新增 subscription-utils.ts 计算实际天数及格式化显示 - Sub2API client createAndRedeem 支持 subscription 类型 (group_id, validity_days) - 订阅履约从 assignSubscription 改为 createAndRedeem,在 Sub2API 留痕 - 订单创建动态计算天数(月单位按自然月差值) - 管理后台表单支持有效期数值+单位下拉 - 前端 ChannelCard 渠道卡片视觉层次优化(模型标签渐变、倍率突出、闪电图标) - 按量付费 banner 改为渐变背景+底部倍率说明标签 - 帮助/客服信息区块添加到充值、订阅、支付全流程页面 - 移除系统配置独立页面入口,subscriptions API 返回用户信息
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "subscription_plans" ADD COLUMN "validity_unit" TEXT NOT NULL DEFAULT 'day';
|
||||||
@@ -114,6 +114,7 @@ model SubscriptionPlan {
|
|||||||
price Decimal @db.Decimal(10, 2)
|
price Decimal @db.Decimal(10, 2)
|
||||||
originalPrice Decimal? @db.Decimal(10, 2) @map("original_price")
|
originalPrice Decimal? @db.Decimal(10, 2) @map("original_price")
|
||||||
validityDays Int @default(30) @map("validity_days")
|
validityDays Int @default(30) @map("validity_days")
|
||||||
|
validityUnit String @default("day") @map("validity_unit") // day | week | month
|
||||||
features String? @db.Text
|
features String? @db.Text
|
||||||
forSale Boolean @default(false) @map("for_sale")
|
forSale Boolean @default(false) @map("for_sale")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const NAV_ITEMS = [
|
|||||||
{ path: '/admin/dashboard', label: { zh: '数据概览', en: 'Dashboard' } },
|
{ path: '/admin/dashboard', label: { zh: '数据概览', en: 'Dashboard' } },
|
||||||
{ path: '/admin/channels', label: { zh: '渠道管理', en: 'Channels' } },
|
{ path: '/admin/channels', label: { zh: '渠道管理', en: 'Channels' } },
|
||||||
{ path: '/admin/subscriptions', label: { zh: '订阅管理', en: 'Subscriptions' } },
|
{ path: '/admin/subscriptions', label: { zh: '订阅管理', en: 'Subscriptions' } },
|
||||||
{ path: '/admin/settings', label: { zh: '系统配置', en: 'Settings' } },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function AdminNav() {
|
function AdminNav() {
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,7 @@ interface SubscriptionPlan {
|
|||||||
price: number;
|
price: number;
|
||||||
originalPrice: number | null;
|
originalPrice: number | null;
|
||||||
validDays: number;
|
validDays: number;
|
||||||
|
validityUnit: 'day' | 'week' | 'month';
|
||||||
features: string[];
|
features: string[];
|
||||||
groupId: string;
|
groupId: string;
|
||||||
groupName: string | null;
|
groupName: string | null;
|
||||||
@@ -25,17 +26,32 @@ interface SubscriptionPlan {
|
|||||||
interface Sub2ApiGroup {
|
interface Sub2ApiGroup {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
subscription_type: string;
|
||||||
|
daily_limit_usd: number | null;
|
||||||
|
weekly_limit_usd: number | null;
|
||||||
|
monthly_limit_usd: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserSubscription {
|
interface Sub2ApiSubscription {
|
||||||
userId: number;
|
id: number;
|
||||||
groupId: string;
|
user_id: number;
|
||||||
|
group_id: number;
|
||||||
|
starts_at: string;
|
||||||
|
expires_at: string;
|
||||||
status: string;
|
status: string;
|
||||||
startsAt: string | null;
|
daily_usage_usd: number;
|
||||||
expiresAt: string | null;
|
weekly_usage_usd: number;
|
||||||
dailyUsage: number | null;
|
monthly_usage_usd: number;
|
||||||
weeklyUsage: number | null;
|
daily_window_start: string | null;
|
||||||
monthlyUsage: number | null;
|
weekly_window_start: string | null;
|
||||||
|
monthly_window_start: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubsUserInfo {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- i18n ---------- */
|
/* ---------- i18n ---------- */
|
||||||
@@ -67,7 +83,11 @@ function buildText(locale: Locale) {
|
|||||||
fieldDescription: 'Description',
|
fieldDescription: 'Description',
|
||||||
fieldPrice: 'Price (CNY)',
|
fieldPrice: 'Price (CNY)',
|
||||||
fieldOriginalPrice: 'Original Price (CNY)',
|
fieldOriginalPrice: 'Original Price (CNY)',
|
||||||
fieldValidDays: 'Validity (days)',
|
fieldValidDays: 'Validity',
|
||||||
|
fieldValidUnit: 'Unit',
|
||||||
|
unitDay: 'Day(s)',
|
||||||
|
unitWeek: 'Week(s)',
|
||||||
|
unitMonth: 'Month(s)',
|
||||||
fieldFeatures: 'Features (one per line)',
|
fieldFeatures: 'Features (one per line)',
|
||||||
fieldSortOrder: 'Sort Order',
|
fieldSortOrder: 'Sort Order',
|
||||||
fieldEnabled: 'For Sale',
|
fieldEnabled: 'For Sale',
|
||||||
@@ -88,19 +108,27 @@ function buildText(locale: Locale) {
|
|||||||
noPlans: 'No plans configured',
|
noPlans: 'No plans configured',
|
||||||
searchUserId: 'Search by user ID',
|
searchUserId: 'Search by user ID',
|
||||||
search: 'Search',
|
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',
|
noSubs: 'No subscription records found',
|
||||||
enterUserId: 'Enter a user ID to search',
|
enterUserId: 'Enter a user ID to search',
|
||||||
saveFailed: 'Failed to save plan',
|
saveFailed: 'Failed to save plan',
|
||||||
deleteFailed: 'Failed to delete plan',
|
deleteFailed: 'Failed to delete plan',
|
||||||
loadFailed: 'Failed to load data',
|
loadFailed: 'Failed to load data',
|
||||||
days: 'days',
|
days: 'days',
|
||||||
|
user: 'User',
|
||||||
|
group: 'Group',
|
||||||
|
usage: 'Usage',
|
||||||
|
expiresAt: 'Expires At',
|
||||||
|
status: 'Status',
|
||||||
|
active: 'Active',
|
||||||
|
expired: 'Expired',
|
||||||
|
suspended: 'Suspended',
|
||||||
|
daily: 'Daily',
|
||||||
|
weekly: 'Weekly',
|
||||||
|
monthly: 'Monthly',
|
||||||
|
remaining: 'remaining',
|
||||||
|
unlimited: 'Unlimited',
|
||||||
|
resetIn: 'Reset in',
|
||||||
|
noGroup: 'Unknown Group',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
missingToken: '缺少管理员凭证',
|
missingToken: '缺少管理员凭证',
|
||||||
@@ -127,7 +155,11 @@ function buildText(locale: Locale) {
|
|||||||
fieldDescription: '描述',
|
fieldDescription: '描述',
|
||||||
fieldPrice: '价格(元)',
|
fieldPrice: '价格(元)',
|
||||||
fieldOriginalPrice: '原价(元)',
|
fieldOriginalPrice: '原价(元)',
|
||||||
fieldValidDays: '有效天数',
|
fieldValidDays: '有效期',
|
||||||
|
fieldValidUnit: '单位',
|
||||||
|
unitDay: '天',
|
||||||
|
unitWeek: '周',
|
||||||
|
unitMonth: '月',
|
||||||
fieldFeatures: '特性描述(每行一个)',
|
fieldFeatures: '特性描述(每行一个)',
|
||||||
fieldSortOrder: '排序',
|
fieldSortOrder: '排序',
|
||||||
fieldEnabled: '启用售卖',
|
fieldEnabled: '启用售卖',
|
||||||
@@ -148,22 +180,101 @@ function buildText(locale: Locale) {
|
|||||||
noPlans: '暂无套餐配置',
|
noPlans: '暂无套餐配置',
|
||||||
searchUserId: '按用户 ID 搜索',
|
searchUserId: '按用户 ID 搜索',
|
||||||
search: '搜索',
|
search: '搜索',
|
||||||
colUserId: '用户 ID',
|
|
||||||
colStatus: '状态',
|
|
||||||
colStartsAt: '开始时间',
|
|
||||||
colExpiresAt: '到期时间',
|
|
||||||
colDailyUsage: '日用量',
|
|
||||||
colWeeklyUsage: '周用量',
|
|
||||||
colMonthlyUsage: '月用量',
|
|
||||||
noSubs: '未找到订阅记录',
|
noSubs: '未找到订阅记录',
|
||||||
enterUserId: '请输入用户 ID 进行搜索',
|
enterUserId: '请输入用户 ID 进行搜索',
|
||||||
saveFailed: '保存套餐失败',
|
saveFailed: '保存套餐失败',
|
||||||
deleteFailed: '删除套餐失败',
|
deleteFailed: '删除套餐失败',
|
||||||
loadFailed: '加载数据失败',
|
loadFailed: '加载数据失败',
|
||||||
days: '天',
|
days: '天',
|
||||||
|
user: '用户',
|
||||||
|
group: '分组',
|
||||||
|
usage: '用量',
|
||||||
|
expiresAt: '到期时间',
|
||||||
|
status: '状态',
|
||||||
|
active: '生效中',
|
||||||
|
expired: '已过期',
|
||||||
|
suspended: '已暂停',
|
||||||
|
daily: '日用量',
|
||||||
|
weekly: '周用量',
|
||||||
|
monthly: '月用量',
|
||||||
|
remaining: '剩余',
|
||||||
|
unlimited: '无限制',
|
||||||
|
resetIn: '重置于',
|
||||||
|
noGroup: '未知分组',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysRemaining(expiresAt: string | null): number | null {
|
||||||
|
if (!expiresAt) return null;
|
||||||
|
const now = new Date();
|
||||||
|
const exp = new Date(expiresAt);
|
||||||
|
const diff = exp.getTime() - now.getTime();
|
||||||
|
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCountdown(windowStart: string | null, periodDays: number): string | null {
|
||||||
|
if (!windowStart) return null;
|
||||||
|
const start = new Date(windowStart);
|
||||||
|
const resetAt = new Date(start.getTime() + periodDays * 24 * 60 * 60 * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = resetAt.getTime() - now.getTime();
|
||||||
|
if (diffMs <= 0) return null;
|
||||||
|
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
if (hours >= 24) {
|
||||||
|
const d = Math.floor(hours / 24);
|
||||||
|
const h = hours % 24;
|
||||||
|
return `${d}d ${h}h`;
|
||||||
|
}
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- UsageBar component ---------- */
|
||||||
|
|
||||||
|
function UsageBar({
|
||||||
|
label,
|
||||||
|
usage,
|
||||||
|
limit,
|
||||||
|
resetText,
|
||||||
|
isDark,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
usage: number;
|
||||||
|
limit: number | null;
|
||||||
|
resetText: string | null;
|
||||||
|
isDark: boolean;
|
||||||
|
}) {
|
||||||
|
const pct = limit && limit > 0 ? Math.min((usage / limit) * 100, 100) : 0;
|
||||||
|
const barColor = pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-yellow-500' : 'bg-green-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-1.5 last:mb-0">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>{label}</span>
|
||||||
|
<span className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||||
|
${usage.toFixed(2)} {limit != null ? `/ $${limit.toFixed(2)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{limit != null && limit > 0 ? (
|
||||||
|
<div className={`mt-0.5 h-1.5 w-full rounded-full ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`}>
|
||||||
|
<div className={`h-full rounded-full transition-all ${barColor}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{resetText && (
|
||||||
|
<div className={`mt-0.5 text-[10px] ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>{resetText}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- main content ---------- */
|
/* ---------- main content ---------- */
|
||||||
|
|
||||||
function SubscriptionsContent() {
|
function SubscriptionsContent() {
|
||||||
@@ -195,6 +306,7 @@ function SubscriptionsContent() {
|
|||||||
const [formPrice, setFormPrice] = useState('');
|
const [formPrice, setFormPrice] = useState('');
|
||||||
const [formOriginalPrice, setFormOriginalPrice] = useState('');
|
const [formOriginalPrice, setFormOriginalPrice] = useState('');
|
||||||
const [formValidDays, setFormValidDays] = useState('30');
|
const [formValidDays, setFormValidDays] = useState('30');
|
||||||
|
const [formValidUnit, setFormValidUnit] = useState<'day' | 'week' | 'month'>('day');
|
||||||
const [formFeatures, setFormFeatures] = useState('');
|
const [formFeatures, setFormFeatures] = useState('');
|
||||||
const [formSortOrder, setFormSortOrder] = useState('0');
|
const [formSortOrder, setFormSortOrder] = useState('0');
|
||||||
const [formEnabled, setFormEnabled] = useState(true);
|
const [formEnabled, setFormEnabled] = useState(true);
|
||||||
@@ -202,10 +314,12 @@ function SubscriptionsContent() {
|
|||||||
|
|
||||||
/* --- subs state --- */
|
/* --- subs state --- */
|
||||||
const [subsUserId, setSubsUserId] = useState('');
|
const [subsUserId, setSubsUserId] = useState('');
|
||||||
const [subs, setSubs] = useState<UserSubscription[]>([]);
|
const [subs, setSubs] = useState<Sub2ApiSubscription[]>([]);
|
||||||
|
const [subsUser, setSubsUser] = useState<SubsUserInfo | null>(null);
|
||||||
const [subsLoading, setSubsLoading] = useState(false);
|
const [subsLoading, setSubsLoading] = useState(false);
|
||||||
const [subsSearched, setSubsSearched] = useState(false);
|
const [subsSearched, setSubsSearched] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
/* --- fetch plans --- */
|
/* --- fetch plans --- */
|
||||||
const fetchPlans = useCallback(async () => {
|
const fetchPlans = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -256,6 +370,7 @@ function SubscriptionsContent() {
|
|||||||
setFormPrice('');
|
setFormPrice('');
|
||||||
setFormOriginalPrice('');
|
setFormOriginalPrice('');
|
||||||
setFormValidDays('30');
|
setFormValidDays('30');
|
||||||
|
setFormValidUnit('day');
|
||||||
setFormFeatures('');
|
setFormFeatures('');
|
||||||
setFormSortOrder('0');
|
setFormSortOrder('0');
|
||||||
setFormEnabled(true);
|
setFormEnabled(true);
|
||||||
@@ -270,6 +385,7 @@ function SubscriptionsContent() {
|
|||||||
setFormPrice(String(plan.price));
|
setFormPrice(String(plan.price));
|
||||||
setFormOriginalPrice(plan.originalPrice != null ? String(plan.originalPrice) : '');
|
setFormOriginalPrice(plan.originalPrice != null ? String(plan.originalPrice) : '');
|
||||||
setFormValidDays(String(plan.validDays));
|
setFormValidDays(String(plan.validDays));
|
||||||
|
setFormValidUnit(plan.validityUnit ?? 'day');
|
||||||
setFormFeatures((plan.features ?? []).join('\n'));
|
setFormFeatures((plan.features ?? []).join('\n'));
|
||||||
setFormSortOrder(String(plan.sortOrder));
|
setFormSortOrder(String(plan.sortOrder));
|
||||||
setFormEnabled(plan.enabled);
|
setFormEnabled(plan.enabled);
|
||||||
@@ -281,24 +397,25 @@ function SubscriptionsContent() {
|
|||||||
setEditingPlan(null);
|
setEditingPlan(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* --- save plan --- */
|
/* --- save plan (snake_case for backend) --- */
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!formName.trim() || !formPrice) return;
|
if (!formName.trim() || !formPrice) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
const body = {
|
const body = {
|
||||||
groupId: formGroupId || undefined,
|
group_id: formGroupId ? Number(formGroupId) : undefined,
|
||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
description: formDescription.trim() || null,
|
description: formDescription.trim() || null,
|
||||||
price: parseFloat(formPrice),
|
price: parseFloat(formPrice),
|
||||||
originalPrice: formOriginalPrice ? parseFloat(formOriginalPrice) : null,
|
original_price: formOriginalPrice ? parseFloat(formOriginalPrice) : null,
|
||||||
validDays: parseInt(formValidDays, 10) || 30,
|
validity_days: parseInt(formValidDays, 10) || 30,
|
||||||
|
validity_unit: formValidUnit,
|
||||||
features: formFeatures
|
features: formFeatures
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
sortOrder: parseInt(formSortOrder, 10) || 0,
|
sort_order: parseInt(formSortOrder, 10) || 0,
|
||||||
enabled: formEnabled,
|
for_sale: formEnabled,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const url = editingPlan
|
const url = editingPlan
|
||||||
@@ -349,6 +466,7 @@ function SubscriptionsContent() {
|
|||||||
if (!token || !subsUserId.trim()) return;
|
if (!token || !subsUserId.trim()) return;
|
||||||
setSubsLoading(true);
|
setSubsLoading(true);
|
||||||
setSubsSearched(true);
|
setSubsSearched(true);
|
||||||
|
setSubsUser(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/admin/subscriptions?token=${encodeURIComponent(token)}&user_id=${encodeURIComponent(subsUserId.trim())}`,
|
`/api/admin/subscriptions?token=${encodeURIComponent(token)}&user_id=${encodeURIComponent(subsUserId.trim())}`,
|
||||||
@@ -361,7 +479,8 @@ function SubscriptionsContent() {
|
|||||||
throw new Error(t.requestFailed);
|
throw new Error(t.requestFailed);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSubs(Array.isArray(data) ? data : data.subscriptions ?? []);
|
setSubs(data.subscriptions ?? []);
|
||||||
|
setSubsUser(data.user ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
setError(t.loadFailed);
|
setError(t.loadFailed);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -395,9 +514,13 @@ function SubscriptionsContent() {
|
|||||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
/* available groups for the form (exclude groups already used by other plans, unless editing that plan) */
|
/* available groups for the form: only subscription type, exclude already used */
|
||||||
|
const subscriptionGroups = groups.filter((g) => g.subscription_type === 'subscription');
|
||||||
const usedGroupIds = new Set(plans.filter((p) => p.id !== editingPlan?.id).map((p) => p.groupId));
|
const usedGroupIds = new Set(plans.filter((p) => p.id !== editingPlan?.id).map((p) => p.groupId));
|
||||||
const availableGroups = groups.filter((g) => !usedGroupIds.has(g.id));
|
const availableGroups = subscriptionGroups.filter((g) => !usedGroupIds.has(String(g.id)));
|
||||||
|
|
||||||
|
/* group id → name map (all groups, for subscription display) */
|
||||||
|
const groupNameMap = new Map(groups.map((g) => [String(g.id), g.name]));
|
||||||
|
|
||||||
/* --- tab classes --- */
|
/* --- tab classes --- */
|
||||||
const tabCls = (active: boolean) =>
|
const tabCls = (active: boolean) =>
|
||||||
@@ -437,6 +560,31 @@ function SubscriptionsContent() {
|
|||||||
|
|
||||||
const labelCls = ['block text-sm font-medium mb-1', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ');
|
const labelCls = ['block text-sm font-medium mb-1', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ');
|
||||||
|
|
||||||
|
/* --- status badge --- */
|
||||||
|
const statusBadge = (status: string) => {
|
||||||
|
const map: Record<string, { label: string; cls: string }> = {
|
||||||
|
active: {
|
||||||
|
label: t.active,
|
||||||
|
cls: isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-50 text-green-700',
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
label: t.expired,
|
||||||
|
cls: isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-600',
|
||||||
|
},
|
||||||
|
suspended: {
|
||||||
|
label: t.suspended,
|
||||||
|
cls: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-50 text-yellow-700',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const info = map[status] ?? {
|
||||||
|
label: status,
|
||||||
|
cls: isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-100 text-gray-500',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${info.cls}`}>{info.label}</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PayPageLayout
|
<PayPageLayout
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
@@ -457,6 +605,7 @@ function SubscriptionsContent() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (activeTab === 'plans') fetchPlans();
|
if (activeTab === 'plans') fetchPlans();
|
||||||
|
if (activeTab === 'subs' && subsSearched) fetchSubs();
|
||||||
}}
|
}}
|
||||||
className={btnBase}
|
className={btnBase}
|
||||||
>
|
>
|
||||||
@@ -548,7 +697,12 @@ function SubscriptionsContent() {
|
|||||||
{plan.originalPrice != null ? plan.originalPrice.toFixed(2) : '-'}
|
{plan.originalPrice != null ? plan.originalPrice.toFixed(2) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className={tdCls}>
|
<td className={tdCls}>
|
||||||
{plan.validDays} {t.days}
|
{plan.validDays}{' '}
|
||||||
|
{plan.validityUnit === 'month'
|
||||||
|
? t.unitMonth
|
||||||
|
: plan.validityUnit === 'week'
|
||||||
|
? t.unitWeek
|
||||||
|
: t.unitDay}
|
||||||
</td>
|
</td>
|
||||||
<td className={tdCls}>
|
<td className={tdCls}>
|
||||||
<span
|
<span
|
||||||
@@ -645,7 +799,35 @@ function SubscriptionsContent() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subs table */}
|
{/* User info card */}
|
||||||
|
{subsUser && (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'mb-4 flex items-center gap-3 rounded-xl border p-3',
|
||||||
|
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold',
|
||||||
|
isDark ? 'bg-indigo-500/30 text-indigo-200' : 'bg-blue-100 text-blue-700',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{(subsUser.email?.[0] ?? subsUser.username?.[0] ?? '?').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-sm font-medium ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>
|
||||||
|
{subsUser.username}
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{subsUser.email}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`ml-auto text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
ID: {subsUser.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subs list */}
|
||||||
<div className={tableWrapCls}>
|
<div className={tableWrapCls}>
|
||||||
{subsLoading ? (
|
{subsLoading ? (
|
||||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
|
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
|
||||||
@@ -659,46 +841,101 @@ function SubscriptionsContent() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className={`border-b ${rowBorderCls}`}>
|
<tr className={`border-b ${rowBorderCls}`}>
|
||||||
<th className={thCls}>{t.colUserId}</th>
|
<th className={thCls}>{t.group}</th>
|
||||||
<th className={thCls}>{t.colGroup}</th>
|
<th className={thCls}>{t.status}</th>
|
||||||
<th className={thCls}>{t.colStatus}</th>
|
<th className={thCls}>{t.usage}</th>
|
||||||
<th className={thCls}>{t.colStartsAt}</th>
|
<th className={thCls}>{t.expiresAt}</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{subs.map((sub, idx) => (
|
{subs.map((sub) => {
|
||||||
<tr key={`${sub.userId}-${sub.groupId}-${idx}`} className={`border-b ${rowBorderCls} last:border-b-0`}>
|
const gName = groupNameMap.get(String(sub.group_id)) ?? t.noGroup;
|
||||||
<td className={tdCls}>{sub.userId}</td>
|
const remaining = daysRemaining(sub.expires_at);
|
||||||
<td className={tdCls}>
|
const group = groups.find((g) => String(g.id) === String(sub.group_id));
|
||||||
<span className="font-mono text-xs">{sub.groupId}</span>
|
const dailyLimit = group?.daily_limit_usd ?? null;
|
||||||
</td>
|
const weeklyLimit = group?.weekly_limit_usd ?? null;
|
||||||
|
const monthlyLimit = group?.monthly_limit_usd ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={sub.id} className={`border-b ${rowBorderCls} last:border-b-0`}>
|
||||||
|
{/* Group */}
|
||||||
<td className={tdCls}>
|
<td className={tdCls}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={[
|
className={`inline-block h-2 w-2 rounded-full ${sub.status === 'active' ? 'bg-green-500' : 'bg-slate-400'}`}
|
||||||
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
|
/>
|
||||||
sub.status === 'active'
|
<span className="font-medium">{gName}</span>
|
||||||
? isDark
|
</div>
|
||||||
? 'bg-green-500/20 text-green-300'
|
<div className={`mt-0.5 text-xs font-mono ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
: 'bg-green-50 text-green-700'
|
ID: {sub.group_id}
|
||||||
: isDark
|
</div>
|
||||||
? 'bg-slate-700 text-slate-400'
|
|
||||||
: 'bg-gray-100 text-gray-500',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{sub.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className={tdCls}>{sub.startsAt ?? '-'}</td>
|
|
||||||
<td className={tdCls}>{sub.expiresAt ?? '-'}</td>
|
{/* Status */}
|
||||||
<td className={tdCls}>{sub.dailyUsage ?? '-'}</td>
|
<td className={tdCls}>{statusBadge(sub.status)}</td>
|
||||||
<td className={tdCls}>{sub.weeklyUsage ?? '-'}</td>
|
|
||||||
<td className={tdCls}>{sub.monthlyUsage ?? '-'}</td>
|
{/* Usage */}
|
||||||
|
<td className={`${tdCls} min-w-[200px]`}>
|
||||||
|
<UsageBar
|
||||||
|
label={t.daily}
|
||||||
|
usage={sub.daily_usage_usd}
|
||||||
|
limit={dailyLimit}
|
||||||
|
resetText={
|
||||||
|
sub.daily_window_start
|
||||||
|
? `${t.resetIn} ${resetCountdown(sub.daily_window_start, 1) ?? '-'}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
<UsageBar
|
||||||
|
label={t.weekly}
|
||||||
|
usage={sub.weekly_usage_usd}
|
||||||
|
limit={weeklyLimit}
|
||||||
|
resetText={
|
||||||
|
sub.weekly_window_start
|
||||||
|
? `${t.resetIn} ${resetCountdown(sub.weekly_window_start, 7) ?? '-'}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
<UsageBar
|
||||||
|
label={t.monthly}
|
||||||
|
usage={sub.monthly_usage_usd}
|
||||||
|
limit={monthlyLimit}
|
||||||
|
resetText={
|
||||||
|
sub.monthly_window_start
|
||||||
|
? `${t.resetIn} ${resetCountdown(sub.monthly_window_start, 30) ?? '-'}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Expires */}
|
||||||
|
<td className={tdCls}>
|
||||||
|
<div>{formatDate(sub.expires_at)}</div>
|
||||||
|
{remaining != null && (
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 text-xs ${
|
||||||
|
remaining <= 0
|
||||||
|
? 'text-red-500'
|
||||||
|
: remaining <= 7
|
||||||
|
? 'text-yellow-500'
|
||||||
|
: isDark
|
||||||
|
? 'text-slate-400'
|
||||||
|
: 'text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{remaining > 0
|
||||||
|
? `${remaining} ${t.days} ${t.remaining}`
|
||||||
|
: t.expired}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
@@ -740,7 +977,7 @@ function SubscriptionsContent() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{/* If editing, ensure the current group is always visible */}
|
{/* If editing, ensure the current group is always visible */}
|
||||||
{editingPlan && !availableGroups.some((g) => g.id === editingPlan.groupId) && (
|
{editingPlan && !availableGroups.some((g) => String(g.id) === editingPlan.groupId) && (
|
||||||
<option value={editingPlan.groupId}>
|
<option value={editingPlan.groupId}>
|
||||||
{editingPlan.groupName ?? editingPlan.groupId} ({editingPlan.groupId})
|
{editingPlan.groupName ?? editingPlan.groupId} ({editingPlan.groupId})
|
||||||
</option>
|
</option>
|
||||||
@@ -798,8 +1035,8 @@ function SubscriptionsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Valid days + Sort */}
|
{/* Valid days + Unit + Sort */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t.fieldValidDays}</label>
|
<label className={labelCls}>{t.fieldValidDays}</label>
|
||||||
<input
|
<input
|
||||||
@@ -810,6 +1047,18 @@ function SubscriptionsContent() {
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t.fieldValidUnit}</label>
|
||||||
|
<select
|
||||||
|
value={formValidUnit}
|
||||||
|
onChange={(e) => setFormValidUnit(e.target.value as 'day' | 'week' | 'month')}
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
<option value="day">{t.unitDay}</option>
|
||||||
|
<option value="week">{t.unitWeek}</option>
|
||||||
|
<option value="month">{t.unitMonth}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t.fieldSortOrder}</label>
|
<label className={labelCls}>{t.fieldSortOrder}</label>
|
||||||
<input
|
<input
|
||||||
@@ -881,6 +1130,8 @@ function SubscriptionsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ====== Extend Confirmation Modal ====== */}
|
||||||
</PayPageLayout>
|
</PayPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (body.price !== undefined) data.price = body.price;
|
if (body.price !== undefined) data.price = body.price;
|
||||||
if (body.original_price !== undefined) data.originalPrice = body.original_price;
|
if (body.original_price !== undefined) data.originalPrice = body.original_price;
|
||||||
if (body.validity_days !== undefined) data.validityDays = body.validity_days;
|
if (body.validity_days !== undefined) data.validityDays = body.validity_days;
|
||||||
|
if (body.validity_unit !== undefined && ['day', 'week', 'month'].includes(body.validity_unit)) {
|
||||||
|
data.validityUnit = body.validity_unit;
|
||||||
|
}
|
||||||
if (body.features !== undefined) data.features = body.features;
|
if (body.features !== undefined) data.features = body.features;
|
||||||
if (body.for_sale !== undefined) data.forSale = body.for_sale;
|
if (body.for_sale !== undefined) data.forSale = body.for_sale;
|
||||||
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
|
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { group_id, name, description, price, original_price, validity_days, features, for_sale, sort_order } = body;
|
const { group_id, name, description, price, original_price, validity_days, validity_unit, features, for_sale, sort_order } = body;
|
||||||
|
|
||||||
if (!group_id || !name || price === undefined) {
|
if (!group_id || !name || price === undefined) {
|
||||||
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
|
||||||
@@ -68,6 +68,7 @@ export async function POST(request: NextRequest) {
|
|||||||
price,
|
price,
|
||||||
originalPrice: original_price ?? null,
|
originalPrice: original_price ?? null,
|
||||||
validityDays: validity_days ?? 30,
|
validityDays: validity_days ?? 30,
|
||||||
|
validityUnit: ['day', 'week', 'month'].includes(validity_unit) ? validity_unit : 'day',
|
||||||
features: features ?? null,
|
features: features ?? null,
|
||||||
forSale: for_sale ?? false,
|
forSale: for_sale ?? false,
|
||||||
sortOrder: sort_order ?? 0,
|
sortOrder: sort_order ?? 0,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||||
import { getUserSubscriptions } from '@/lib/sub2api/client';
|
import { getUserSubscriptions, getUser } from '@/lib/sub2api/client';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||||
@@ -18,7 +18,10 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '无效的 user_id' }, { status: 400 });
|
return NextResponse.json({ error: '无效的 user_id' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptions = await getUserSubscriptions(parsedUserId);
|
const [subscriptions, user] = await Promise.all([
|
||||||
|
getUserSubscriptions(parsedUserId),
|
||||||
|
getUser(parsedUserId).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
// 如果提供了 group_id 筛选,过滤结果
|
// 如果提供了 group_id 筛选,过滤结果
|
||||||
const groupId = searchParams.get('group_id');
|
const groupId = searchParams.get('group_id');
|
||||||
@@ -26,7 +29,10 @@ export async function GET(request: NextRequest) {
|
|||||||
? subscriptions.filter((s) => s.group_id === Number(groupId))
|
? subscriptions.filter((s) => s.group_id === Number(groupId))
|
||||||
: subscriptions;
|
: subscriptions;
|
||||||
|
|
||||||
return NextResponse.json({ subscriptions: filtered });
|
return NextResponse.json({
|
||||||
|
subscriptions: filtered,
|
||||||
|
user: user ? { id: user.id, username: user.username, email: user.email } : null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to query subscriptions:', error);
|
console.error('Failed to query subscriptions:', error);
|
||||||
return NextResponse.json({ error: '查询订阅信息失败' }, { status: 500 });
|
return NextResponse.json({ error: '查询订阅信息失败' }, { status: 500 });
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export async function GET(request: NextRequest) {
|
|||||||
price: Number(plan.price),
|
price: Number(plan.price),
|
||||||
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
|
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
|
||||||
validityDays: plan.validityDays,
|
validityDays: plan.validityDays,
|
||||||
|
validityUnit: plan.validityUnit,
|
||||||
features: plan.features ? JSON.parse(plan.features) : [],
|
features: plan.features ? JSON.parse(plan.features) : [],
|
||||||
limits: groupInfo,
|
limits: groupInfo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import PayPageLayout from '@/components/PayPageLayout';
|
|||||||
import MobileOrderList from '@/components/MobileOrderList';
|
import MobileOrderList from '@/components/MobileOrderList';
|
||||||
import MainTabs from '@/components/MainTabs';
|
import MainTabs from '@/components/MainTabs';
|
||||||
import ChannelGrid from '@/components/ChannelGrid';
|
import ChannelGrid from '@/components/ChannelGrid';
|
||||||
import TopUpModal from '@/components/TopUpModal';
|
|
||||||
import SubscriptionPlanCard from '@/components/SubscriptionPlanCard';
|
import SubscriptionPlanCard from '@/components/SubscriptionPlanCard';
|
||||||
import SubscriptionConfirm from '@/components/SubscriptionConfirm';
|
import SubscriptionConfirm from '@/components/SubscriptionConfirm';
|
||||||
import UserSubscriptions from '@/components/UserSubscriptions';
|
import UserSubscriptions from '@/components/UserSubscriptions';
|
||||||
@@ -79,7 +78,7 @@ function PayContent() {
|
|||||||
const [channels, setChannels] = useState<ChannelInfo[]>([]);
|
const [channels, setChannels] = useState<ChannelInfo[]>([]);
|
||||||
const [plans, setPlans] = useState<PlanInfo[]>([]);
|
const [plans, setPlans] = useState<PlanInfo[]>([]);
|
||||||
const [userSubscriptions, setUserSubscriptions] = useState<UserSub[]>([]);
|
const [userSubscriptions, setUserSubscriptions] = useState<UserSub[]>([]);
|
||||||
const [topUpModalOpen, setTopUpModalOpen] = useState(false);
|
const [showTopUpForm, setShowTopUpForm] = useState(false);
|
||||||
const [selectedPlan, setSelectedPlan] = useState<PlanInfo | null>(null);
|
const [selectedPlan, setSelectedPlan] = useState<PlanInfo | null>(null);
|
||||||
const [channelsLoaded, setChannelsLoaded] = useState(false);
|
const [channelsLoaded, setChannelsLoaded] = useState(false);
|
||||||
|
|
||||||
@@ -97,6 +96,30 @@ function PayContent() {
|
|||||||
const helpImageUrl = (config.helpImageUrl || '').trim();
|
const helpImageUrl = (config.helpImageUrl || '').trim();
|
||||||
const helpText = (config.helpText || '').trim();
|
const helpText = (config.helpText || '').trim();
|
||||||
const hasHelpContent = Boolean(helpImageUrl || helpText);
|
const hasHelpContent = Boolean(helpImageUrl || helpText);
|
||||||
|
|
||||||
|
// 通用帮助/客服信息区块
|
||||||
|
const renderHelpSection = () => {
|
||||||
|
if (!hasHelpContent) return null;
|
||||||
|
return (
|
||||||
|
<div className={[
|
||||||
|
'mt-6 rounded-2xl border p-4',
|
||||||
|
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||||
|
].join(' ')}>
|
||||||
|
<div className={['text-xs font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
{pickLocaleText(locale, '帮助', 'Support')}
|
||||||
|
</div>
|
||||||
|
{helpImageUrl && (
|
||||||
|
<img src={helpImageUrl} alt="help" onClick={() => setHelpImageOpen(true)} className="mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2" />
|
||||||
|
)}
|
||||||
|
{helpText && (
|
||||||
|
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||||
|
{helpText.split('\n').map((line, i) => (<p key={i}>{line}</p>))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_PENDING = 3;
|
const MAX_PENDING = 3;
|
||||||
const pendingBlocked = pendingCount >= MAX_PENDING;
|
const pendingBlocked = pendingCount >= MAX_PENDING;
|
||||||
|
|
||||||
@@ -367,7 +390,6 @@ 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'));
|
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error'));
|
||||||
@@ -376,14 +398,6 @@ function PayContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 充值弹窗确认 → 进入支付方式选择(复用 PaymentForm) ──
|
|
||||||
const [topUpAmount, setTopUpAmount] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const handleTopUpConfirm = (amount: number) => {
|
|
||||||
setTopUpAmount(amount);
|
|
||||||
setTopUpModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 订阅下单 ──
|
// ── 订阅下单 ──
|
||||||
const handleSubscriptionSubmit = async (paymentType: string) => {
|
const handleSubscriptionSubmit = async (paymentType: string) => {
|
||||||
if (!selectedPlan) return;
|
if (!selectedPlan) return;
|
||||||
@@ -445,7 +459,7 @@ function PayContent() {
|
|||||||
setError('');
|
setError('');
|
||||||
setSubscriptionError('');
|
setSubscriptionError('');
|
||||||
setSelectedPlan(null);
|
setSelectedPlan(null);
|
||||||
setTopUpAmount(null);
|
setShowTopUpForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 渲染 ──
|
// ── 渲染 ──
|
||||||
@@ -563,7 +577,7 @@ function PayContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 有渠道配置:新版UI ── */}
|
{/* ── 有渠道配置:新版UI ── */}
|
||||||
{channelsLoaded && showMainTabs && (activeMobileTab === 'pay' || !isMobile) && !selectedPlan && !topUpAmount && (
|
{channelsLoaded && showMainTabs && (activeMobileTab === 'pay' || !isMobile) && !selectedPlan && !showTopUpForm && (
|
||||||
<>
|
<>
|
||||||
<MainTabs activeTab={mainTab} onTabChange={setMainTab} showSubscribeTab={hasPlans} isDark={isDark} locale={locale} />
|
<MainTabs activeTab={mainTab} onTabChange={setMainTab} showSubscribeTab={hasPlans} isDark={isDark} locale={locale} />
|
||||||
|
|
||||||
@@ -571,33 +585,75 @@ function PayContent() {
|
|||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{/* 按量付费说明 banner */}
|
{/* 按量付费说明 banner */}
|
||||||
<div className={[
|
<div className={[
|
||||||
'mb-6 rounded-xl border p-4',
|
'mb-6 rounded-2xl border p-6',
|
||||||
isDark ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50',
|
isDark
|
||||||
|
? 'border-emerald-500/20 bg-gradient-to-r from-emerald-500/10 to-purple-500/10'
|
||||||
|
: 'border-emerald-500/20 bg-gradient-to-r from-emerald-50 to-purple-50',
|
||||||
].join(' ')}>
|
].join(' ')}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-4">
|
||||||
<div className={['text-2xl'].join(' ')}>💰</div>
|
<div className={[
|
||||||
<div>
|
'flex-shrink-0 rounded-lg p-2',
|
||||||
<div className={['font-semibold', isDark ? 'text-emerald-400' : 'text-emerald-600'].join(' ')}>
|
isDark ? 'bg-emerald-500/20' : 'bg-emerald-500/15',
|
||||||
{pickLocaleText(locale, '按量付费模式', 'Pay-as-you-go')}
|
].join(' ')}>
|
||||||
|
<svg className="h-6 w-6 text-emerald-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
<div className="flex-1">
|
||||||
|
<h3 className={['text-lg font-semibold mb-2', isDark ? 'text-emerald-400' : 'text-emerald-700'].join(' ')}>
|
||||||
|
{pickLocaleText(locale, '按量付费模式', 'Pay-as-you-go')}
|
||||||
|
</h3>
|
||||||
|
<p className={['text-sm mb-4', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
{pickLocaleText(
|
{pickLocaleText(
|
||||||
locale,
|
locale,
|
||||||
'无需订阅,充值即用,按实际消耗扣费,余额所有渠道通用。',
|
'无需订阅,充值即用,按实际消耗扣费。余额所有渠道通用,可自由切换。价格以美元计价(当前比例:1美元≈1人民币)',
|
||||||
'No subscription needed. Top up and use. Charged by actual usage. Balance works across all channels.',
|
'No subscription needed. Top up and use. Charged by actual usage. Balance works across all channels. Priced in USD (current rate: 1 USD ≈ 1 CNY)',
|
||||||
)}
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
|
<div className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
<svg className="h-4 w-4 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
|
||||||
|
<polyline points="17 6 23 6 23 12" />
|
||||||
|
</svg>
|
||||||
|
<span>{pickLocaleText(locale, '倍率越低越划算', 'Lower rate = better value')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
<svg className="h-4 w-4 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
|
</svg>
|
||||||
|
<span>{pickLocaleText(locale, '0.15倍率 = 1元可用约6.67美元额度', '0.15 rate = 1 CNY ≈ $6.67 quota')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasChannels ? (
|
||||||
<ChannelGrid
|
<ChannelGrid
|
||||||
channels={channels}
|
channels={channels}
|
||||||
onTopUp={() => setTopUpModalOpen(true)}
|
onTopUp={() => setShowTopUpForm(true)}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
userBalance={userInfo?.balance}
|
userBalance={userInfo?.balance}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 用户已有订阅 */}
|
{/* 用户已有订阅 */}
|
||||||
{userSubscriptions.length > 0 && (
|
{userSubscriptions.length > 0 && (
|
||||||
@@ -619,6 +675,8 @@ function PayContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{renderHelpSection()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -653,27 +711,21 @@ function PayContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{renderHelpSection()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PurchaseFlow isDark={isDark} locale={locale} />
|
<PurchaseFlow isDark={isDark} locale={locale} />
|
||||||
|
|
||||||
<TopUpModal
|
|
||||||
open={topUpModalOpen}
|
|
||||||
onClose={() => setTopUpModalOpen(false)}
|
|
||||||
onConfirm={handleTopUpConfirm}
|
|
||||||
isDark={isDark}
|
|
||||||
locale={locale}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 充值弹窗确认后:选择支付方式 */}
|
{/* 点击"立即充值"后:直接显示 PaymentForm(含金额选择) */}
|
||||||
{topUpAmount && step === 'form' && (
|
{showTopUpForm && step === 'form' && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTopUpAmount(null)}
|
onClick={() => setShowTopUpForm(false)}
|
||||||
className={['mb-4 text-sm', isDark ? 'text-emerald-400 hover:text-emerald-300' : 'text-emerald-600 hover:text-emerald-500'].join(' ')}
|
className={['mb-4 text-sm', isDark ? 'text-emerald-400 hover:text-emerald-300' : 'text-emerald-600 hover:text-emerald-500'].join(' ')}
|
||||||
>
|
>
|
||||||
← {pickLocaleText(locale, '返回', 'Back')}
|
← {pickLocaleText(locale, '返回', 'Back')}
|
||||||
@@ -686,19 +738,20 @@ function PayContent() {
|
|||||||
methodLimits={config.methodLimits}
|
methodLimits={config.methodLimits}
|
||||||
minAmount={config.minAmount}
|
minAmount={config.minAmount}
|
||||||
maxAmount={config.maxAmount}
|
maxAmount={config.maxAmount}
|
||||||
onSubmit={(_, paymentType) => handleSubmit(topUpAmount, paymentType)}
|
onSubmit={handleSubmit}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dark={isDark}
|
dark={isDark}
|
||||||
pendingBlocked={pendingBlocked}
|
pendingBlocked={pendingBlocked}
|
||||||
pendingCount={pendingCount}
|
pendingCount={pendingCount}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
fixedAmount={topUpAmount}
|
|
||||||
/>
|
/>
|
||||||
|
{renderHelpSection()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 订阅确认页 */}
|
{/* 订阅确认页 */}
|
||||||
{selectedPlan && step === 'form' && (
|
{selectedPlan && step === 'form' && (
|
||||||
|
<>
|
||||||
<SubscriptionConfirm
|
<SubscriptionConfirm
|
||||||
plan={selectedPlan}
|
plan={selectedPlan}
|
||||||
paymentTypes={config.enabledPaymentTypes}
|
paymentTypes={config.enabledPaymentTypes}
|
||||||
@@ -708,10 +761,12 @@ function PayContent() {
|
|||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
{renderHelpSection()}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 无渠道配置:传统充值UI ── */}
|
{/* ── 无渠道配置:传统充值UI ── */}
|
||||||
{channelsLoaded && !showMainTabs && config.enabledPaymentTypes.length > 0 && !topUpAmount && !selectedPlan && (
|
{channelsLoaded && !showMainTabs && config.enabledPaymentTypes.length > 0 && !selectedPlan && (
|
||||||
<>
|
<>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
activeMobileTab === 'pay' ? (
|
activeMobileTab === 'pay' ? (
|
||||||
@@ -813,6 +868,7 @@ function PayContent() {
|
|||||||
|
|
||||||
{/* ── 支付阶段 ── */}
|
{/* ── 支付阶段 ── */}
|
||||||
{step === 'paying' && orderResult && (
|
{step === 'paying' && orderResult && (
|
||||||
|
<>
|
||||||
<PaymentQRCode
|
<PaymentQRCode
|
||||||
orderId={orderResult.orderId}
|
orderId={orderResult.orderId}
|
||||||
token={token || undefined}
|
token={token || undefined}
|
||||||
@@ -832,6 +888,8 @@ function PayContent() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
{renderHelpSection()}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 结果阶段 ── */}
|
{/* ── 结果阶段 ── */}
|
||||||
|
|||||||
@@ -23,83 +23,92 @@ interface ChannelCardProps {
|
|||||||
userBalance?: number;
|
userBalance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLATFORM_STYLES: Record<string, { bg: string; text: string }> = {
|
const PLATFORM_STYLES: Record<string, { badge: string; border: string }> = {
|
||||||
claude: { bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300' },
|
claude: {
|
||||||
openai: { bg: 'bg-green-100 dark:bg-green-900/40', text: 'text-green-700 dark:text-green-300' },
|
badge: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30',
|
||||||
gemini: { bg: 'bg-purple-100 dark:bg-purple-900/40', text: 'text-purple-700 dark:text-purple-300' },
|
border: 'border-orange-500/20',
|
||||||
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' },
|
openai: {
|
||||||
|
badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
|
||||||
|
border: 'border-green-500/20',
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
badge: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
||||||
|
border: 'border-blue-500/20',
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
|
||||||
|
border: 'border-green-500/20',
|
||||||
|
},
|
||||||
|
sora: {
|
||||||
|
badge: 'bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/30',
|
||||||
|
border: 'border-pink-500/20',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPlatformStyle(platform: string, isDark: boolean): { bg: string; text: string } {
|
function getPlatformStyle(platform: string) {
|
||||||
const key = platform.toLowerCase();
|
const key = platform.toLowerCase();
|
||||||
const match = PLATFORM_STYLES[key];
|
return PLATFORM_STYLES[key] ?? {
|
||||||
if (match) {
|
badge: 'bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/30',
|
||||||
return {
|
border: 'border-slate-500/20',
|
||||||
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) {
|
export default function ChannelCard({ channel, onTopUp, isDark, locale }: ChannelCardProps) {
|
||||||
const platformStyle = getPlatformStyle(channel.platform, isDark);
|
const platformStyle = getPlatformStyle(channel.platform);
|
||||||
const usableQuota = (1 / channel.rateMultiplier).toFixed(2);
|
const usableQuota = (1 / channel.rateMultiplier).toFixed(2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'flex flex-col rounded-2xl border p-5 transition-shadow hover:shadow-lg',
|
'flex flex-col rounded-2xl border p-6 transition-shadow hover:shadow-lg',
|
||||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
|
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{/* Header: Platform badge + Name */}
|
{/* Header: Platform badge + Name */}
|
||||||
|
<div className="mb-4">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<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(' ')}>
|
<span className={['rounded-md border px-2 py-0.5 text-xs font-medium', platformStyle.badge].join(' ')}>
|
||||||
{channel.platform}
|
{channel.platform}
|
||||||
</span>
|
</span>
|
||||||
<h3 className={['text-lg font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
<h3 className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||||
{channel.name}
|
{channel.name}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rate display */}
|
{/* Rate display - prominent */}
|
||||||
<div className="mb-1 flex items-baseline gap-1.5">
|
<div className="mb-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
{pickLocaleText(locale, '当前倍率', 'Rate')}
|
{pickLocaleText(locale, '当前倍率', 'Rate')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base font-semibold text-emerald-500">
|
<div className="flex items-baseline">
|
||||||
1 : {channel.rateMultiplier}
|
<span className="text-xl font-bold text-emerald-500">1</span>
|
||||||
</span>
|
<span className={['mx-1.5 text-lg', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>:</span>
|
||||||
|
<span className="text-xl font-bold text-emerald-500">{channel.rateMultiplier}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{userBalance !== undefined && (
|
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
<p className={['mb-3 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
|
||||||
{pickLocaleText(
|
{pickLocaleText(
|
||||||
locale,
|
locale,
|
||||||
`1元可用约${usableQuota}美元额度`,
|
<>1元可用约<span className="font-medium text-emerald-500">{usableQuota}</span>美元额度</>,
|
||||||
`1 CNY ≈ ${usableQuota} USD quota`,
|
<>1 CNY ≈ <span className="font-medium text-emerald-500">{usableQuota}</span> USD quota</>,
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{channel.description && (
|
{channel.description && (
|
||||||
<p className={['mb-3 text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
<p className={['text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
{channel.description}
|
{channel.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Models */}
|
{/* Models */}
|
||||||
{channel.models.length > 0 && (
|
{channel.models.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-4">
|
||||||
<p className={['mb-1.5 text-xs font-medium uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
<p className={['mb-2 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||||
{pickLocaleText(locale, '支持模型', 'Supported Models')}
|
{pickLocaleText(locale, '支持模型', 'Supported Models')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -107,10 +116,13 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale, userBala
|
|||||||
<span
|
<span
|
||||||
key={model}
|
key={model}
|
||||||
className={[
|
className={[
|
||||||
'rounded-md px-2 py-0.5 text-xs',
|
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs',
|
||||||
isDark ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600',
|
isDark
|
||||||
|
? 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-purple-500/10 text-blue-400'
|
||||||
|
: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-purple-500/10 text-blue-600',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||||
{model}
|
{model}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -120,8 +132,8 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale, userBala
|
|||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
{channel.features.length > 0 && (
|
{channel.features.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-5">
|
||||||
<p className={['mb-1.5 text-xs font-medium uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
<p className={['mb-2 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||||
{pickLocaleText(locale, '功能特性', 'Features')}
|
{pickLocaleText(locale, '功能特性', 'Features')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -129,8 +141,8 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale, userBala
|
|||||||
<span
|
<span
|
||||||
key={feature}
|
key={feature}
|
||||||
className={[
|
className={[
|
||||||
'rounded-md px-2 py-0.5 text-xs',
|
'rounded-md px-2 py-1 text-xs',
|
||||||
isDark ? 'bg-emerald-900/30 text-emerald-300' : 'bg-emerald-50 text-emerald-700',
|
isDark ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{feature}
|
{feature}
|
||||||
@@ -147,8 +159,11 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale, userBala
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onTopUp}
|
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"
|
className="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 py-3 text-sm font-semibold text-white transition-colors hover:bg-emerald-600 active:bg-emerald-700"
|
||||||
>
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||||
|
</svg>
|
||||||
{pickLocaleText(locale, '立即充值', 'Top Up Now')}
|
{pickLocaleText(locale, '立即充值', 'Top Up Now')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Locale } from '@/lib/locale';
|
|||||||
import { pickLocaleText } from '@/lib/locale';
|
import { pickLocaleText } from '@/lib/locale';
|
||||||
import { getPaymentTypeLabel, getPaymentIconSrc } from '@/lib/pay-utils';
|
import { getPaymentTypeLabel, getPaymentIconSrc } from '@/lib/pay-utils';
|
||||||
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
|
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
|
||||||
|
import { formatValidityLabel } from '@/lib/subscription-utils';
|
||||||
|
|
||||||
interface SubscriptionConfirmProps {
|
interface SubscriptionConfirmProps {
|
||||||
plan: PlanInfo;
|
plan: PlanInfo;
|
||||||
@@ -28,10 +29,7 @@ export default function SubscriptionConfirm({
|
|||||||
}: SubscriptionConfirmProps) {
|
}: SubscriptionConfirmProps) {
|
||||||
const [selectedPayment, setSelectedPayment] = useState(paymentTypes[0] || '');
|
const [selectedPayment, setSelectedPayment] = useState(paymentTypes[0] || '');
|
||||||
|
|
||||||
const periodLabel =
|
const periodLabel = formatValidityLabel(plan.validityDays, plan.validityUnit ?? 'day', locale);
|
||||||
plan.validityDays === 30
|
|
||||||
? pickLocaleText(locale, '包月', 'Monthly')
|
|
||||||
: pickLocaleText(locale, `包${plan.validityDays}天`, `${plan.validityDays} Days`);
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (selectedPayment && !loading) {
|
if (selectedPayment && !loading) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Locale } from '@/lib/locale';
|
import type { Locale } from '@/lib/locale';
|
||||||
import { pickLocaleText } from '@/lib/locale';
|
import { pickLocaleText } from '@/lib/locale';
|
||||||
|
import { formatValidityLabel, formatValiditySuffix, type ValidityUnit } from '@/lib/subscription-utils';
|
||||||
|
|
||||||
export interface PlanInfo {
|
export interface PlanInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,6 +12,7 @@ export interface PlanInfo {
|
|||||||
price: number;
|
price: number;
|
||||||
originalPrice: number | null;
|
originalPrice: number | null;
|
||||||
validityDays: number;
|
validityDays: number;
|
||||||
|
validityUnit?: ValidityUnit;
|
||||||
features: string[];
|
features: string[];
|
||||||
description: string | null;
|
description: string | null;
|
||||||
limits: {
|
limits: {
|
||||||
@@ -28,15 +30,9 @@ interface SubscriptionPlanCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale }: SubscriptionPlanCardProps) {
|
export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale }: SubscriptionPlanCardProps) {
|
||||||
const periodLabel =
|
const unit = plan.validityUnit ?? 'day';
|
||||||
plan.validityDays === 30
|
const periodLabel = formatValidityLabel(plan.validityDays, unit, locale);
|
||||||
? pickLocaleText(locale, '包月', 'Monthly')
|
const periodSuffix = formatValiditySuffix(plan.validityDays, unit, locale);
|
||||||
: pickLocaleText(locale, `包${plan.validityDays}天`, `${plan.validityDays} Days`);
|
|
||||||
|
|
||||||
const periodSuffix =
|
|
||||||
plan.validityDays === 30
|
|
||||||
? pickLocaleText(locale, '/月', '/mo')
|
|
||||||
: pickLocaleText(locale, `/${plan.validityDays}天`, `/${plan.validityDays}d`);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ 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, getGroup, assignSubscription } from '@/lib/sub2api/client';
|
import { getUser, createAndRedeem, subtractBalance, addBalance, getGroup } from '@/lib/sub2api/client';
|
||||||
|
import { computeValidityDays, type ValidityUnit } from '@/lib/subscription-utils';
|
||||||
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';
|
||||||
@@ -56,7 +57,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
const orderType = input.orderType ?? 'balance';
|
const orderType = input.orderType ?? 'balance';
|
||||||
|
|
||||||
// ── 订阅订单前置校验 ──
|
// ── 订阅订单前置校验 ──
|
||||||
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; name: string } | null = null;
|
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; validityUnit: string; name: string } | null = null;
|
||||||
if (orderType === 'subscription') {
|
if (orderType === 'subscription') {
|
||||||
if (!input.planId) {
|
if (!input.planId) {
|
||||||
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
|
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
|
||||||
@@ -180,7 +181,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
orderType,
|
orderType,
|
||||||
planId: subscriptionPlan?.id ?? null,
|
planId: subscriptionPlan?.id ?? null,
|
||||||
subscriptionGroupId: subscriptionPlan?.groupId ?? null,
|
subscriptionGroupId: subscriptionPlan?.groupId ?? null,
|
||||||
subscriptionDays: subscriptionPlan?.validityDays ?? null,
|
subscriptionDays: subscriptionPlan
|
||||||
|
? computeValidityDays(subscriptionPlan.validityDays, subscriptionPlan.validityUnit as ValidityUnit)
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -598,12 +601,16 @@ export async function executeSubscriptionFulfillment(orderId: string): Promise<v
|
|||||||
throw new Error(`Subscription group ${order.subscriptionGroupId} no longer exists or inactive`);
|
throw new Error(`Subscription group ${order.subscriptionGroupId} no longer exists or inactive`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await assignSubscription(
|
await createAndRedeem(
|
||||||
|
order.rechargeCode,
|
||||||
|
Number(order.amount),
|
||||||
order.userId,
|
order.userId,
|
||||||
order.subscriptionGroupId,
|
|
||||||
order.subscriptionDays,
|
|
||||||
`sub2apipay subscription order:${orderId}`,
|
`sub2apipay subscription order:${orderId}`,
|
||||||
`sub2apipay:subscription:${order.rechargeCode}`,
|
{
|
||||||
|
type: 'subscription',
|
||||||
|
groupId: order.subscriptionGroupId,
|
||||||
|
validityDays: order.subscriptionDays,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await prisma.order.updateMany({
|
await prisma.order.updateMany({
|
||||||
|
|||||||
@@ -60,15 +60,20 @@ export async function createAndRedeem(
|
|||||||
value: number,
|
value: number,
|
||||||
userId: number,
|
userId: number,
|
||||||
notes: string,
|
notes: string,
|
||||||
|
options?: { type?: 'balance' | 'subscription'; groupId?: number; validityDays?: number },
|
||||||
): Promise<Sub2ApiRedeemCode> {
|
): Promise<Sub2ApiRedeemCode> {
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const url = `${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`;
|
const url = `${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`;
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
code,
|
code,
|
||||||
type: 'balance',
|
type: options?.type ?? 'balance',
|
||||||
value,
|
value,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
notes,
|
notes,
|
||||||
|
...(options?.type === 'subscription' && {
|
||||||
|
group_id: options.groupId,
|
||||||
|
validity_days: options.validityDays,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|||||||
90
src/lib/subscription-utils.ts
Normal file
90
src/lib/subscription-utils.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
export type ValidityUnit = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据数值和单位计算实际有效天数。
|
||||||
|
* - day: 直接返回
|
||||||
|
* - week: value * 7
|
||||||
|
* - month: 从 fromDate 到 value 个月后同一天的天数差
|
||||||
|
*/
|
||||||
|
export function computeValidityDays(value: number, unit: ValidityUnit, fromDate?: Date): number {
|
||||||
|
if (unit === 'day') return value;
|
||||||
|
if (unit === 'week') return value * 7;
|
||||||
|
|
||||||
|
// month: 计算到 value 个月后同一天的天数差
|
||||||
|
const from = fromDate ?? new Date();
|
||||||
|
const target = new Date(from);
|
||||||
|
target.setMonth(target.getMonth() + value);
|
||||||
|
return Math.round((target.getTime() - from.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能格式化有效期显示文本。
|
||||||
|
* - unit=month, value=1 → 包月 / Monthly
|
||||||
|
* - unit=month, value=3 → 包3月 / 3 Months
|
||||||
|
* - unit=week, value=2 → 包2周 / 2 Weeks
|
||||||
|
* - unit=day, value=30 → 包月 / Monthly (特殊处理)
|
||||||
|
* - unit=day, value=90 → 包90天 / 90 Days
|
||||||
|
*/
|
||||||
|
export function formatValidityLabel(
|
||||||
|
value: number,
|
||||||
|
unit: ValidityUnit,
|
||||||
|
locale: 'zh' | 'en',
|
||||||
|
): string {
|
||||||
|
if (unit === 'month') {
|
||||||
|
if (value === 1) return locale === 'zh' ? '包月' : 'Monthly';
|
||||||
|
return locale === 'zh' ? `包${value}月` : `${value} Months`;
|
||||||
|
}
|
||||||
|
if (unit === 'week') {
|
||||||
|
if (value === 1) return locale === 'zh' ? '包周' : 'Weekly';
|
||||||
|
return locale === 'zh' ? `包${value}周` : `${value} Weeks`;
|
||||||
|
}
|
||||||
|
// day
|
||||||
|
if (value === 30) return locale === 'zh' ? '包月' : 'Monthly';
|
||||||
|
return locale === 'zh' ? `包${value}天` : `${value} Days`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能格式化有效期后缀(用于价格展示)。
|
||||||
|
* - unit=month, value=1 → /月 / /mo
|
||||||
|
* - unit=month, value=3 → /3月 / /3mo
|
||||||
|
* - unit=week, value=2 → /2周 / /2wk
|
||||||
|
* - unit=day, value=30 → /月 / /mo
|
||||||
|
* - unit=day, value=90 → /90天 / /90d
|
||||||
|
*/
|
||||||
|
export function formatValiditySuffix(
|
||||||
|
value: number,
|
||||||
|
unit: ValidityUnit,
|
||||||
|
locale: 'zh' | 'en',
|
||||||
|
): string {
|
||||||
|
if (unit === 'month') {
|
||||||
|
if (value === 1) return locale === 'zh' ? '/月' : '/mo';
|
||||||
|
return locale === 'zh' ? `/${value}月` : `/${value}mo`;
|
||||||
|
}
|
||||||
|
if (unit === 'week') {
|
||||||
|
if (value === 1) return locale === 'zh' ? '/周' : '/wk';
|
||||||
|
return locale === 'zh' ? `/${value}周` : `/${value}wk`;
|
||||||
|
}
|
||||||
|
// day
|
||||||
|
if (value === 30) return locale === 'zh' ? '/月' : '/mo';
|
||||||
|
return locale === 'zh' ? `/${value}天` : `/${value}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化有效期列表展示文本(管理后台表格用)。
|
||||||
|
* - unit=day → "30 天"
|
||||||
|
* - unit=week → "2 周"
|
||||||
|
* - unit=month → "1 月"
|
||||||
|
*/
|
||||||
|
export function formatValidityDisplay(
|
||||||
|
value: number,
|
||||||
|
unit: ValidityUnit,
|
||||||
|
locale: 'zh' | 'en',
|
||||||
|
): string {
|
||||||
|
const unitLabels: Record<ValidityUnit, { zh: string; en: string }> = {
|
||||||
|
day: { zh: '天', en: 'day(s)' },
|
||||||
|
week: { zh: '周', en: 'week(s)' },
|
||||||
|
month: { zh: '月', en: 'month(s)' },
|
||||||
|
};
|
||||||
|
const label = locale === 'zh' ? unitLabels[unit].zh : unitLabels[unit].en;
|
||||||
|
return `${value} ${label}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user