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

173 lines
6.9 KiB
TypeScript

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