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 });
|
||||
|
||||
Reference in New Issue
Block a user