From e07686f29d51fe026623e594def9225972797f5d Mon Sep 17 00:00:00 2001 From: john Date: Mon, 7 Sep 2026 16:22:08 +0800 Subject: [PATCH] feat(admin): show user subscription plan and token quota in user pages Expose active subscription summary on user list and detail so operators can see plan type, total/used/remaining tokens without opening billing subscriptions. Co-authored-by: Cursor --- server/app.mjs | 25 ++++++- src/admin/pages/UserDetailPage.tsx | 107 +++++++++++++++++++++++++++-- src/admin/pages/UsersPage.tsx | 18 ++++- src/admin/utils/subscription.ts | 36 ++++++++++ src/api/client.ts | 8 ++- src/types.ts | 9 +++ 6 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 src/admin/utils/subscription.ts diff --git a/server/app.mjs b/server/app.mjs index efb482c..81a3244 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -290,13 +290,36 @@ export function createAdminApp(services) { role: typeof req.query.role === 'string' ? req.query.role.trim() : '', status: typeof req.query.status === 'string' ? req.query.status.trim() : '', }); + if (subscriptionService?.getActiveSubscription && Array.isArray(result.users) && result.users.length) { + result.users = await Promise.all( + result.users.map(async (user) => { + if (user.role !== 'user') return user; + const sub = await subscriptionService.getActiveSubscription(user.id); + if (!sub) return { ...user, subscription: null }; + return { + ...user, + subscription: { + planType: sub.planType, + status: sub.status, + periodTokensLimit: sub.periodTokensLimit, + periodTokensUsed: sub.periodTokensUsed, + expiresAt: sub.expiresAt, + }, + }; + }), + ); + } res.json(result); }); adminApi.get('/users/:userId', requireAdmin, async (req, res) => { const user = await userAuth.getUserPublic(req.params.userId); if (!user) return res.status(404).json({ message: '用户不存在' }); - res.json({ user }); + let subscription = null; + if (subscriptionService?.getActiveSubscription && user.role === 'user') { + subscription = await subscriptionService.getActiveSubscription(req.params.userId); + } + res.json({ user, subscription }); }); adminApi.get('/summary', requireAdmin, async (_req, res) => { diff --git a/src/admin/pages/UserDetailPage.tsx b/src/admin/pages/UserDetailPage.tsx index 609b828..1712701 100644 --- a/src/admin/pages/UserDetailPage.tsx +++ b/src/admin/pages/UserDetailPage.tsx @@ -1,12 +1,27 @@ import { useEffect, useState } from 'react'; import { Link, Navigate, useParams } from 'react-router-dom'; -import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota, fetchAdminTemplateCatalog, grantUserPageTemplate } from '../../api/client'; +import { + getAdminUser, + listSubscriptionPlans, + rechargeUser, + updateAdminUser, + fetchUserImageQuota, + setUserImageQuota, + fetchAdminTemplateCatalog, + grantUserPageTemplate, +} from '../../api/client'; import { CapabilitySettings } from '../../components/CapabilitySettings'; import { PolicySettings } from '../../components/PolicySettings'; import { SkillSettings } from '../../components/SkillSettings'; import { useAdminUsers } from '../hooks/useAdminUsers'; -import { formatYuan } from '../utils/format'; -import type { AdminTemplateCatalogItem, ImageQuotaView, PortalUser } from '../../types'; +import { formatTime, formatYuan } from '../utils/format'; +import { + formatTokenCount, + formatTokenQuota, + resolvePlanLabel, + subscriptionStatusLabel, +} from '../utils/subscription'; +import type { AdminSubscription, AdminTemplateCatalogItem, ImageQuotaView, PlanDefinition, PortalUser } from '../../types'; function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; @@ -18,6 +33,8 @@ export function UserDetailPage() { const { userId = '' } = useParams(); const { users, reload, setError } = useAdminUsers(); const [user, setUser] = useState(null); + const [subscription, setSubscription] = useState(null); + const [plans, setPlans] = useState([]); const [loading, setLoading] = useState(true); const [error, setLocalError] = useState(null); const [message, setMessage] = useState(null); @@ -42,9 +59,10 @@ export function UserDetailPage() { setLoading(true); setLocalError(null); void getAdminUser(userId) - .then((nextUser) => { + .then(({ user: nextUser, subscription: nextSubscription }) => { if (cancelled) return; setUser(nextUser); + setSubscription(nextSubscription); }) .catch((err) => { if (cancelled) return; @@ -64,6 +82,20 @@ export function UserDetailPage() { }; }, [userId, users]); + useEffect(() => { + let cancelled = false; + void listSubscriptionPlans() + .then((nextPlans) => { + if (!cancelled) setPlans(nextPlans); + }) + .catch(() => { + if (!cancelled) setPlans([]); + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (!user || user.role !== 'user') { setImageQuota(null); @@ -173,6 +205,17 @@ export function UserDetailPage() { : `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used})` : ''; + const activePlanType = subscription?.planType ?? user?.planType ?? 'free'; + const planLabel = resolvePlanLabel(activePlanType, plans); + const tokenLimit = subscription?.periodTokensLimit ?? 0; + const tokenUsed = subscription?.periodTokensUsed ?? 0; + const tokenRemaining = tokenLimit === 0 ? null : Math.max(0, tokenLimit - tokenUsed); + const tokenQuotaSummary = subscription + ? formatTokenQuota(tokenLimit, tokenUsed) + : activePlanType !== 'free' + ? '无有效订阅记录' + : '免费用户,无套餐额度'; + const handleTemplateGrant = async (skillName: string) => { if (!user) return; setMessage(null); @@ -296,6 +339,23 @@ export function UserDetailPage() { +
+
当前套餐
+
+ {planLabel} + {subscription && ( + + {' '} + · {subscriptionStatusLabel(subscription.status)} + {subscription.expiresAt ? ` · 到期 ${formatTime(subscription.expiresAt)}` : ''} + + )} +
+
+
+
Token 额度
+
{tokenQuotaSummary}
+
工作目录
{user.workspaceRoot}
@@ -305,6 +365,45 @@ export function UserDetailPage() { {user.role === 'user' && ( <> +
+

套餐与 Token 额度

+
+
+
套餐
+
+ {planLabel} + ({activePlanType}) +
+
+
+
订阅状态
+
{subscription ? subscriptionStatusLabel(subscription.status) : '无有效订阅'}
+
+
+
到期时间
+
{subscription?.expiresAt ? formatTime(subscription.expiresAt) : '—'}
+
+
+
Token 总量
+
{tokenLimit === 0 ? '无限' : formatTokenCount(tokenLimit)}
+
+
+
Token 已用
+
{formatTokenCount(tokenUsed)}
+
+
+
Token 剩余
+
{tokenRemaining == null ? '无限' : formatTokenCount(tokenRemaining)}
+
+
+

+ 授予或取消套餐请前往 + {' '} + 计费 · 订阅记录 + 。 +

+
+

充值

diff --git a/src/admin/pages/UsersPage.tsx b/src/admin/pages/UsersPage.tsx index 7c8de3d..948be9c 100644 --- a/src/admin/pages/UsersPage.tsx +++ b/src/admin/pages/UsersPage.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { createAdminUser, listAdminUsers, updateAdminUser } from '../../api/client'; +import { createAdminUser, listAdminUsers, listSubscriptionPlans, updateAdminUser } from '../../api/client'; import type { PagedResult } from '../../api/client'; -import type { AdminUserRow } from '../../types'; +import type { AdminUserRow, PlanDefinition } from '../../types'; import { Pagination } from '../../components/Pagination'; import { formatYuan } from '../utils/format'; +import { resolvePlanLabel, summarizeSubscription } from '../utils/subscription'; const PAGE_SIZE = 20; @@ -25,6 +26,7 @@ export function UsersPage() { const [pending, setPending] = useState({ search: '', role: '', status: '' }); const [showCreate, setShowCreate] = useState(false); + const [plans, setPlans] = useState([]); const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '', workspaceRoot: '', balanceCents: 500 }); const [creating, setCreating] = useState(false); @@ -46,6 +48,12 @@ export function UsersPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + void listSubscriptionPlans() + .then(setPlans) + .catch(() => setPlans([])); + }, []); + const handleQuery = () => { setCommitted(pending); void load(1, pending); @@ -171,6 +179,8 @@ export function UsersPage() { 用户 角色 状态 + 套餐 + Token 额度 余额 空间 工作目录 @@ -190,6 +200,10 @@ export function UsersPage() { {user.status === 'active' ? '正常' : user.status === 'disabled' ? '禁用' : '封禁'} + {resolvePlanLabel(user.subscription?.planType ?? user.planType ?? 'free', plans)} + + {summarizeSubscription(user.subscription, user.planType)} + ¥{formatYuan(user.balanceCents)} {user.spaceQuotaBytes diff --git a/src/admin/utils/subscription.ts b/src/admin/utils/subscription.ts new file mode 100644 index 0000000..ace97c0 --- /dev/null +++ b/src/admin/utils/subscription.ts @@ -0,0 +1,36 @@ +import type { AdminSubscription, PlanDefinition, UserSubscriptionSummary } from '../../types'; + +export function formatTokenCount(value: number) { + return value.toLocaleString('zh-CN'); +} + +export function formatTokenQuota(limit: number, used: number) { + if (limit === 0) { + return `已用 ${formatTokenCount(used)} / 无限`; + } + const remaining = Math.max(0, limit - used); + return `${formatTokenCount(remaining)} / ${formatTokenCount(limit)}(已用 ${formatTokenCount(used)})`; +} + +export function resolvePlanLabel(planType: string, plans: PlanDefinition[]) { + return plans.find((plan) => plan.planType === planType)?.name ?? planType; +} + +export function subscriptionStatusLabel(status: AdminSubscription['status']) { + if (status === 'active') return '有效'; + if (status === 'expired') return '已到期'; + if (status === 'cancelled') return '已取消'; + return status; +} + +export function summarizeSubscription( + subscription: Pick | null | undefined, + fallbackPlanType?: string, +) { + if (!subscription) { + return fallbackPlanType && fallbackPlanType !== 'free' + ? `${fallbackPlanType}(无有效订阅)` + : '免费 / 无订阅'; + } + return formatTokenQuota(subscription.periodTokensLimit, subscription.periodTokensUsed); +} diff --git a/src/api/client.ts b/src/api/client.ts index 8195aaf..28a0dfc 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -877,9 +877,11 @@ export async function listAdminUsers(params?: { return { items: result.users ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) }; } -export async function getAdminUser(userId: string): Promise { - const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`); - return result.user; +export async function getAdminUser(userId: string): Promise<{ user: PortalUser; subscription: AdminSubscription | null }> { + const result = await portalFetch<{ user: PortalUser; subscription?: AdminSubscription | null }>( + `/admin-api/users/${userId}`, + ); + return { user: result.user, subscription: result.subscription ?? null }; } export async function createAdminUser(payload: { diff --git a/src/types.ts b/src/types.ts index 0051c91..1f3ba71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,8 +44,17 @@ export type AuthStatus = { unrestricted?: boolean; }; +export type UserSubscriptionSummary = { + planType: string; + status: 'active' | 'expired' | 'cancelled'; + periodTokensLimit: number; + periodTokensUsed: number; + expiresAt: number; +}; + export type AdminUserRow = PortalUser & { billingFormula?: 'A' | 'B'; + subscription?: UserSubscriptionSummary | null; createdAt: number; updatedAt: number; };