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:
@@ -23,83 +23,92 @@ interface ChannelCardProps {
|
||||
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' },
|
||||
const PLATFORM_STYLES: Record<string, { badge: string; border: string }> = {
|
||||
claude: {
|
||||
badge: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30',
|
||||
border: 'border-orange-500/20',
|
||||
},
|
||||
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 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',
|
||||
return PLATFORM_STYLES[key] ?? {
|
||||
badge: 'bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/30',
|
||||
border: 'border-slate-500/20',
|
||||
};
|
||||
}
|
||||
|
||||
export default function ChannelCard({ channel, onTopUp, isDark, locale, userBalance }: ChannelCardProps) {
|
||||
const platformStyle = getPlatformStyle(channel.platform, isDark);
|
||||
export default function ChannelCard({ channel, onTopUp, isDark, locale }: ChannelCardProps) {
|
||||
const platformStyle = getPlatformStyle(channel.platform);
|
||||
const usableQuota = (1 / channel.rateMultiplier).toFixed(2);
|
||||
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
].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 className="mb-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className={['rounded-md border px-2 py-0.5 text-xs font-medium', platformStyle.badge].join(' ')}>
|
||||
{channel.platform}
|
||||
</span>
|
||||
<h3 className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{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>
|
||||
|
||||
{/* 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(' ')}>
|
||||
<div className="mb-4">
|
||||
<p className={['mb-2 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '支持模型', 'Supported Models')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -107,10 +116,13 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale, userBala
|
||||
<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',
|
||||
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs',
|
||||
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(' ')}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
@@ -120,8 +132,8 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale, userBala
|
||||
|
||||
{/* 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(' ')}>
|
||||
<div className="mb-5">
|
||||
<p className={['mb-2 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '功能特性', 'Features')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -129,8 +141,8 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale, userBala
|
||||
<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',
|
||||
'rounded-md px-2 py-1 text-xs',
|
||||
isDark ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{feature}
|
||||
@@ -147,8 +159,11 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale, userBala
|
||||
<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"
|
||||
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')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user