feat: 渠道展示、订阅套餐、系统配置全功能

- 新增 Channel / SubscriptionPlan / SystemConfig 三个数据模型
- Order 模型扩展支持订阅订单(order_type, plan_id, subscription_group_id)
- Sub2API client 新增分组查询、订阅分配/续期、用户订阅查询
- 订单服务支持订阅履约流程(CAS 锁 + 分组消失安全处理)
- 管理后台:渠道管理、订阅套餐管理、系统配置、Sub2API 分组同步
- 用户页面:双 Tab UI(按量付费/包月订阅)、渠道卡片、充值弹窗、订阅确认
- PaymentForm 支持 fixedAmount 固定金额模式
- 订单状态 API 返回 failedReason 用于订阅异常展示
- 数据库迁移脚本
This commit is contained in:
erio
2026-03-13 19:06:25 +08:00
parent 9f621713c3
commit eafb7e49fa
38 changed files with 5376 additions and 289 deletions

View File

@@ -0,0 +1,156 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
export interface ChannelInfo {
id: string;
groupId: number;
name: string;
platform: string;
rateMultiplier: number;
description: string | null;
models: string[];
features: string[];
}
interface ChannelCardProps {
channel: ChannelInfo;
onTopUp: () => void;
isDark: boolean;
locale: Locale;
userBalance?: number;
}
const PLATFORM_STYLES: Record<string, { bg: string; text: string }> = {
claude: { bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300' },
openai: { bg: 'bg-green-100 dark:bg-green-900/40', text: 'text-green-700 dark:text-green-300' },
gemini: { bg: 'bg-purple-100 dark:bg-purple-900/40', text: 'text-purple-700 dark:text-purple-300' },
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' },
};
function getPlatformStyle(platform: string, isDark: boolean): { bg: string; text: string } {
const key = platform.toLowerCase();
const match = PLATFORM_STYLES[key];
if (match) {
return {
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) {
const platformStyle = getPlatformStyle(channel.platform, isDark);
const usableQuota = (1 / channel.rateMultiplier).toFixed(2);
return (
<div
className={[
'flex flex-col rounded-2xl border p-5 transition-shadow hover:shadow-lg',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
].join(' ')}
>
{/* Header: Platform badge + Name */}
<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(' ')}>
{channel.platform}
</span>
<h3 className={['text-lg font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{channel.name}
</h3>
</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 */}
{channel.models.length > 0 && (
<div className="mb-3">
<p className={['mb-1.5 text-xs font-medium uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{pickLocaleText(locale, '支持模型', 'Supported Models')}
</p>
<div className="flex flex-wrap gap-1.5">
{channel.models.map((model) => (
<span
key={model}
className={[
'rounded-md px-2 py-0.5 text-xs',
isDark ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600',
].join(' ')}
>
{model}
</span>
))}
</div>
</div>
)}
{/* Features */}
{channel.features.length > 0 && (
<div className="mb-4">
<p className={['mb-1.5 text-xs font-medium uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
{pickLocaleText(locale, '功能特性', 'Features')}
</p>
<div className="flex flex-wrap gap-1.5">
{channel.features.map((feature) => (
<span
key={feature}
className={[
'rounded-md px-2 py-0.5 text-xs',
isDark ? 'bg-emerald-900/30 text-emerald-300' : 'bg-emerald-50 text-emerald-700',
].join(' ')}
>
{feature}
</span>
))}
</div>
</div>
)}
{/* Spacer to push button to bottom */}
<div className="flex-1" />
{/* Top-up button */}
<button
type="button"
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"
>
{pickLocaleText(locale, '立即充值', 'Top Up Now')}
</button>
</div>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import ChannelCard from '@/components/ChannelCard';
import type { ChannelInfo } from '@/components/ChannelCard';
interface ChannelGridProps {
channels: ChannelInfo[];
onTopUp: () => void;
isDark: boolean;
locale: Locale;
userBalance?: number;
}
export type { ChannelInfo };
export default function ChannelGrid({ channels, onTopUp, isDark, locale, userBalance }: ChannelGridProps) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{channels.map((channel) => (
<ChannelCard
key={channel.id}
channel={channel}
onTopUp={onTopUp}
isDark={isDark}
locale={locale}
userBalance={userBalance}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
interface MainTabsProps {
activeTab: 'topup' | 'subscribe';
onTabChange: (tab: 'topup' | 'subscribe') => void;
showSubscribeTab: boolean;
isDark: boolean;
locale: Locale;
}
export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, isDark, locale }: MainTabsProps) {
if (!showSubscribeTab) return null;
const tabs: { key: 'topup' | 'subscribe'; label: string }[] = [
{ key: 'topup', label: pickLocaleText(locale, '按量付费', 'Pay-as-you-go') },
{ key: 'subscribe', label: pickLocaleText(locale, '包月套餐', 'Subscription') },
];
return (
<div
className={[
'inline-flex rounded-xl p-1',
isDark ? 'bg-slate-800' : 'bg-slate-100',
].join(' ')}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
className={[
'rounded-lg px-5 py-2 text-sm font-medium transition-all',
isActive
? isDark
? 'bg-slate-700 text-slate-100 shadow-sm'
: 'bg-white text-slate-900 shadow-sm'
: isDark
? 'text-slate-400 hover:text-slate-200'
: 'text-slate-500 hover:text-slate-700',
].join(' ')}
>
{tab.label}
</button>
);
})}
</div>
);
}

View File

@@ -27,6 +27,8 @@ interface PaymentFormProps {
pendingBlocked?: boolean;
pendingCount?: number;
locale?: Locale;
/** 固定金额模式:隐藏金额选择,只显示支付方式和提交按钮 */
fixedAmount?: number;
}
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
@@ -50,10 +52,11 @@ export default function PaymentForm({
pendingBlocked = false,
pendingCount = 0,
locale = 'zh',
fixedAmount,
}: PaymentFormProps) {
const [amount, setAmount] = useState<number | ''>('');
const [amount, setAmount] = useState<number | ''>(fixedAmount ?? '');
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
const [customAmount, setCustomAmount] = useState('');
const [customAmount, setCustomAmount] = useState(fixedAmount ? String(fixedAmount) : '');
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
? paymentType
@@ -166,60 +169,76 @@ export default function PaymentForm({
)}
</div>
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
</label>
<div className="grid grid-cols-3 gap-2">
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
<button
key={val}
type="button"
onClick={() => handleQuickAmount(val)}
className={`rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors ${
amount === val
? 'border-blue-500 bg-blue-50 text-blue-700'
: dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
}`}
>
¥{val}
</button>
))}
{fixedAmount ? (
<div className={[
'rounded-xl border p-4 text-center',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
].join(' ')}>
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
</div>
<div className={['mt-1 text-3xl font-bold', dark ? 'text-emerald-400' : 'text-emerald-600'].join(' ')}>
¥{fixedAmount.toFixed(2)}
</div>
</div>
</div>
) : (
<>
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
</label>
<div className="grid grid-cols-3 gap-2">
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
<button
key={val}
type="button"
onClick={() => handleQuickAmount(val)}
className={`rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors ${
amount === val
? 'border-blue-500 bg-blue-50 text-blue-700'
: dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
}`}
>
¥{val}
</button>
))}
</div>
</div>
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
</label>
<div className="relative">
<span
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
' ',
)}
>
¥
</span>
<input
type="text"
inputMode="decimal"
step="0.01"
min={minAmount}
max={effectiveMax}
value={customAmount}
onChange={(e) => handleCustomAmountChange(e.target.value)}
placeholder={`${minAmount} - ${effectiveMax}`}
className={[
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
].join(' ')}
/>
</div>
</div>
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
</label>
<div className="relative">
<span
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
' ',
)}
>
¥
</span>
<input
type="text"
inputMode="decimal"
step="0.01"
min={minAmount}
max={effectiveMax}
value={customAmount}
onChange={(e) => handleCustomAmountChange(e.target.value)}
placeholder={`${minAmount} - ${effectiveMax}`}
className={[
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
].join(' ')}
/>
</div>
</div>
</>
)}
{customAmount !== '' &&
{!fixedAmount && customAmount !== '' &&
!isValid &&
(() => {
const num = parseFloat(customAmount);

View File

@@ -0,0 +1,134 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
interface PurchaseFlowProps {
isDark: boolean;
locale: Locale;
}
interface Step {
icon: React.ReactNode;
zh: string;
en: string;
}
const STEPS: Step[] = [
{
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
zh: '选择套餐',
en: 'Select Plan',
},
{
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
),
zh: '完成支付',
en: 'Complete Payment',
},
{
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
),
zh: '获取激活码',
en: 'Get Activation',
},
{
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
zh: '激活使用',
en: 'Start Using',
},
];
export default function PurchaseFlow({ isDark, locale }: PurchaseFlowProps) {
return (
<div
className={[
'rounded-2xl border p-6',
isDark ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<h3 className={['mb-5 text-center text-sm font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, '购买流程', 'How It Works')}
</h3>
{/* Desktop: horizontal */}
<div className="hidden items-center justify-center sm:flex">
{STEPS.map((step, idx) => (
<React.Fragment key={idx}>
{/* Step */}
<div className="flex flex-col items-center gap-2">
<div
className={[
'flex h-12 w-12 items-center justify-center rounded-full',
isDark ? 'bg-emerald-900/40 text-emerald-400' : 'bg-emerald-100 text-emerald-600',
].join(' ')}
>
{step.icon}
</div>
<span className={['text-xs font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{pickLocaleText(locale, step.zh, step.en)}
</span>
</div>
{/* Connector */}
{idx < STEPS.length - 1 && (
<div
className={[
'mx-4 h-px w-12 flex-shrink-0',
isDark ? 'bg-slate-700' : 'bg-slate-300',
].join(' ')}
/>
)}
</React.Fragment>
))}
</div>
{/* Mobile: vertical */}
<div className="flex flex-col items-start gap-0 sm:hidden">
{STEPS.map((step, idx) => (
<React.Fragment key={idx}>
{/* Step */}
<div className="flex items-center gap-3">
<div
className={[
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
isDark ? 'bg-emerald-900/40 text-emerald-400' : 'bg-emerald-100 text-emerald-600',
].join(' ')}
>
{step.icon}
</div>
<span className={['text-sm font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{pickLocaleText(locale, step.zh, step.en)}
</span>
</div>
{/* Connector */}
{idx < STEPS.length - 1 && (
<div
className={[
'ml-5 h-6 w-px',
isDark ? 'bg-slate-700' : 'bg-slate-300',
].join(' ')}
/>
)}
</React.Fragment>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
import { getPaymentTypeLabel, getPaymentIconSrc } from '@/lib/pay-utils';
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
interface SubscriptionConfirmProps {
plan: PlanInfo;
paymentTypes: string[];
onBack: () => void;
onSubmit: (paymentType: string) => void;
loading: boolean;
isDark: boolean;
locale: Locale;
}
export default function SubscriptionConfirm({
plan,
paymentTypes,
onBack,
onSubmit,
loading,
isDark,
locale,
}: SubscriptionConfirmProps) {
const [selectedPayment, setSelectedPayment] = useState(paymentTypes[0] || '');
const periodLabel =
plan.validityDays === 30
? pickLocaleText(locale, '包月', 'Monthly')
: pickLocaleText(locale, `${plan.validityDays}`, `${plan.validityDays} Days`);
const handleSubmit = () => {
if (selectedPayment && !loading) {
onSubmit(selectedPayment);
}
};
return (
<div className="mx-auto max-w-lg space-y-6">
{/* Back link */}
<button
type="button"
onClick={onBack}
className={[
'flex items-center gap-1 text-sm transition-colors',
isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
].join(' ')}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{pickLocaleText(locale, '返回套餐页面', 'Back to Plans')}
</button>
{/* Title */}
<h2 className={['text-xl font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{pickLocaleText(locale, '确认订单', 'Confirm Order')}
</h2>
{/* Plan info card */}
<div
className={[
'rounded-xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{plan.name}
</span>
<span
className={[
'rounded-full px-2 py-0.5 text-xs font-medium',
isDark ? 'bg-emerald-900/40 text-emerald-300' : 'bg-emerald-50 text-emerald-700',
].join(' ')}
>
{periodLabel}
</span>
</div>
</div>
{plan.features.length > 0 && (
<ul className="space-y-1">
{plan.features.map((feature) => (
<li key={feature} className={['flex items-center gap-1.5 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
<svg className="h-3.5 w-3.5 shrink-0 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{feature}
</li>
))}
</ul>
)}
</div>
{/* Payment method selector */}
<div>
<label className={['mb-2 block text-sm font-medium', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{pickLocaleText(locale, '支付方式', 'Payment Method')}
</label>
<div className="space-y-2">
{paymentTypes.map((type) => {
const isSelected = selectedPayment === type;
const iconSrc = getPaymentIconSrc(type);
return (
<button
key={type}
type="button"
onClick={() => setSelectedPayment(type)}
className={[
'flex w-full items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all',
isSelected
? 'border-emerald-500 ring-1 ring-emerald-500/30'
: isDark
? 'border-slate-700 hover:border-slate-600'
: 'border-slate-200 hover:border-slate-300',
isSelected
? isDark
? 'bg-emerald-950/30'
: 'bg-emerald-50/50'
: isDark
? 'bg-slate-800/60'
: 'bg-white',
].join(' ')}
>
{/* Radio indicator */}
<span
className={[
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2',
isSelected ? 'border-emerald-500' : isDark ? 'border-slate-600' : 'border-slate-300',
].join(' ')}
>
{isSelected && <span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />}
</span>
{/* Icon */}
{iconSrc && (
<Image src={iconSrc} alt="" width={24} height={24} className="h-6 w-6 shrink-0 object-contain" />
)}
{/* Label */}
<span className={['text-sm font-medium', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{getPaymentTypeLabel(type, locale)}
</span>
</button>
);
})}
</div>
</div>
{/* Amount to pay */}
<div
className={[
'flex items-center justify-between rounded-xl border px-4 py-3',
isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<span className={['text-sm font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{pickLocaleText(locale, '应付金额', 'Amount Due')}
</span>
<span className="text-xl font-bold text-emerald-500">¥{plan.price}</span>
</div>
{/* Submit button */}
<button
type="button"
disabled={!selectedPayment || loading}
onClick={handleSubmit}
className={[
'w-full rounded-xl py-3 text-sm font-bold text-white transition-colors',
selectedPayment && !loading
? 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700'
: isDark
? 'cursor-not-allowed bg-slate-700 text-slate-400'
: 'cursor-not-allowed bg-slate-200 text-slate-400',
].join(' ')}
>
{loading
? pickLocaleText(locale, '处理中...', 'Processing...')
: pickLocaleText(locale, '立即购买', 'Buy Now')}
</button>
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
export interface PlanInfo {
id: string;
groupId: number;
name: string;
price: number;
originalPrice: number | null;
validityDays: number;
features: string[];
description: string | null;
limits: {
daily_limit_usd: number | null;
weekly_limit_usd: number | null;
monthly_limit_usd: number | null;
} | null;
}
interface SubscriptionPlanCardProps {
plan: PlanInfo;
onSubscribe: (planId: string) => void;
isDark: boolean;
locale: Locale;
}
export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale }: SubscriptionPlanCardProps) {
const periodLabel =
plan.validityDays === 30
? pickLocaleText(locale, '包月', 'Monthly')
: pickLocaleText(locale, `${plan.validityDays}`, `${plan.validityDays} Days`);
const periodSuffix =
plan.validityDays === 30
? pickLocaleText(locale, '/月', '/mo')
: pickLocaleText(locale, `/${plan.validityDays}`, `/${plan.validityDays}d`);
return (
<div
className={[
'flex flex-col rounded-2xl border p-5 transition-shadow hover:shadow-lg',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
].join(' ')}
>
{/* Name + Period badge */}
<div className="mb-3 flex items-center gap-2">
<h3 className={['text-lg font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{plan.name}
</h3>
<span
className={[
'rounded-full px-2.5 py-0.5 text-xs font-medium',
isDark ? 'bg-emerald-900/40 text-emerald-300' : 'bg-emerald-50 text-emerald-700',
].join(' ')}
>
{periodLabel}
</span>
</div>
{/* Price */}
<div className="mb-4 flex items-baseline gap-2">
{plan.originalPrice !== null && (
<span className={['text-sm line-through', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
¥{plan.originalPrice}
</span>
)}
<span className="text-3xl font-bold text-emerald-500">¥{plan.price}</span>
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{periodSuffix}
</span>
</div>
{/* Description */}
{plan.description && (
<p className={['mb-3 text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{plan.description}
</p>
)}
{/* Features */}
{plan.features.length > 0 && (
<ul className="mb-4 space-y-2">
{plan.features.map((feature) => (
<li key={feature} className={['flex items-start gap-2 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
<svg className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{feature}
</li>
))}
</ul>
)}
{/* Limits */}
{plan.limits && (
<div className={['mb-4 rounded-lg p-3 text-xs', isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-slate-50 text-slate-500'].join(' ')}>
<p className="mb-1 font-medium uppercase tracking-wide">
{pickLocaleText(locale, '用量限制', 'Usage Limits')}
</p>
<div className="space-y-0.5">
{plan.limits.daily_limit_usd !== null && (
<p>{pickLocaleText(locale, `每日: $${plan.limits.daily_limit_usd}`, `Daily: $${plan.limits.daily_limit_usd}`)}</p>
)}
{plan.limits.weekly_limit_usd !== null && (
<p>{pickLocaleText(locale, `每周: $${plan.limits.weekly_limit_usd}`, `Weekly: $${plan.limits.weekly_limit_usd}`)}</p>
)}
{plan.limits.monthly_limit_usd !== null && (
<p>{pickLocaleText(locale, `每月: $${plan.limits.monthly_limit_usd}`, `Monthly: $${plan.limits.monthly_limit_usd}`)}</p>
)}
</div>
</div>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Subscribe button */}
<button
type="button"
onClick={() => onSubscribe(plan.id)}
className="mt-2 w-full rounded-xl bg-emerald-500 py-2.5 text-sm font-bold text-white transition-colors hover:bg-emerald-600 active:bg-emerald-700"
>
{pickLocaleText(locale, '立即开通', 'Subscribe Now')}
</button>
</div>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import React, { useState } from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
interface TopUpModalProps {
open: boolean;
onClose: () => void;
onConfirm: (amount: number) => void;
amounts?: number[];
isDark: boolean;
locale: Locale;
}
const DEFAULT_AMOUNTS = [50, 100, 500, 1000];
export default function TopUpModal({ open, onClose, onConfirm, amounts, isDark, locale }: TopUpModalProps) {
const amountOptions = amounts ?? DEFAULT_AMOUNTS;
const [selected, setSelected] = useState<number | null>(null);
if (!open) return null;
const handleConfirm = () => {
if (selected !== null) {
onConfirm(selected);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div
className={[
'relative mx-4 w-full max-w-md rounded-2xl border p-6 shadow-2xl',
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-slate-200 bg-white text-slate-900',
].join(' ')}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold">
{pickLocaleText(locale, '选择充值金额', 'Select Amount')}
</h2>
<button
type="button"
onClick={onClose}
className={[
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
isDark ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600',
].join(' ')}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Amount grid */}
<div className="mb-6 grid grid-cols-2 gap-3">
{amountOptions.map((amount) => {
const isSelected = selected === amount;
return (
<button
key={amount}
type="button"
onClick={() => setSelected(amount)}
className={[
'flex flex-col items-center rounded-xl border-2 px-4 py-4 transition-all',
isSelected
? 'border-emerald-500 ring-2 ring-emerald-500/30'
: isDark
? 'border-slate-700 hover:border-slate-600'
: 'border-slate-200 hover:border-slate-300',
isSelected
? isDark
? 'bg-emerald-950/40'
: 'bg-emerald-50'
: isDark
? 'bg-slate-800/60'
: 'bg-slate-50',
].join(' ')}
>
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, `余额充值${amount}$`, `Balance +${amount}$`)}
</span>
<span className="mt-1 text-2xl font-bold text-emerald-500">
¥{amount}
</span>
</button>
);
})}
</div>
{/* Confirm button */}
<button
type="button"
disabled={selected === null}
onClick={handleConfirm}
className={[
'w-full rounded-xl py-3 text-sm font-semibold text-white transition-colors',
selected !== null
? 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700'
: isDark
? 'cursor-not-allowed bg-slate-700 text-slate-400'
: 'cursor-not-allowed bg-slate-200 text-slate-400',
].join(' ')}
>
{pickLocaleText(locale, '确认充值', 'Confirm')}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import React from 'react';
import type { Locale } from '@/lib/locale';
import { pickLocaleText } from '@/lib/locale';
export interface UserSub {
id: number;
group_id: number;
starts_at: string;
expires_at: string;
status: string;
daily_usage_usd: number;
weekly_usage_usd: number;
monthly_usage_usd: number;
}
interface UserSubscriptionsProps {
subscriptions: UserSub[];
onRenew: (groupId: number) => void;
isDark: boolean;
locale: Locale;
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
function daysUntil(iso: string): number {
const now = new Date();
const target = new Date(iso);
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
function getStatusBadge(status: string, isDark: boolean, locale: Locale): { text: string; className: string } {
const statusMap: Record<string, { zh: string; en: string; cls: string; clsDark: string }> = {
active: { zh: '生效中', en: 'Active', cls: 'bg-emerald-100 text-emerald-700', clsDark: 'bg-emerald-900/40 text-emerald-300' },
expired: { zh: '已过期', en: 'Expired', cls: 'bg-slate-100 text-slate-600', clsDark: 'bg-slate-700 text-slate-400' },
cancelled: { zh: '已取消', en: 'Cancelled', cls: 'bg-red-100 text-red-700', clsDark: 'bg-red-900/40 text-red-300' },
};
const entry = statusMap[status] || { zh: status, en: status, cls: 'bg-slate-100 text-slate-600', clsDark: 'bg-slate-700 text-slate-400' };
return {
text: pickLocaleText(locale, entry.zh, entry.en),
className: isDark ? entry.clsDark : entry.cls,
};
}
export default function UserSubscriptions({ subscriptions, onRenew, isDark, locale }: UserSubscriptionsProps) {
if (subscriptions.length === 0) {
return (
<div
className={[
'flex flex-col items-center justify-center rounded-2xl border py-16',
isDark ? 'border-slate-700 bg-slate-800/50 text-slate-400' : 'border-slate-200 bg-slate-50 text-slate-500',
].join(' ')}
>
<svg className="mb-3 h-12 w-12 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm">{pickLocaleText(locale, '暂无订阅', 'No Subscriptions')}</p>
</div>
);
}
return (
<div className="space-y-4">
{subscriptions.map((sub) => {
const remaining = daysUntil(sub.expires_at);
const isExpiringSoon = remaining > 0 && remaining <= 7;
const badge = getStatusBadge(sub.status, isDark, locale);
return (
<div
key={sub.id}
className={[
'rounded-2xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
].join(' ')}
>
{/* Header */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{pickLocaleText(locale, `渠道 #${sub.group_id}`, `Channel #${sub.group_id}`)}
</span>
<span className={['rounded-full px-2 py-0.5 text-xs font-medium', badge.className].join(' ')}>
{badge.text}
</span>
</div>
{sub.status === 'active' && (
<button
type="button"
onClick={() => onRenew(sub.group_id)}
className="rounded-lg bg-emerald-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-emerald-600 active:bg-emerald-700"
>
{pickLocaleText(locale, '续费', 'Renew')}
</button>
)}
</div>
{/* Dates */}
<div className={['mb-3 grid grid-cols-2 gap-3 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
<div>
<span className="text-xs uppercase tracking-wide">{pickLocaleText(locale, '开始', 'Start')}</span>
<p className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
{formatDate(sub.starts_at)}
</p>
</div>
<div>
<span className="text-xs uppercase tracking-wide">{pickLocaleText(locale, '到期', 'Expires')}</span>
<p className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
{formatDate(sub.expires_at)}
</p>
</div>
</div>
{/* Expiry warning */}
{isExpiringSoon && (
<div
className={[
'mb-3 rounded-lg px-3 py-2 text-xs font-medium',
isDark ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700',
].join(' ')}
>
{pickLocaleText(
locale,
`即将到期,剩余 ${remaining}`,
`Expiring soon, ${remaining} days remaining`,
)}
</div>
)}
{/* Usage stats */}
<div
className={[
'grid grid-cols-3 gap-2 rounded-lg p-3 text-center text-xs',
isDark ? 'bg-slate-900/60' : 'bg-slate-50',
].join(' ')}
>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
{pickLocaleText(locale, '日用量', 'Daily')}
</span>
<p className={['mt-0.5 font-semibold', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
${sub.daily_usage_usd.toFixed(2)}
</p>
</div>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
{pickLocaleText(locale, '周用量', 'Weekly')}
</span>
<p className={['mt-0.5 font-semibold', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
${sub.weekly_usage_usd.toFixed(2)}
</p>
</div>
<div>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
{pickLocaleText(locale, '月用量', 'Monthly')}
</span>
<p className={['mt-0.5 font-semibold', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
${sub.monthly_usage_usd.toFixed(2)}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}