feat: complete Rust backend UI compatibility

This commit is contained in:
boos
2026-05-18 16:52:54 +08:00
parent f2434e725d
commit f986fe0377
41 changed files with 3585 additions and 811 deletions
+1 -1
View File
@@ -416,7 +416,7 @@ export const sidebarGroups = [
const topNavItems = [
{ label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' },
{ label: 'ComfyUI', to: '/video-agent' },
{ label: '视频工作台', to: '/video-agent' },
{ label: '视频助手', to: '/comfyui-agent' },
{ label: '关于', to: '/about' },
];
+264 -35
View File
@@ -243,6 +243,184 @@ async function request<T = any>(
return res.json() as Promise<T>;
}
function unwrapArrayResponse(value: any, keys: string[] = ["items", "data"]): any[] {
if (Array.isArray(value)) return value;
for (const key of keys) {
if (Array.isArray(value?.[key])) return value[key];
}
return [];
}
function wrapItemsResponse(value: any): any {
if (Array.isArray(value)) return { success: true, items: value };
if (Array.isArray(value?.items)) return value;
if (Array.isArray(value?.data)) return { ...value, success: value.success ?? true, items: value.data };
return { ...(value || {}), success: value?.success ?? true, items: [] };
}
function parseJsonArrayField(value: any): any[] {
if (Array.isArray(value)) return value;
if (typeof value !== "string" || !value.trim()) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
}
function normalizeRouteRow(row: any): any {
if (!row || typeof row !== "object") return row;
return {
...row,
modelPattern: row.modelPattern ?? row.model_pattern ?? "",
modelMapping: row.modelMapping ?? row.model_mapping ?? null,
displayName: row.displayName ?? row.display_name ?? null,
displayIcon: row.displayIcon ?? row.display_icon ?? null,
routingStrategy: row.routingStrategy ?? row.routing_strategy ?? "weighted",
routeMode: row.routeMode ?? row.route_mode ?? "pattern",
updatedAt: row.updatedAt ?? row.updated_at ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
};
}
function normalizeDownstreamKeyRow(row: any): any {
if (!row || typeof row !== "object") return row;
return {
...row,
keyMasked: row.keyMasked ?? row.key_masked ?? (row.key ? `${String(row.key).slice(0, 8)}...` : ""),
groupName: row.groupName ?? row.group_name ?? null,
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
maxCost: row.maxCost ?? row.max_cost ?? null,
maxRequests: row.maxRequests ?? row.max_requests ?? null,
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
allowedRouteIds: (row.allowedRouteIds ?? parseJsonArrayField(row.allowed_route_ids))
.map((id: any) => Number(id))
.filter((id: number) => Number.isFinite(id)),
expiresAt: row.expiresAt ?? row.expires_at ?? null,
lastUsedAt: row.lastUsedAt ?? row.last_used_at ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
updatedAt: row.updatedAt ?? row.updated_at ?? null,
};
}
function normalizeProxyLogSummary(summary: any): ProxyLogsSummary {
return {
totalCount: Number(summary?.totalCount ?? summary?.total ?? 0),
successCount: Number(summary?.successCount ?? summary?.success ?? 0),
failedCount: Number(summary?.failedCount ?? summary?.failed ?? 0),
totalCost: Number(summary?.totalCost ?? summary?.cost ?? 0),
totalTokensAll: Number(summary?.totalTokensAll ?? summary?.totalTokens ?? 0),
};
}
function normalizeProxyLogRow(row: any): any {
if (!row || typeof row !== "object") return row;
return {
...row,
routeId: row.routeId ?? row.route_id ?? null,
channelId: row.channelId ?? row.channel_id ?? null,
accountId: row.accountId ?? row.account_id ?? null,
downstreamApiKeyId:
row.downstreamApiKeyId ?? row.downstream_api_key_id ?? null,
modelRequested: row.modelRequested ?? row.model_requested ?? "",
modelActual: row.modelActual ?? row.model_actual ?? null,
httpStatus: row.httpStatus ?? row.http_status ?? null,
latencyMs: row.latencyMs ?? row.latency_ms ?? null,
firstByteLatencyMs:
row.firstByteLatencyMs ?? row.first_byte_latency_ms ?? null,
promptTokens: row.promptTokens ?? row.prompt_tokens ?? null,
completionTokens: row.completionTokens ?? row.completion_tokens ?? null,
totalTokens: row.totalTokens ?? row.total_tokens ?? null,
estimatedCost: Number(row.estimatedCost ?? row.estimated_cost ?? 0),
errorMessage: row.errorMessage ?? row.error_message ?? null,
retryCount: row.retryCount ?? row.retry_count ?? 0,
isStream: row.isStream ?? row.is_stream ?? null,
clientFamily: row.clientFamily ?? row.client_family ?? null,
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
createdAt: row.createdAt ?? row.created_at ?? null,
};
}
function normalizeProxyLogsResponse(response: any): ProxyLogsResponse {
const items = unwrapArrayResponse(response, ["items", "data"]).map(
normalizeProxyLogRow,
);
return {
...(response || {}),
items,
total: Number(response?.total ?? items.length),
page: Number(response?.page ?? 1),
pageSize: Number(response?.pageSize ?? response?.page_size ?? items.length),
clientOptions: Array.isArray(response?.clientOptions)
? response.clientOptions
: [],
summary: normalizeProxyLogSummary(response?.summary),
};
}
function normalizeProxyDebugTraceRow(row: any): ProxyDebugTraceListItem {
return {
...row,
id: Number(row?.id ?? 0),
createdAt: row?.createdAt ?? row?.created_at ?? "",
downstreamPath: row?.downstreamPath ?? row?.downstream_path ?? "",
clientKind: row?.clientKind ?? row?.client_kind ?? null,
sessionId: row?.sessionId ?? row?.session_id ?? null,
requestedModel: row?.requestedModel ?? row?.requested_model ?? null,
selectedChannelId:
row?.selectedChannelId ?? row?.selected_channel_id ?? null,
finalStatus: row?.finalStatus ?? row?.final_status ?? null,
finalHttpStatus: row?.finalHttpStatus ?? row?.final_http_status ?? null,
finalUpstreamPath: row?.finalUpstreamPath ?? row?.final_upstream_path ?? null,
};
}
function normalizeProxyDebugTraceDetail(response: any): ProxyDebugTraceDetail {
const trace = response?.trace ?? response?.data?.trace ?? response ?? {};
const attempts = Array.isArray(response?.attempts)
? response.attempts
: Array.isArray(response?.data?.attempts)
? response.data.attempts
: [];
return {
trace: {
...trace,
id: Number(trace?.id ?? response?.id ?? 0),
createdAt: trace?.createdAt ?? trace?.created_at ?? null,
updatedAt: trace?.updatedAt ?? trace?.updated_at ?? null,
downstreamPath: trace?.downstreamPath ?? trace?.downstream_path ?? null,
clientKind: trace?.clientKind ?? trace?.client_kind ?? null,
sessionId: trace?.sessionId ?? trace?.session_id ?? null,
traceHint: trace?.traceHint ?? trace?.trace_hint ?? null,
requestedModel: trace?.requestedModel ?? trace?.requested_model ?? null,
stickySessionKey:
trace?.stickySessionKey ?? trace?.sticky_session_key ?? null,
stickyHitChannelId:
trace?.stickyHitChannelId ?? trace?.sticky_hit_channel_id ?? null,
selectedChannelId:
trace?.selectedChannelId ?? trace?.selected_channel_id ?? null,
selectedRouteId: trace?.selectedRouteId ?? trace?.selected_route_id ?? null,
selectedAccountId:
trace?.selectedAccountId ?? trace?.selected_account_id ?? null,
selectedSiteId: trace?.selectedSiteId ?? trace?.selected_site_id ?? null,
selectedSitePlatform:
trace?.selectedSitePlatform ?? trace?.selected_site_platform ?? null,
finalStatus: trace?.finalStatus ?? trace?.final_status ?? null,
finalHttpStatus: trace?.finalHttpStatus ?? trace?.final_http_status ?? null,
finalUpstreamPath:
trace?.finalUpstreamPath ?? trace?.final_upstream_path ?? null,
},
attempts,
};
}
async function streamSse(
url: string,
handlers: {
@@ -840,11 +1018,16 @@ export type DownstreamApiKeyTrendResponse = {
export const api = {
// Sites
getSites: () => request("/api/sites"),
getSites: async () => {
const result = await request<any>("/api/sites");
return Array.isArray(result?.data) ? result.data : result;
},
addSite: (data: any) =>
request("/api/sites", { method: "POST", body: JSON.stringify(data) }),
request("/api/sites", { method: "POST", body: JSON.stringify(data) })
.then((result: any) => result?.data ?? result),
updateSite: (id: number, data: any) =>
request(`/api/sites/${id}`, { method: "PUT", body: JSON.stringify(data) }),
request(`/api/sites/${id}`, { method: "PUT", body: JSON.stringify(data) })
.then((result: any) => result?.data ?? result),
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: "DELETE" }),
batchUpdateSites: (data: any) =>
request("/api/sites/batch", { method: "POST", body: JSON.stringify(data) }),
@@ -985,8 +1168,10 @@ export const api = {
triggerCheckinAll: () => request("/api/checkin/trigger", { method: "POST" }),
triggerCheckin: (id: number) =>
request(`/api/checkin/trigger/${id}`, { method: "POST" }),
getCheckinLogs: (params?: string) =>
request(`/api/checkin/logs${params ? "?" + params : ""}`),
getCheckinLogs: async (params?: string) =>
unwrapArrayResponse(
await request(`/api/checkin/logs${params ? "?" + params : ""}`),
),
updateCheckinSchedule: (cron: string) =>
request("/api/checkin/schedule", {
method: "PUT",
@@ -994,11 +1179,28 @@ export const api = {
}),
// Routes
getRoutes: () => request("/api/routes"),
getRoutesLite: () => request("/api/routes/lite"),
getRoutesSummary: () => request("/api/routes/summary"),
getRoutes: async () =>
unwrapArrayResponse(await request("/api/routes"), [
"routes",
"items",
"data",
]).map(normalizeRouteRow),
getRoutesLite: async () =>
unwrapArrayResponse(await request("/api/routes/lite"), [
"routes",
"items",
"data",
]).map(normalizeRouteRow),
getRoutesSummary: async () =>
unwrapArrayResponse(await request("/api/routes/summary"), [
"routes",
"items",
"data",
]).map(normalizeRouteRow),
getRouteChannels: (routeId: number) =>
request(`/api/routes/${routeId}/channels`),
request(`/api/routes/${routeId}/channels`).then((value) =>
unwrapArrayResponse(value, ["channels", "items", "data"]),
),
batchAddChannels: (
routeId: number,
channels: Array<{
@@ -1113,22 +1315,24 @@ export const api = {
...(options?.refresh ? { refresh: 1 } : {}),
})}`,
),
getProxyLogs: (params?: ProxyLogsQuery) =>
request(
`/api/stats/proxy-logs${buildQueryString(params)}`,
) as Promise<ProxyLogsResponse>,
getProxyLogsQuery: (params?: ProxyLogsQuery) =>
request(
`/api/stats/proxy-logs${buildQueryString({
...params,
view: "query",
})}`,
) as Promise<{
getProxyLogs: async (params?: ProxyLogsQuery) =>
normalizeProxyLogsResponse(
await request(`/api/stats/proxy-logs${buildQueryString(params)}`),
),
getProxyLogsQuery: async (params?: ProxyLogsQuery) =>
normalizeProxyLogsResponse(
await request(
`/api/stats/proxy-logs${buildQueryString({
...params,
view: "query",
})}`,
),
) as {
items: ProxyLogsResponse["items"];
total: number;
page: number;
pageSize: number;
}>,
},
getProxyLogsMeta: (
params?: Omit<ProxyLogsQuery, "limit" | "offset"> & {
refresh?: number | boolean;
@@ -1146,8 +1350,14 @@ export const api = {
...(refresh !== undefined ? { refresh } : {}),
} as Record<string, string | number | boolean | null | undefined>;
if (refresh === undefined) delete queryParams.refresh;
return request(
`/api/stats/proxy-logs${buildQueryString(queryParams)}`,
return request(`/api/stats/proxy-logs${buildQueryString(queryParams)}`).then(
(response: any) => ({
clientOptions: Array.isArray(response?.clientOptions)
? response.clientOptions
: [],
summary: normalizeProxyLogSummary(response?.summary),
sites: Array.isArray(response?.sites) ? response.sites : [],
}),
) as Promise<{
clientOptions: ProxyLogsResponse["clientOptions"];
summary: ProxyLogsResponse["summary"];
@@ -1155,14 +1365,20 @@ export const api = {
}>;
},
getProxyLogDetail: (id: number) =>
request(`/api/stats/proxy-logs/${id}`) as Promise<ProxyLogDetail>,
request(`/api/stats/proxy-logs/${id}`).then((response: any) =>
normalizeProxyLogRow(response?.item ?? response?.data ?? response),
) as Promise<ProxyLogDetail>,
getProxyDebugTraces: (params?: { limit?: number }) =>
request(
`/api/stats/proxy-debug/traces${buildQueryString(params)}`,
request(`/api/stats/proxy-debug/traces${buildQueryString(params)}`).then(
(response: any) => ({
items: unwrapArrayResponse(response, ["items", "data"]).map(
normalizeProxyDebugTraceRow,
),
}),
) as Promise<ProxyDebugTracesResponse>,
getProxyDebugTraceDetail: (id: number) =>
request(
`/api/stats/proxy-debug/traces/${id}`,
request(`/api/stats/proxy-debug/traces/${id}`).then(
normalizeProxyDebugTraceDetail,
) as Promise<ProxyDebugTraceDetail>,
checkModels: (accountId: number) =>
request(`/api/models/check/${accountId}`, { method: "POST" }),
@@ -1281,8 +1497,10 @@ export const api = {
}) as Promise<{ success: true }>,
// Events
getEvents: (params?: string) =>
request(`/api/events${params ? "?" + params : ""}`),
getEvents: async (params?: string) =>
unwrapArrayResponse(
await request(`/api/events${params ? "?" + params : ""}`),
),
getEventCount: () => request("/api/events/count"),
markEventRead: (id: number) =>
request(`/api/events/${id}/read`, { method: "POST" }),
@@ -1394,7 +1612,10 @@ export const api = {
body: JSON.stringify(data),
timeoutMs: 120_000,
}),
getDownstreamApiKeys: () => request("/api/downstream-keys"),
getDownstreamApiKeys: async () => {
const response = wrapItemsResponse(await request("/api/downstream-keys"));
return { ...response, items: response.items.map(normalizeDownstreamKeyRow) };
},
createDownstreamApiKey: (data: any) =>
request("/api/downstream-keys", {
method: "POST",
@@ -1425,11 +1646,16 @@ export const api = {
request(`/api/downstream-keys/${id}/reset-usage`, {
method: "POST",
}),
getDownstreamApiKeysSummary: (params?: {
getDownstreamApiKeysSummary: async (params?: {
range?: "24h" | "7d" | "all";
status?: "all" | "enabled" | "disabled";
search?: string;
}) => request(`/api/downstream-keys/summary${buildQueryString(params)}`),
}) => {
const response = wrapItemsResponse(
await request(`/api/downstream-keys/summary${buildQueryString(params)}`),
);
return { ...response, items: response.items.map(normalizeDownstreamKeyRow) };
},
getDownstreamApiKeyOverview: (id: number) =>
request(`/api/downstream-keys/${id}/overview`),
getDownstreamApiKeyTrend: (
@@ -1586,9 +1812,12 @@ export const api = {
},
// Admin user management
getUsers: () => request("/api/admin/users"),
getUsers: (params?: URLSearchParams) =>
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
getUserById: (id: number) => request(`/api/admin/users/${id}`),
updateUser: (id: number, data: { role?: string; status?: string; username?: string }) =>
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteUser: (id: number) =>
request(`/api/admin/users/${id}`, { method: "DELETE" }),
+268
View File
@@ -5479,6 +5479,274 @@ textarea {
color: var(--color-text-muted);
}
/* ===== User Management ===== */
.user-management-page {
min-width: 0;
}
.user-management-alert {
margin-bottom: 12px;
}
.user-management-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.user-stat-card {
min-height: 82px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
background: var(--color-bg-card);
box-shadow: var(--shadow-card);
padding: 14px 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.user-stat-label {
font-size: 12px;
color: var(--color-text-muted);
}
.user-stat-value {
font-size: 24px;
line-height: 1;
font-weight: 700;
color: var(--color-text-primary);
}
.user-stat-primary .user-stat-value {
color: var(--color-primary);
}
.user-stat-success .user-stat-value {
color: var(--color-success);
}
.user-stat-info .user-stat-value {
color: var(--color-info);
}
.user-stat-danger .user-stat-value {
color: var(--color-danger);
}
.user-management-toolbar-card {
padding: 14px;
margin-bottom: 14px;
}
.user-management-toolbar {
margin-bottom: 0;
}
.user-management-search {
min-width: 260px;
}
.user-management-select {
height: 36px;
min-width: 120px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-primary);
padding: 0 10px;
font-size: 13px;
outline: none;
}
.user-management-select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.user-management-batchbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border-light);
font-size: 13px;
color: var(--color-text-secondary);
}
.user-management-table-card {
overflow: hidden;
}
.user-management-table-wrap {
overflow-x: auto;
}
.user-management-table {
min-width: 900px;
}
.user-management-check-col {
width: 42px;
text-align: center !important;
}
.user-management-actions-col {
width: 188px;
text-align: right !important;
}
.user-management-row-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.user-identity-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-bg-card));
color: var(--color-primary);
font-size: 13px;
font-weight: 700;
}
.user-identity-text {
min-width: 0;
}
.user-name-line {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.user-name {
color: var(--color-text-primary);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.user-id {
color: var(--color-text-muted);
font-size: 12px;
}
.user-email {
margin-top: 2px;
color: var(--color-text-muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
.user-management-pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-top: 1px solid var(--color-border-light);
color: var(--color-text-muted);
font-size: 13px;
flex-wrap: wrap;
}
.user-management-page-controls {
display: inline-flex;
gap: 8px;
}
.user-management-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.user-management-form label {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
font-size: 12px;
color: var(--color-text-muted);
}
.user-management-form input,
.user-management-form select {
width: 100%;
height: 38px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-primary);
padding: 0 10px;
font-size: 13px;
outline: none;
}
.user-management-form input:focus,
.user-management-form select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
@media (max-width: 900px) {
.user-management-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-management-search {
min-width: 100%;
}
}
@media (max-width: 640px) {
.user-management-stats {
grid-template-columns: 1fr;
}
.user-management-select {
width: 100%;
}
.user-management-form {
grid-template-columns: 1fr;
}
.user-management-pagination {
align-items: stretch;
}
.user-management-page-controls {
width: 100%;
}
.user-management-page-controls .btn {
flex: 1;
}
}
.route-batch-bar {
position: sticky;
top: calc(var(--topbar-height) + 8px);
File diff suppressed because it is too large Load Diff
+487 -157
View File
@@ -1,204 +1,534 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
type UserRole = 'admin' | 'user';
type UserStatus = 'active' | 'disabled';
interface User {
id: number;
username: string;
email: string;
role: string;
status: string;
createdAt: string;
role: UserRole;
status: UserStatus;
createdAt?: string;
created_at?: string;
updatedAt?: string;
updated_at?: string;
}
interface UserStats {
total: number;
active: number;
disabled: number;
admins: number;
}
interface UserListMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
type UserFormMode = 'create' | 'edit';
interface UserFormState {
username: string;
email: string;
password: string;
role: UserRole;
status: UserStatus;
}
const emptyForm: UserFormState = {
username: '',
email: '',
password: '',
role: 'user',
status: 'active',
};
function normalizeUsers(result: any): User[] {
const rawUsers = Array.isArray(result?.data)
? result.data
: Array.isArray(result?.users)
? result.users
: [];
return rawUsers.map((user: any) => ({
id: Number(user.id),
username: String(user.username || ''),
email: String(user.email || ''),
role: user.role === 'admin' ? 'admin' : 'user',
status: user.status === 'disabled' ? 'disabled' : 'active',
createdAt: user.createdAt ?? user.created_at,
created_at: user.created_at,
updatedAt: user.updatedAt ?? user.updated_at,
updated_at: user.updated_at,
}));
}
function normalizeStats(result: any, users: User[]): UserStats {
const stats = result?.stats;
if (stats) {
return {
total: Number(stats.total || 0),
active: Number(stats.active || 0),
disabled: Number(stats.disabled || 0),
admins: Number(stats.admins || 0),
};
}
return {
total: users.length,
active: users.filter((user) => user.status === 'active').length,
disabled: users.filter((user) => user.status === 'disabled').length,
admins: users.filter((user) => user.role === 'admin').length,
};
}
function normalizeMeta(result: any, users: User[], page: number, pageSize: number): UserListMeta {
const meta = result?.meta || {};
const total = Number(meta.total ?? users.length);
return {
page: Number(meta.page || page),
pageSize: Number(meta.pageSize || pageSize),
total,
totalPages: Math.max(1, Number(meta.totalPages || Math.ceil(total / pageSize) || 1)),
};
}
function formatDate(value?: string): string {
if (!value) return '-';
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function roleLabel(role: UserRole): string {
return role === 'admin' ? '管理员' : '普通用户';
}
function statusLabel(status: UserStatus): string {
return status === 'active' ? '正常' : '已禁用';
}
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [stats, setStats] = useState<UserStats>({ total: 0, active: 0, disabled: 0, admins: 0 });
const [meta, setMeta] = useState<UserListMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 1 });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [editingUser, setEditingUser] = useState<number | null>(null);
const [editRole, setEditRole] = useState('');
const [editStatus, setEditStatus] = useState('');
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [formMode, setFormMode] = useState<UserFormMode>('create');
const [formOpen, setFormOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form, setForm] = useState<UserFormState>(emptyForm);
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
const loadUsers = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await api.getUsers();
setUsers(Array.isArray(result.users) ? result.users : []);
const params = new URLSearchParams();
if (search.trim()) params.set('q', search.trim());
if (roleFilter) params.set('role', roleFilter);
if (statusFilter) params.set('status', statusFilter);
params.set('page', String(page));
params.set('page_size', String(pageSize));
const result = await api.getUsers(params);
const nextUsers = normalizeUsers(result);
setUsers(nextUsers);
setStats(normalizeStats(result, nextUsers));
setMeta(normalizeMeta(result, nextUsers, page, pageSize));
setSelectedIds((ids) => ids.filter((id) => nextUsers.some((user) => user.id === id)));
} catch (err: any) {
setError(err?.message || '加载用户列表失败');
} finally {
setLoading(false);
}
}, []);
}, [page, pageSize, roleFilter, search, statusFilter]);
useEffect(() => { loadUsers(); }, [loadUsers]);
useEffect(() => {
const handle = window.setTimeout(() => {
loadUsers();
}, 180);
return () => window.clearTimeout(handle);
}, [loadUsers]);
const handleUpdateUser = async (id: number) => {
const selectedUsers = useMemo(
() => users.filter((user) => selectedIds.includes(user.id)),
[selectedIds, users],
);
const allVisibleSelected = users.length > 0 && users.every((user) => selectedIds.includes(user.id));
const openCreateModal = () => {
setFormMode('create');
setEditingUser(null);
setForm(emptyForm);
setFormOpen(true);
};
const openEditModal = (user: User) => {
setFormMode('edit');
setEditingUser(user);
setForm({
username: user.username,
email: user.email,
password: '',
role: user.role,
status: user.status,
});
setFormOpen(true);
};
const updateSelection = (id: number, checked: boolean) => {
setSelectedIds((ids) => (
checked ? Array.from(new Set([...ids, id])) : ids.filter((item) => item !== id)
));
};
const toggleVisibleSelection = (checked: boolean) => {
if (checked) {
setSelectedIds((ids) => Array.from(new Set([...ids, ...users.map((user) => user.id)])));
return;
}
setSelectedIds((ids) => ids.filter((id) => !users.some((user) => user.id === id)));
};
const handleSubmit = async () => {
const username = form.username.trim();
const email = form.email.trim();
const password = form.password.trim();
if (!username || !email) {
setError('用户名和邮箱不能为空');
return;
}
if (formMode === 'create' && password.length < 6) {
setError('新用户密码至少 6 位');
return;
}
setSaving(true);
setError('');
try {
await api.updateUser(id, { role: editRole, status: editStatus });
setEditingUser(null);
if (formMode === 'create') {
await api.createUser({
username,
email,
password,
role: form.role,
});
} else if (editingUser) {
await api.updateUser(editingUser.id, {
username,
email,
role: form.role,
status: form.status,
...(password ? { password } : {}),
});
}
setFormOpen(false);
await loadUsers();
} catch (err: any) {
setError(err?.message || '更新失败');
setError(err?.message || '保存用户失败');
} finally {
setSaving(false);
}
};
const handleDisableUser = async (id: number) => {
const updateUserStatus = async (user: User, status: UserStatus) => {
setSaving(true);
setError('');
try {
await api.deleteUser(id);
await api.updateUser(user.id, { status });
await loadUsers();
} catch (err: any) {
setError(err?.message || '操作失败');
setError(err?.message || '更新用户状态失败');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="animate-fade-in" style={{ padding: 16 }}>
<div className="skeleton" style={{ width: 220, height: 24, marginBottom: 16 }} />
<div className="skeleton" style={{ width: '100%', height: 120, borderRadius: 12 }} />
</div>
);
}
const updateSelectedStatus = async (status: UserStatus) => {
if (selectedUsers.length === 0) return;
setSaving(true);
setError('');
try {
await Promise.all(selectedUsers.map((user) => api.updateUser(user.id, { status })));
setSelectedIds([]);
await loadUsers();
} catch (err: any) {
setError(err?.message || '批量更新失败');
} finally {
setSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setSaving(true);
setError('');
try {
const targets = deleteTarget === 'selected' ? selectedUsers : [deleteTarget];
await Promise.all(targets.map((user) => api.deleteUser(user.id)));
setDeleteTarget(null);
setSelectedIds([]);
await loadUsers();
} catch (err: any) {
setError(err?.message || '删除用户失败');
} finally {
setSaving(false);
}
};
const statCards = [
{ label: '总用户', value: stats.total, tone: 'primary' },
{ label: '正常用户', value: stats.active, tone: 'success' },
{ label: '管理员', value: stats.admins, tone: 'info' },
{ label: '已禁用', value: stats.disabled, tone: 'danger' },
];
return (
<div className="animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h1 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>用户管理</h1>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
共 {users.length} 个用户
</span>
<div className="page-container animate-fade-in user-management-page">
<div className="page-header">
<div>
<h2 className="page-title">用户管理</h2>
<div className="page-subtitle">维护平台用户、角色与登录状态。禁用不会删除用户数据,可随时恢复。</div>
</div>
<div className="page-actions">
<button type="button" className="btn btn-ghost" onClick={loadUsers} disabled={loading || saving}>
刷新
</button>
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
新建用户
</button>
</div>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
{error ? <div className="alert alert-error user-management-alert">{error}</div> : null}
{/* Stats cards */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
{[
{ label: '总用户', value: users.length, color: '#818cf8' },
{ label: '管理员', value: users.filter((u) => u.role === 'admin').length, color: '#4ade80' },
{ label: '已禁用', value: users.filter((u) => u.status === 'disabled').length, color: '#f87171' },
].map((stat) => (
<div key={stat.label} style={{
flex: 1, padding: '14px 16px', borderRadius: 10,
background: 'var(--color-surface, #1a1a2e)',
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{stat.label}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: stat.color }}>{stat.value}</div>
<div className="user-management-stats">
{statCards.map((stat) => (
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
<div className="user-stat-label">{stat.label}</div>
<div className="user-stat-value">{loading ? '-' : stat.value}</div>
</div>
))}
</div>
{/* Users table */}
<div style={{
background: 'var(--color-surface, #1a1a2e)',
borderRadius: 12, border: '1px solid var(--color-border, #2a2a3e)',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>ID</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>用户名</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>邮箱</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>角色</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>状态</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>注册时间</th>
<th style={{ padding: '12px 16px', textAlign: 'right', fontWeight: 600, color: 'var(--color-text-muted)' }}>操作</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={7} style={{ padding: 24, textAlign: 'center', color: 'var(--color-text-muted)' }}>
暂无用户
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: 12 }}>{user.id}</td>
<td style={{ padding: '12px 16px' }}>{user.username}</td>
<td style={{ padding: '12px 16px', color: 'var(--color-text-secondary)' }}>{user.email}</td>
<td style={{ padding: '12px 16px' }}>
{editingUser === user.id ? (
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
style={{
padding: '4px 8px', borderRadius: 4, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
) : (
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: user.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
color: user.role === 'admin' ? '#818cf8' : '#4ade80',
}}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
)}
</td>
<td style={{ padding: '12px 16px' }}>
{editingUser === user.id ? (
<select
value={editStatus}
onChange={(e) => setEditStatus(e.target.value)}
style={{
padding: '4px 8px', borderRadius: 4, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
>
<option value="active">active</option>
<option value="disabled">disabled</option>
</select>
) : (
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: user.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: user.status === 'active' ? '#4ade80' : '#f87171',
}}>
{user.status === 'active' ? '正常' : '禁用'}
</span>
)}
</td>
<td style={{ padding: '12px 16px', fontSize: 12, color: 'var(--color-text-muted)' }}>
{user.createdAt?.slice(0, 10) || '-'}
</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
{editingUser === user.id ? (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => handleUpdateUser(user.id)} className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }}>保存</button>
<button onClick={() => setEditingUser(null)} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 12px' }}>取消</button>
</div>
) : (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button
onClick={() => { setEditingUser(user.id); setEditRole(user.role); setEditStatus(user.status); }}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 12px' }}
>
编辑
</button>
{user.status !== 'disabled' && (
<button onClick={() => handleDisableUser(user.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '4px 12px' }}>
禁用
</button>
)}
</div>
)}
</td>
</tr>
))
)}
</tbody>
</table>
<div className="card user-management-toolbar-card">
<div className="toolbar user-management-toolbar">
<div className="toolbar-search user-management-search">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="m21 21-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z" />
</svg>
<input
value={search}
onChange={(event) => { setSearch(event.target.value); setPage(1); }}
placeholder="搜索 ID / 用户名 / 邮箱"
/>
</div>
<select className="user-management-select" value={roleFilter} onChange={(event) => { setRoleFilter(event.target.value); setPage(1); }}>
<option value="">全部角色</option>
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
<select className="user-management-select" value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }}>
<option value="">全部状态</option>
<option value="active">正常</option>
<option value="disabled">已禁用</option>
</select>
<select className="user-management-select" value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
<option value={25}>25 / 页</option>
<option value={50}>50 / 页</option>
<option value={100}>100 / 页</option>
</select>
</div>
{selectedUsers.length > 0 ? (
<div className="user-management-batchbar">
<span>已选 {selectedUsers.length} 个用户</span>
<button type="button" className="btn btn-ghost" onClick={() => updateSelectedStatus('active')} disabled={saving}>批量启用</button>
<button type="button" className="btn btn-ghost" onClick={() => updateSelectedStatus('disabled')} disabled={saving}>批量禁用</button>
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteTarget('selected')} disabled={saving}>批量删除</button>
</div>
) : null}
</div>
<div className="card user-management-table-card">
{loading ? (
<div className="empty-state">
<div className="empty-state-title">加载中...</div>
<div className="empty-state-desc">正在读取用户列表。</div>
</div>
) : users.length === 0 ? (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M18 18.72a9.1 9.1 0 0 0 3.74-1.22 5.25 5.25 0 0 0-7.49-3.82M15 11.25a3.75 3.75 0 1 0-7.5 0 3.75 3.75 0 0 0 7.5 0ZM4.5 20.1a7.5 7.5 0 0 1 15 0" />
</svg>
<div className="empty-state-title">没有匹配的用户</div>
<div className="empty-state-desc">调整筛选条件,或使用右上角新建用户。</div>
</div>
) : (
<div className="user-management-table-wrap">
<table className="data-table user-management-table">
<thead>
<tr>
<th className="user-management-check-col">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={(event) => toggleVisibleSelection(event.target.checked)}
aria-label="选择当前页用户"
/>
</th>
<th>用户</th>
<th>角色</th>
<th>状态</th>
<th>注册时间</th>
<th>最近更新</th>
<th className="user-management-actions-col">操作</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className={selectedIds.includes(user.id) ? 'row-selected' : ''}>
<td className="user-management-check-col">
<input
type="checkbox"
checked={selectedIds.includes(user.id)}
onChange={(event) => updateSelection(user.id, event.target.checked)}
aria-label={`选择 ${user.username}`}
/>
</td>
<td>
<div className="user-identity-cell">
<div className="user-avatar">{user.username.slice(0, 1).toUpperCase() || 'U'}</div>
<div className="user-identity-text">
<div className="user-name-line">
<span className="user-name">{user.username}</span>
<span className="user-id">#{user.id}</span>
</div>
<div className="user-email">{user.email}</div>
</div>
</div>
</td>
<td>
<span className={`badge ${user.role === 'admin' ? 'badge-info' : 'badge-muted'}`}>
{roleLabel(user.role)}
</span>
</td>
<td>
<span className={`badge ${user.status === 'active' ? 'badge-success' : 'badge-error'}`}>
{statusLabel(user.status)}
</span>
</td>
<td>{formatDate(user.createdAt || user.created_at)}</td>
<td>{formatDate(user.updatedAt || user.updated_at)}</td>
<td className="user-management-actions-col">
<div className="user-management-row-actions">
<button type="button" className="btn btn-link btn-link-primary" onClick={() => openEditModal(user)}>编辑</button>
{user.status === 'active' ? (
<button type="button" className="btn btn-link btn-link-warning" onClick={() => updateUserStatus(user, 'disabled')} disabled={saving}>禁用</button>
) : (
<button type="button" className="btn btn-link btn-link-info" onClick={() => updateUserStatus(user, 'active')} disabled={saving}>启用</button>
)}
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteTarget(user)} disabled={saving}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="user-management-pagination">
<div>共 {meta.total} 个用户,第 {meta.page} / {meta.totalPages} 页</div>
<div className="user-management-page-controls">
<button type="button" className="btn btn-ghost" disabled={page <= 1 || loading} onClick={() => setPage((value) => Math.max(1, value - 1))}>
上一页
</button>
<button type="button" className="btn btn-ghost" disabled={page >= meta.totalPages || loading} onClick={() => setPage((value) => value + 1)}>
下一页
</button>
</div>
</div>
</div>
<CenteredModal
open={formOpen}
onClose={() => setFormOpen(false)}
title={formMode === 'create' ? '新建用户' : `编辑用户 #${editingUser?.id ?? ''}`}
maxWidth={620}
closeOnEscape
footer={(
<>
<button type="button" className="btn btn-ghost" onClick={() => setFormOpen(false)} disabled={saving}>取消</button>
<button type="button" className="btn btn-primary" onClick={handleSubmit} disabled={saving}>
{saving ? '保存中...' : '保存'}
</button>
</>
)}
>
<div className="user-management-form">
<label>
<span>用户名</span>
<input value={form.username} onChange={(event) => setForm((value) => ({ ...value, username: event.target.value }))} />
</label>
<label>
<span>邮箱</span>
<input value={form.email} onChange={(event) => setForm((value) => ({ ...value, email: event.target.value }))} />
</label>
<label>
<span>{formMode === 'create' ? '密码' : '重置密码'}</span>
<input
type="password"
value={form.password}
onChange={(event) => setForm((value) => ({ ...value, password: event.target.value }))}
placeholder={formMode === 'create' ? '至少 6 位' : '留空则不修改'}
/>
</label>
<label>
<span>角色</span>
<select value={form.role} onChange={(event) => setForm((value) => ({ ...value, role: event.target.value as UserRole }))}>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</label>
<label>
<span>状态</span>
<select value={form.status} onChange={(event) => setForm((value) => ({ ...value, status: event.target.value as UserStatus }))} disabled={formMode === 'create'}>
<option value="active">正常</option>
<option value="disabled">已禁用</option>
</select>
</label>
</div>
</CenteredModal>
<DeleteConfirmModal
open={deleteTarget !== null}
title="确认删除用户"
confirmText="确认删除"
loading={saving}
onClose={() => setDeleteTarget(null)}
onConfirm={confirmDelete}
description={deleteTarget === 'selected'
? `将永久删除选中的 ${selectedUsers.length} 个用户。`
: `将永久删除用户 ${deleteTarget?.username || ''}。`}
/>
</div>
);
}