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:
+24
-1
@@ -290,13 +290,36 @@ export function createAdminApp(services) {
|
|||||||
role: typeof req.query.role === 'string' ? req.query.role.trim() : '',
|
role: typeof req.query.role === 'string' ? req.query.role.trim() : '',
|
||||||
status: typeof req.query.status === 'string' ? req.query.status.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);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
adminApi.get('/users/:userId', requireAdmin, async (req, res) => {
|
adminApi.get('/users/:userId', requireAdmin, async (req, res) => {
|
||||||
const user = await userAuth.getUserPublic(req.params.userId);
|
const user = await userAuth.getUserPublic(req.params.userId);
|
||||||
if (!user) return res.status(404).json({ message: '用户不存在' });
|
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) => {
|
adminApi.get('/summary', requireAdmin, async (_req, res) => {
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
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 { CapabilitySettings } from '../../components/CapabilitySettings';
|
||||||
import { PolicySettings } from '../../components/PolicySettings';
|
import { PolicySettings } from '../../components/PolicySettings';
|
||||||
import { SkillSettings } from '../../components/SkillSettings';
|
import { SkillSettings } from '../../components/SkillSettings';
|
||||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||||
import { formatYuan } from '../utils/format';
|
import { formatTime, formatYuan } from '../utils/format';
|
||||||
import type { AdminTemplateCatalogItem, ImageQuotaView, PortalUser } from '../../types';
|
import {
|
||||||
|
formatTokenCount,
|
||||||
|
formatTokenQuota,
|
||||||
|
resolvePlanLabel,
|
||||||
|
subscriptionStatusLabel,
|
||||||
|
} from '../utils/subscription';
|
||||||
|
import type { AdminSubscription, AdminTemplateCatalogItem, ImageQuotaView, PlanDefinition, PortalUser } from '../../types';
|
||||||
|
|
||||||
function formatBytes(bytes: number) {
|
function formatBytes(bytes: number) {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -18,6 +33,8 @@ export function UserDetailPage() {
|
|||||||
const { userId = '' } = useParams();
|
const { userId = '' } = useParams();
|
||||||
const { users, reload, setError } = useAdminUsers();
|
const { users, reload, setError } = useAdminUsers();
|
||||||
const [user, setUser] = useState<PortalUser | null>(null);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setLocalError] = useState<string | null>(null);
|
const [error, setLocalError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
@@ -42,9 +59,10 @@ export function UserDetailPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
void getAdminUser(userId)
|
void getAdminUser(userId)
|
||||||
.then((nextUser) => {
|
.then(({ user: nextUser, subscription: nextSubscription }) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setUser(nextUser);
|
setUser(nextUser);
|
||||||
|
setSubscription(nextSubscription);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -64,6 +82,20 @@ export function UserDetailPage() {
|
|||||||
};
|
};
|
||||||
}, [userId, users]);
|
}, [userId, users]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void listSubscriptionPlans()
|
||||||
|
.then((nextPlans) => {
|
||||||
|
if (!cancelled) setPlans(nextPlans);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setPlans([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role !== 'user') {
|
if (!user || user.role !== 'user') {
|
||||||
setImageQuota(null);
|
setImageQuota(null);
|
||||||
@@ -173,6 +205,17 @@ export function UserDetailPage() {
|
|||||||
: `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used})`
|
: `剩余 ${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) => {
|
const handleTemplateGrant = async (skillName: string) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
@@ -296,6 +339,23 @@ export function UserDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<dt>工作目录</dt>
|
<dt>工作目录</dt>
|
||||||
<dd className="mono">{user.workspaceRoot}</dd>
|
<dd className="mono">{user.workspaceRoot}</dd>
|
||||||
@@ -305,6 +365,45 @@ export function UserDetailPage() {
|
|||||||
|
|
||||||
{user.role === 'user' && (
|
{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">
|
<section className="admin-card">
|
||||||
<h2>充值</h2>
|
<h2>充值</h2>
|
||||||
<form className="admin-form" onSubmit={handleRecharge}>
|
<form className="admin-form" onSubmit={handleRecharge}>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { PagedResult } from '../../api/client';
|
||||||
import type { AdminUserRow } from '../../types';
|
import type { AdminUserRow, PlanDefinition } from '../../types';
|
||||||
import { Pagination } from '../../components/Pagination';
|
import { Pagination } from '../../components/Pagination';
|
||||||
import { formatYuan } from '../utils/format';
|
import { formatYuan } from '../utils/format';
|
||||||
|
import { resolvePlanLabel, summarizeSubscription } from '../utils/subscription';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export function UsersPage() {
|
|||||||
const [pending, setPending] = useState({ search: '', role: '', status: '' });
|
const [pending, setPending] = useState({ search: '', role: '', status: '' });
|
||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [plans, setPlans] = useState<PlanDefinition[]>([]);
|
||||||
const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '', workspaceRoot: '', balanceCents: 500 });
|
const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '', workspaceRoot: '', balanceCents: 500 });
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
@@ -46,6 +48,12 @@ export function UsersPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void listSubscriptionPlans()
|
||||||
|
.then(setPlans)
|
||||||
|
.catch(() => setPlans([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleQuery = () => {
|
const handleQuery = () => {
|
||||||
setCommitted(pending);
|
setCommitted(pending);
|
||||||
void load(1, pending);
|
void load(1, pending);
|
||||||
@@ -171,6 +179,8 @@ export function UsersPage() {
|
|||||||
<th>用户</th>
|
<th>用户</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
|
<th>套餐</th>
|
||||||
|
<th>Token 额度</th>
|
||||||
<th>余额</th>
|
<th>余额</th>
|
||||||
<th>空间</th>
|
<th>空间</th>
|
||||||
<th>工作目录</th>
|
<th>工作目录</th>
|
||||||
@@ -190,6 +200,10 @@ export function UsersPage() {
|
|||||||
{user.status === 'active' ? '正常' : user.status === 'disabled' ? '禁用' : '封禁'}
|
{user.status === 'active' ? '正常' : user.status === 'disabled' ? '禁用' : '封禁'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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">¥{formatYuan(user.balanceCents)}</td>
|
||||||
<td className="billing-num">
|
<td className="billing-num">
|
||||||
{user.spaceQuotaBytes
|
{user.spaceQuotaBytes
|
||||||
|
|||||||
@@ -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
@@ -877,9 +877,11 @@ export async function listAdminUsers(params?: {
|
|||||||
return { items: result.users ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
|
return { items: result.users ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminUser(userId: string): Promise<PortalUser> {
|
export async function getAdminUser(userId: string): Promise<{ user: PortalUser; subscription: AdminSubscription | null }> {
|
||||||
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`);
|
const result = await portalFetch<{ user: PortalUser; subscription?: AdminSubscription | null }>(
|
||||||
return result.user;
|
`/admin-api/users/${userId}`,
|
||||||
|
);
|
||||||
|
return { user: result.user, subscription: result.subscription ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAdminUser(payload: {
|
export async function createAdminUser(payload: {
|
||||||
|
|||||||
@@ -44,8 +44,17 @@ export type AuthStatus = {
|
|||||||
unrestricted?: boolean;
|
unrestricted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserSubscriptionSummary = {
|
||||||
|
planType: string;
|
||||||
|
status: 'active' | 'expired' | 'cancelled';
|
||||||
|
periodTokensLimit: number;
|
||||||
|
periodTokensUsed: number;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminUserRow = PortalUser & {
|
export type AdminUserRow = PortalUser & {
|
||||||
billingFormula?: 'A' | 'B';
|
billingFormula?: 'A' | 'B';
|
||||||
|
subscription?: UserSubscriptionSummary | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user