Merge branch 'feature/user-subscription-token-display'

Show user subscription plan and token quota in admin user list and detail pages.
This commit is contained in:
john
2026-09-07 16:22:47 +08:00
6 changed files with 193 additions and 10 deletions
+24 -1
View File
@@ -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) => {
+103 -4
View File
@@ -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<PortalUser | null>(null);
const [subscription, setSubscription] = useState<AdminSubscription | null>(null);
const [plans, setPlans] = useState<PlanDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [error, setLocalError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(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() {
</select>
</dd>
</div>
<div>
<dt></dt>
<dd>
{planLabel}
{subscription && (
<span className="muted">
{' '}
· {subscriptionStatusLabel(subscription.status)}
{subscription.expiresAt ? ` · 到期 ${formatTime(subscription.expiresAt)}` : ''}
</span>
)}
</dd>
</div>
<div>
<dt>Token </dt>
<dd>{tokenQuotaSummary}</dd>
</div>
<div>
<dt></dt>
<dd className="mono">{user.workspaceRoot}</dd>
@@ -305,6 +365,45 @@ export function UserDetailPage() {
{user.role === 'user' && (
<>
<section className="admin-card">
<h2> Token </h2>
<dl className="admin-dl">
<div>
<dt></dt>
<dd>
{planLabel}
<span className="muted mono"> ({activePlanType})</span>
</dd>
</div>
<div>
<dt></dt>
<dd>{subscription ? subscriptionStatusLabel(subscription.status) : '无有效订阅'}</dd>
</div>
<div>
<dt></dt>
<dd>{subscription?.expiresAt ? formatTime(subscription.expiresAt) : '—'}</dd>
</div>
<div>
<dt>Token </dt>
<dd>{tokenLimit === 0 ? '无限' : formatTokenCount(tokenLimit)}</dd>
</div>
<div>
<dt>Token </dt>
<dd>{formatTokenCount(tokenUsed)}</dd>
</div>
<div>
<dt>Token </dt>
<dd>{tokenRemaining == null ? '无限' : formatTokenCount(tokenRemaining)}</dd>
</div>
</dl>
<p className="muted">
{' '}
<Link to="/billing/subscriptions"> · </Link>
</p>
</section>
<section className="admin-card">
<h2></h2>
<form className="admin-form" onSubmit={handleRecharge}>
+16 -2
View File
@@ -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<PlanDefinition[]>([]);
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() {
<th></th>
<th></th>
<th></th>
<th></th>
<th>Token </th>
<th></th>
<th></th>
<th></th>
@@ -190,6 +200,10 @@ export function UsersPage() {
{user.status === 'active' ? '正常' : user.status === 'disabled' ? '禁用' : '封禁'}
</span>
</td>
<td>{resolvePlanLabel(user.subscription?.planType ?? user.planType ?? 'free', plans)}</td>
<td className="billing-num">
{summarizeSubscription(user.subscription, user.planType)}
</td>
<td className="billing-num">¥{formatYuan(user.balanceCents)}</td>
<td className="billing-num">
{user.spaceQuotaBytes
+36
View File
@@ -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<UserSubscriptionSummary, 'planType' | 'periodTokensLimit' | 'periodTokensUsed'> | null | undefined,
fallbackPlanType?: string,
) {
if (!subscription) {
return fallbackPlanType && fallbackPlanType !== 'free'
? `${fallbackPlanType}(无有效订阅)`
: '免费 / 无订阅';
}
return formatTokenQuota(subscription.periodTokensLimit, subscription.periodTokensUsed);
}
+5 -3
View File
@@ -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<PortalUser> {
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: {
+9
View File
@@ -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;
};