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:
156
src/components/ChannelCard.tsx
Normal file
156
src/components/ChannelCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/ChannelGrid.tsx
Normal file
33
src/components/ChannelGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/MainTabs.tsx
Normal file
54
src/components/MainTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
134
src/components/PurchaseFlow.tsx
Normal file
134
src/components/PurchaseFlow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
src/components/SubscriptionConfirm.tsx
Normal file
188
src/components/SubscriptionConfirm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/SubscriptionPlanCard.tsx
Normal file
130
src/components/SubscriptionPlanCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/components/TopUpModal.tsx
Normal file
113
src/components/TopUpModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/components/UserSubscriptions.tsx
Normal file
172
src/components/UserSubscriptions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user