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:
erio
2026-03-13 21:19:22 +08:00
parent 9096271307
commit 687336cfd8
16 changed files with 672 additions and 1027 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscription_plans" ADD COLUMN "validity_unit" TEXT NOT NULL DEFAULT 'day';

View File

@@ -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")

View File

@@ -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() {

View File

@@ -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>
);
}

View File

@@ -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;
<td className={tdCls}> const monthlyLimit = group?.monthly_limit_usd ?? null;
<span
className={[ return (
'inline-block rounded-full px-2 py-0.5 text-xs font-medium', <tr key={sub.id} className={`border-b ${rowBorderCls} last:border-b-0`}>
sub.status === 'active' {/* Group */}
? isDark <td className={tdCls}>
? 'bg-green-500/20 text-green-300' <div className="flex items-center gap-1.5">
: 'bg-green-50 text-green-700' <span
: isDark className={`inline-block h-2 w-2 rounded-full ${sub.status === 'active' ? 'bg-green-500' : 'bg-slate-400'}`}
? 'bg-slate-700 text-slate-400' />
: 'bg-gray-100 text-gray-500', <span className="font-medium">{gName}</span>
].join(' ')} </div>
> <div className={`mt-0.5 text-xs font-mono ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
{sub.status} ID: {sub.group_id}
</span> </div>
</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 */}
</tr> <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>
);
})}
</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>
); );
} }

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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,
}; };

View File

@@ -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',
].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 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')} {pickLocaleText(locale, '按量付费模式', 'Pay-as-you-go')}
</div> </h3>
<div className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}> <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>
<ChannelGrid {hasChannels ? (
channels={channels} <ChannelGrid
onTopUp={() => setTopUpModalOpen(true)} channels={channels}
isDark={isDark} onTopUp={() => setShowTopUpForm(true)}
locale={locale} isDark={isDark}
userBalance={userInfo?.balance} locale={locale}
/> 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,32 +738,35 @@ 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 <>
plan={selectedPlan} <SubscriptionConfirm
paymentTypes={config.enabledPaymentTypes} plan={selectedPlan}
onBack={() => setSelectedPlan(null)} paymentTypes={config.enabledPaymentTypes}
onSubmit={handleSubscriptionSubmit} onBack={() => setSelectedPlan(null)}
loading={loading} onSubmit={handleSubscriptionSubmit}
isDark={isDark} loading={loading}
locale={locale} isDark={isDark}
/> 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,25 +868,28 @@ function PayContent() {
{/* ── 支付阶段 ── */} {/* ── 支付阶段 ── */}
{step === 'paying' && orderResult && ( {step === 'paying' && orderResult && (
<PaymentQRCode <>
orderId={orderResult.orderId} <PaymentQRCode
token={token || undefined} orderId={orderResult.orderId}
payUrl={orderResult.payUrl} token={token || undefined}
qrCode={orderResult.qrCode} payUrl={orderResult.payUrl}
clientSecret={orderResult.clientSecret} qrCode={orderResult.qrCode}
stripePublishableKey={config.stripePublishableKey} clientSecret={orderResult.clientSecret}
paymentType={orderResult.paymentType} stripePublishableKey={config.stripePublishableKey}
amount={orderResult.amount} paymentType={orderResult.paymentType}
payAmount={orderResult.payAmount} amount={orderResult.amount}
expiresAt={orderResult.expiresAt} payAmount={orderResult.payAmount}
statusAccessToken={orderResult.statusAccessToken} expiresAt={orderResult.expiresAt}
onStatusChange={handleStatusChange} statusAccessToken={orderResult.statusAccessToken}
onBack={handleBack} onStatusChange={handleStatusChange}
dark={isDark} onBack={handleBack}
isEmbedded={isEmbedded} dark={isDark}
isMobile={isMobile} isEmbedded={isEmbedded}
locale={locale} isMobile={isMobile}
/> locale={locale}
/>
{renderHelpSection()}
</>
)} )}
{/* ── 结果阶段 ── */} {/* ── 结果阶段 ── */}

View File

@@ -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-3 flex items-center gap-2"> <div className="mb-4">
<span className={['rounded-full px-2.5 py-0.5 text-xs font-medium', platformStyle.bg, platformStyle.text].join(' ')}> <div className="mb-3 flex items-center gap-2">
{channel.platform} <span className={['rounded-md border px-2 py-0.5 text-xs font-medium', platformStyle.badge].join(' ')}>
</span> {channel.platform}
<h3 className={['text-lg font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}> </span>
{channel.name} <h3 className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
</h3> {channel.name}
</h3>
</div>
{/* Rate display - prominent */}
<div className="mb-3">
<div className="flex items-baseline gap-2">
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, '当前倍率', 'Rate')}
</span>
<div className="flex items-baseline">
<span className="text-xl font-bold text-emerald-500">1</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>
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(
locale,
<>1<span className="font-medium text-emerald-500">{usableQuota}</span></>,
<>1 CNY <span className="font-medium text-emerald-500">{usableQuota}</span> USD quota</>,
)}
</p>
</div>
{/* Description */}
{channel.description && (
<p className={['text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{channel.description}
</p>
)}
</div> </div>
{/* Rate display */}
<div className="mb-1 flex items-baseline gap-1.5">
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, '当前倍率', 'Rate')}
</span>
<span className="text-base font-semibold text-emerald-500">
1 : {channel.rateMultiplier}
</span>
</div>
{userBalance !== undefined && (
<p className={['mb-3 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{pickLocaleText(
locale,
`1元可用约${usableQuota}美元额度`,
`1 CNY ≈ ${usableQuota} USD quota`,
)}
</p>
)}
{/* Description */}
{channel.description && (
<p className={['mb-3 text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{channel.description}
</p>
)}
{/* Models */} {/* 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>

View File

@@ -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) {

View File

@@ -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

View File

@@ -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({

View File

@@ -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;

View 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}`;
}