feat: complete Rust backend UI compatibility
This commit is contained in:
+1
-1
@@ -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
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
+595
-367
File diff suppressed because it is too large
Load Diff
+487
-157
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user