feat: 订阅套餐展示优化、平台图标、默认月、用户订阅查询
- 新建共享平台样式模块 platform-style.ts,含各平台 SVG 图标 + 彩色 badge - SubscriptionPlanCard 重设计:平台图标 badge、倍率/限额 grid 展示、OpenAI messages 调度信息 - UserSubscriptions 显示 group_name + 平台 badge - ChannelCard 复用共享平台样式模块 - 管理后台:新建套餐默认 1 月、去掉模型展示、平台图标 badge、OpenAI 信息 - 管理后台用户订阅 tab 默认查询所有订阅(user_id 可选) - Sub2API client 新增 listSubscriptions 函数 - API 返回 allowMessagesDispatch / defaultMappedModel / group_name / platform
This commit is contained in:
@@ -4,6 +4,7 @@ import { useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import { resolveLocale, type Locale } from '@/lib/locale';
|
||||
import { PlatformBadge } from '@/lib/platform-style';
|
||||
|
||||
/* ---------- types ---------- */
|
||||
|
||||
@@ -28,6 +29,8 @@ interface SubscriptionPlan {
|
||||
groupMonthlyLimit: number | null;
|
||||
groupModelScopes: string[] | null;
|
||||
productName: string | null;
|
||||
groupAllowMessagesDispatch: boolean;
|
||||
groupDefaultMappedModel: string | null;
|
||||
}
|
||||
|
||||
interface Sub2ApiGroup {
|
||||
@@ -37,6 +40,10 @@ interface Sub2ApiGroup {
|
||||
daily_limit_usd: number | null;
|
||||
weekly_limit_usd: number | null;
|
||||
monthly_limit_usd: number | null;
|
||||
platform: string | null;
|
||||
rate_multiplier: number | null;
|
||||
allow_messages_dispatch: boolean;
|
||||
default_mapped_model: string | null;
|
||||
}
|
||||
|
||||
interface Sub2ApiSubscription {
|
||||
@@ -393,6 +400,13 @@ function SubscriptionsContent() {
|
||||
fetchGroups();
|
||||
}, [fetchPlans, fetchGroups]);
|
||||
|
||||
/* auto-fetch subs when switching to subs tab */
|
||||
useEffect(() => {
|
||||
if (activeTab === 'subs' && !subsSearched) {
|
||||
fetchSubs();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
/* --- modal helpers --- */
|
||||
const openCreate = () => {
|
||||
setEditingPlan(null);
|
||||
@@ -401,8 +415,8 @@ function SubscriptionsContent() {
|
||||
setFormDescription('');
|
||||
setFormPrice('');
|
||||
setFormOriginalPrice('');
|
||||
setFormValidDays('30');
|
||||
setFormValidUnit('day');
|
||||
setFormValidDays('1');
|
||||
setFormValidUnit('month');
|
||||
setFormFeatures('');
|
||||
setFormSortOrder('0');
|
||||
setFormEnabled(true);
|
||||
@@ -550,7 +564,7 @@ function SubscriptionsContent() {
|
||||
|
||||
/* --- fetch user subs --- */
|
||||
const fetchSubs = async () => {
|
||||
if (!token || !subsUserId.trim()) return;
|
||||
if (!token) return;
|
||||
setSubsLoading(true);
|
||||
setSubsSearched(true);
|
||||
setSubsUser(null);
|
||||
@@ -874,7 +888,7 @@ function SubscriptionsContent() {
|
||||
{plan.groupPlatform && (
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.platform}</span>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{plan.groupPlatform}</div>
|
||||
<div className="mt-0.5"><PlatformBadge platform={plan.groupPlatform} /></div>
|
||||
</div>
|
||||
)}
|
||||
{plan.groupRateMultiplier != null && (
|
||||
@@ -901,20 +915,30 @@ function SubscriptionsContent() {
|
||||
{plan.groupMonthlyLimit != null ? `$${plan.groupMonthlyLimit}` : t.unlimited}
|
||||
</div>
|
||||
</div>
|
||||
{plan.groupModelScopes && plan.groupModelScopes.length > 0 && (
|
||||
<div className="sm:col-span-3">
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{t.modelScopes}</span>
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{plan.groupModelScopes.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className={['inline-block rounded px-1.5 py-0.5 text-[10px]', isDark ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600'].join(' ')}
|
||||
>
|
||||
{m}
|
||||
{plan.groupPlatform?.toLowerCase() === 'openai' && (
|
||||
<>
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>/v1/messages</span>
|
||||
<div className="mt-0.5">
|
||||
<span className={[
|
||||
'inline-block rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||
plan.groupAllowMessagesDispatch
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-50 text-green-700'
|
||||
: isDark ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-500',
|
||||
].join(' ')}>
|
||||
{plan.groupAllowMessagesDispatch ? '✓' : '✗'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{plan.groupDefaultMappedModel && (
|
||||
<div className="sm:col-span-2">
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>默认模型</span>
|
||||
<div className={['font-mono text-[10px]', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{plan.groupDefaultMappedModel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -977,7 +1001,7 @@ function SubscriptionsContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchDropdownOpen(false); fetchSubs(); }}
|
||||
disabled={subsLoading || !subsUserId.trim()}
|
||||
disabled={subsLoading}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
|
||||
isDark
|
||||
@@ -1023,7 +1047,7 @@ function SubscriptionsContent() {
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.loading}</div>
|
||||
) : !subsSearched ? (
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{t.enterUserId}
|
||||
{t.loading}
|
||||
</div>
|
||||
) : subs.length === 0 ? (
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{t.noSubs}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { getUserSubscriptions, getUser } from '@/lib/sub2api/client';
|
||||
import { getUserSubscriptions, getUser, listSubscriptions } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
@@ -8,30 +8,47 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const userId = searchParams.get('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '缺少必填参数: user_id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedUserId = Number(userId);
|
||||
if (!Number.isFinite(parsedUserId) || parsedUserId <= 0) {
|
||||
return NextResponse.json({ error: '无效的 user_id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const [subscriptions, user] = await Promise.all([
|
||||
getUserSubscriptions(parsedUserId),
|
||||
getUser(parsedUserId).catch(() => null),
|
||||
]);
|
||||
|
||||
// 如果提供了 group_id 筛选,过滤结果
|
||||
const groupId = searchParams.get('group_id');
|
||||
const filtered = groupId
|
||||
? subscriptions.filter((s) => s.group_id === Number(groupId))
|
||||
: subscriptions;
|
||||
const status = searchParams.get('status');
|
||||
const page = searchParams.get('page');
|
||||
const pageSize = searchParams.get('page_size');
|
||||
|
||||
if (userId) {
|
||||
// 按用户查询(原有逻辑)
|
||||
const parsedUserId = Number(userId);
|
||||
if (!Number.isFinite(parsedUserId) || parsedUserId <= 0) {
|
||||
return NextResponse.json({ error: '无效的 user_id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const [subscriptions, user] = await Promise.all([
|
||||
getUserSubscriptions(parsedUserId),
|
||||
getUser(parsedUserId).catch(() => null),
|
||||
]);
|
||||
|
||||
const filtered = groupId
|
||||
? subscriptions.filter((s) => s.group_id === Number(groupId))
|
||||
: subscriptions;
|
||||
|
||||
return NextResponse.json({
|
||||
subscriptions: filtered,
|
||||
user: user ? { id: user.id, username: user.username, email: user.email } : null,
|
||||
});
|
||||
}
|
||||
|
||||
// 无 user_id 时列出所有订阅
|
||||
const result = await listSubscriptions({
|
||||
group_id: groupId ? Number(groupId) : undefined,
|
||||
status: status || undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
page_size: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
subscriptions: filtered,
|
||||
user: user ? { id: user.id, username: user.username, email: user.email } : null,
|
||||
subscriptions: result.subscriptions,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
page_size: result.page_size,
|
||||
user: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to query subscriptions:', error);
|
||||
|
||||
@@ -57,6 +57,8 @@ export async function GET(request: NextRequest) {
|
||||
platform: group?.platform ?? null,
|
||||
rateMultiplier: group?.rate_multiplier ?? null,
|
||||
limits: groupInfo,
|
||||
allowMessagesDispatch: group?.allow_messages_dispatch ?? false,
|
||||
defaultMappedModel: group?.default_mapped_model ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUserByToken, getUserSubscriptions } from '@/lib/sub2api/client';
|
||||
import { getCurrentUserByToken, getUserSubscriptions, getAllGroups } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||
@@ -16,8 +16,23 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const subscriptions = await getUserSubscriptions(userId);
|
||||
return NextResponse.json({ subscriptions });
|
||||
const [subscriptions, groups] = await Promise.all([
|
||||
getUserSubscriptions(userId),
|
||||
getAllGroups().catch(() => []),
|
||||
]);
|
||||
|
||||
const groupMap = new Map(groups.map((g) => [g.id, g]));
|
||||
|
||||
const enriched = subscriptions.map((sub) => {
|
||||
const group = groupMap.get(sub.group_id);
|
||||
return {
|
||||
...sub,
|
||||
group_name: group?.name ?? null,
|
||||
platform: group?.platform ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ subscriptions: enriched });
|
||||
} catch (error) {
|
||||
console.error('Failed to get user subscriptions:', error);
|
||||
return NextResponse.json({ error: '获取订阅信息失败' }, { status: 500 });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
import { PlatformBadge } from '@/lib/platform-style';
|
||||
|
||||
export interface ChannelInfo {
|
||||
id: string;
|
||||
@@ -23,39 +24,7 @@ interface ChannelCardProps {
|
||||
userBalance?: number;
|
||||
}
|
||||
|
||||
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) {
|
||||
const key = platform.toLowerCase();
|
||||
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 }: ChannelCardProps) {
|
||||
const platformStyle = getPlatformStyle(channel.platform);
|
||||
const usableQuota = (1 / channel.rateMultiplier).toFixed(2);
|
||||
|
||||
return (
|
||||
@@ -68,9 +37,7 @@ export default function ChannelCard({ channel, onTopUp, isDark, locale }: Channe
|
||||
{/* Header: Platform badge + Name */}
|
||||
<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>
|
||||
<PlatformBadge platform={channel.platform} />
|
||||
<h3 className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{channel.name}
|
||||
</h3>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
import { formatValidityLabel, formatValiditySuffix, type ValidityUnit } from '@/lib/subscription-utils';
|
||||
import { PlatformBadge } from '@/lib/platform-style';
|
||||
|
||||
export interface PlanInfo {
|
||||
id: string;
|
||||
@@ -23,6 +24,8 @@ export interface PlanInfo {
|
||||
weekly_limit_usd: number | null;
|
||||
monthly_limit_usd: number | null;
|
||||
} | null;
|
||||
allowMessagesDispatch: boolean;
|
||||
defaultMappedModel: string | null;
|
||||
}
|
||||
|
||||
interface SubscriptionPlanCardProps {
|
||||
@@ -37,94 +40,161 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
||||
const periodLabel = formatValidityLabel(plan.validityDays, unit, locale);
|
||||
const periodSuffix = formatValiditySuffix(plan.validityDays, unit, locale);
|
||||
|
||||
const hasLimits = plan.limits && (
|
||||
plan.limits.daily_limit_usd !== null ||
|
||||
plan.limits.weekly_limit_usd !== null ||
|
||||
plan.limits.monthly_limit_usd !== null
|
||||
);
|
||||
|
||||
const isOpenAI = plan.platform?.toLowerCase() === 'openai';
|
||||
|
||||
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(' ')}
|
||||
>
|
||||
{/* 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}
|
||||
{/* Header: Platform badge + Name + Period */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{plan.platform && <PlatformBadge platform={plan.platform} />}
|
||||
<h3 className={['text-lg font-bold', 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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Price */}
|
||||
<div className="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>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{plan.description && (
|
||||
<p className={['mb-3 text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
<p className={['mb-4 text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Platform & Rate */}
|
||||
{(plan.platform || plan.rateMultiplier != null) && (
|
||||
<div className={['mb-3 flex flex-wrap gap-2 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{plan.platform && (
|
||||
<span className={['inline-flex items-center gap-1 rounded-md px-2 py-0.5', isDark ? 'bg-slate-700/60' : 'bg-slate-100'].join(' ')}>
|
||||
{pickLocaleText(locale, '平台', 'Platform')}: {plan.platform}
|
||||
</span>
|
||||
)}
|
||||
{/* Rate + Limits grid */}
|
||||
{(plan.rateMultiplier != null || hasLimits) && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||
{plan.rateMultiplier != null && (
|
||||
<span className={['inline-flex items-center gap-1 rounded-md px-2 py-0.5', isDark ? 'bg-slate-700/60' : 'bg-slate-100'].join(' ')}>
|
||||
{pickLocaleText(locale, '倍率', 'Rate')}: {plan.rateMultiplier}x
|
||||
</span>
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '倍率', 'Rate')}
|
||||
</span>
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-lg font-bold text-emerald-500">1</span>
|
||||
<span className={['mx-1 text-base', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>:</span>
|
||||
<span className="text-lg font-bold text-emerald-500">{plan.rateMultiplier}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan.limits?.daily_limit_usd !== null && plan.limits?.daily_limit_usd !== undefined && (
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '日限额', 'Daily Limit')}
|
||||
</span>
|
||||
<div className={['text-lg font-semibold', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
${plan.limits.daily_limit_usd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan.limits?.weekly_limit_usd !== null && plan.limits?.weekly_limit_usd !== undefined && (
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '周限额', 'Weekly Limit')}
|
||||
</span>
|
||||
<div className={['text-lg font-semibold', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
${plan.limits.weekly_limit_usd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan.limits?.monthly_limit_usd !== null && plan.limits?.monthly_limit_usd !== undefined && (
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '月限额', 'Monthly Limit')}
|
||||
</span>
|
||||
<div className={['text-lg font-semibold', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
${plan.limits.monthly_limit_usd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenAI specific: messages dispatch + default model */}
|
||||
{isOpenAI && (
|
||||
<div className={[
|
||||
'mb-4 rounded-lg border p-3',
|
||||
isDark ? 'border-green-500/20 bg-green-500/5' : 'border-green-500/20 bg-green-50/50',
|
||||
].join(' ')}>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
{pickLocaleText(locale, '/v1/messages 调度', '/v1/messages Dispatch')}
|
||||
</span>
|
||||
<span className={[
|
||||
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
plan.allowMessagesDispatch
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDark ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-500',
|
||||
].join(' ')}>
|
||||
{plan.allowMessagesDispatch
|
||||
? pickLocaleText(locale, '已启用', 'Enabled')
|
||||
: pickLocaleText(locale, '未启用', 'Disabled')}
|
||||
</span>
|
||||
</div>
|
||||
{plan.defaultMappedModel && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
{pickLocaleText(locale, '默认模型', 'Default Model')}
|
||||
</span>
|
||||
<span className={['text-xs font-mono', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
|
||||
{plan.defaultMappedModel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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')}
|
||||
<div className="mb-5">
|
||||
<p className={['mb-2 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '功能特性', 'Features')}
|
||||
</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 className="flex flex-wrap gap-1.5">
|
||||
{plan.features.map((feature) => (
|
||||
<span
|
||||
key={feature}
|
||||
className={[
|
||||
'rounded-md px-2 py-1 text-xs',
|
||||
isDark ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -136,8 +206,11 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale
|
||||
<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"
|
||||
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, '立即开通', 'Subscribe Now')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
import { PlatformBadge } from '@/lib/platform-style';
|
||||
|
||||
export interface UserSub {
|
||||
id: number;
|
||||
@@ -13,6 +14,8 @@ export interface UserSub {
|
||||
daily_usage_usd: number;
|
||||
weekly_usage_usd: number;
|
||||
monthly_usage_usd: number;
|
||||
group_name: string | null;
|
||||
platform: string | null;
|
||||
}
|
||||
|
||||
interface UserSubscriptionsProps {
|
||||
@@ -82,8 +85,9 @@ export default function UserSubscriptions({ subscriptions, onRenew, isDark, loca
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{sub.platform && <PlatformBadge platform={sub.platform} />}
|
||||
<span className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{pickLocaleText(locale, `渠道 #${sub.group_id}`, `Channel #${sub.group_id}`)}
|
||||
{sub.group_name || pickLocaleText(locale, `#${sub.group_id}`, `#${sub.group_id}`)}
|
||||
</span>
|
||||
<span className={['rounded-full px-2 py-0.5 text-xs font-medium', badge.className].join(' ')}>
|
||||
{badge.text}
|
||||
|
||||
126
src/lib/platform-style.ts
Normal file
126
src/lib/platform-style.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface PlatformStyleEntry {
|
||||
badge: string;
|
||||
border: string;
|
||||
label: string;
|
||||
/** SVG path data (viewBox 0 0 24 24) */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const PLATFORM_STYLES: Record<string, PlatformStyleEntry> = {
|
||||
claude: {
|
||||
badge: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30',
|
||||
border: 'border-orange-500/20',
|
||||
label: 'Claude',
|
||||
icon: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
|
||||
},
|
||||
anthropic: {
|
||||
badge: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30',
|
||||
border: 'border-orange-500/20',
|
||||
label: 'Anthropic',
|
||||
icon: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
|
||||
},
|
||||
openai: {
|
||||
badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
|
||||
border: 'border-green-500/20',
|
||||
label: 'OpenAI',
|
||||
icon: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
|
||||
},
|
||||
codex: {
|
||||
badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
|
||||
border: 'border-green-500/20',
|
||||
label: 'Codex',
|
||||
icon: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
|
||||
},
|
||||
gemini: {
|
||||
badge: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
||||
border: 'border-blue-500/20',
|
||||
label: 'Gemini',
|
||||
icon: 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81',
|
||||
},
|
||||
google: {
|
||||
badge: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
||||
border: 'border-blue-500/20',
|
||||
label: 'Google',
|
||||
icon: 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81',
|
||||
},
|
||||
sora: {
|
||||
badge: 'bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/30',
|
||||
border: 'border-pink-500/20',
|
||||
label: 'Sora',
|
||||
// four-pointed sparkle star
|
||||
icon: 'M12 2l2.09 6.26L20.18 10l-6.09 1.74L12 18l-2.09-6.26L3.82 10l6.09-1.74L12 2z',
|
||||
},
|
||||
antigravity: {
|
||||
badge: 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||
border: 'border-purple-500/20',
|
||||
label: 'Antigravity',
|
||||
// stylised angular "A" cursor shape
|
||||
icon: 'M12 2L4 22h4l2-5h4l2 5h4L12 2zm0 7l2.5 6h-5L12 9z',
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_STYLE: PlatformStyleEntry = {
|
||||
badge: 'bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/30',
|
||||
border: 'border-slate-500/20',
|
||||
label: '',
|
||||
icon: '',
|
||||
};
|
||||
|
||||
export function getPlatformStyle(platform: string): PlatformStyleEntry {
|
||||
const key = platform.toLowerCase();
|
||||
const entry = PLATFORM_STYLES[key];
|
||||
if (entry) return entry;
|
||||
return { ...FALLBACK_STYLE, label: platform };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG icon for a platform (16×16 by default).
|
||||
* Returns null when the platform has no known icon.
|
||||
*/
|
||||
export function PlatformIcon({
|
||||
platform,
|
||||
className = 'h-4 w-4',
|
||||
}: {
|
||||
platform: string;
|
||||
className?: string;
|
||||
}): React.ReactElement | null {
|
||||
const style = getPlatformStyle(platform);
|
||||
if (!style.icon) return null;
|
||||
return React.createElement(
|
||||
'svg',
|
||||
{
|
||||
className,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor',
|
||||
'aria-hidden': true,
|
||||
},
|
||||
React.createElement('path', { d: style.icon }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a coloured badge with icon + label for a platform.
|
||||
*/
|
||||
export function PlatformBadge({
|
||||
platform,
|
||||
className = '',
|
||||
}: {
|
||||
platform: string;
|
||||
className?: string;
|
||||
}): React.ReactElement {
|
||||
const style = getPlatformStyle(platform);
|
||||
return React.createElement(
|
||||
'span',
|
||||
{
|
||||
className: [
|
||||
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
style.badge,
|
||||
className,
|
||||
].join(' '),
|
||||
},
|
||||
PlatformIcon({ platform, className: 'h-3.5 w-3.5' }),
|
||||
style.label,
|
||||
);
|
||||
}
|
||||
@@ -247,6 +247,39 @@ export async function searchUsers(keyword: string): Promise<{ id: number; email:
|
||||
return (data.data ?? []) as { id: number; email: string; username: string; notes?: string }[];
|
||||
}
|
||||
|
||||
export async function listSubscriptions(params?: {
|
||||
user_id?: number;
|
||||
group_id?: number;
|
||||
status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<{ subscriptions: Sub2ApiSubscription[]; total: number; page: number; page_size: number }> {
|
||||
const env = getEnv();
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.user_id != null) qs.set('user_id', String(params.user_id));
|
||||
if (params?.group_id != null) qs.set('group_id', String(params.group_id));
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.page != null) qs.set('page', String(params.page));
|
||||
if (params?.page_size != null) qs.set('page_size', String(params.page_size));
|
||||
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/subscriptions?${qs}`, {
|
||||
headers: getHeaders(),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list subscriptions: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
subscriptions: (data.data ?? []) as Sub2ApiSubscription[],
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? 1,
|
||||
page_size: data.page_size ?? 50,
|
||||
};
|
||||
}
|
||||
|
||||
export async function addBalance(userId: number, amount: number, notes: string, idempotencyKey: string): Promise<void> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface Sub2ApiGroup {
|
||||
default_validity_days: number;
|
||||
sort_order: number;
|
||||
supported_model_scopes: string[] | null;
|
||||
allow_messages_dispatch?: boolean;
|
||||
default_mapped_model?: string;
|
||||
}
|
||||
|
||||
// ── 订阅 ──
|
||||
|
||||
Reference in New Issue
Block a user