refactor(admin): 管理后台 - 收敛重复边框并提升运维信息密度

This commit is contained in:
ddCat
2026-09-22 06:09:03 +08:00
parent 0b285f66e8
commit 505d6076cc
18 changed files with 489 additions and 167 deletions

View File

@@ -19,7 +19,7 @@ const AgentLessonsPanel = lazy(() => import("./components/agent-lessons-panel"))
export function AnalyticsPage() {
const { references } = useAdminContext();
return (
<AdminPageFrame title="数据概览" description="用户、任务、质量与成本健康度" scroll>
<AdminPageFrame title="运行总览" description="请求质量、任务队列与成本 · 趋势按自然日统计" scroll>
<AnalyticsPanel users={references.users} channels={references.channels} />
</AdminPageFrame>
);

View File

@@ -23,8 +23,8 @@
display: flex;
flex: none;
flex-direction: column;
gap: 10px;
padding: 16px;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--admin-divider);
}
@@ -40,8 +40,8 @@
display: flex;
width: 100%;
flex-direction: column;
gap: 10px;
padding: 12px;
gap: 7px;
padding: 9px 10px;
margin-bottom: 4px;
border: 0;
border-radius: var(--admin-radius);
@@ -136,7 +136,7 @@
.admin-channel-sidebar-footer {
flex: none;
padding: 12px 16px;
padding: 10px 12px;
border-top: 1px solid var(--admin-divider);
}
@@ -150,7 +150,7 @@
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 16px 20px;
padding: 12px 16px;
border-bottom: 1px solid var(--admin-divider);
}
@@ -176,7 +176,7 @@
min-width: 0;
min-height: 0;
flex-direction: column;
padding: 16px;
padding: 12px;
}
.admin-channel-model-toolbar {
@@ -185,8 +185,8 @@
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
gap: 8px;
margin-bottom: 8px;
}
.admin-channel-model-heading {
@@ -223,7 +223,7 @@
flex-direction: column;
flex: 1;
min-height: 0;
gap: 16px;
gap: 12px;
padding-block: 8px;
}
@@ -235,8 +235,8 @@
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 24px;
padding: 24px;
gap: 16px;
padding: 16px;
border-radius: var(--admin-radius);
background: var(--admin-surface-2);
}

View File

@@ -0,0 +1,42 @@
import { createContext, useContext, useState, type ReactNode } from "react";
export type AdminDensity = "compact" | "comfortable";
export function normalizeAdminDensity(value: string | null): AdminDensity {
return value === "comfortable" ? "comfortable" : "compact";
}
export function adminDensityStorageKey(userId: string) {
return `admin-console:${encodeURIComponent(userId)}:density`;
}
const AdminDensityContext = createContext<{ density: AdminDensity; toggleDensity: () => void }>({ density: "compact", toggleDensity: () => {} });
export function AdminDensityProvider({ userId, children }: { userId?: string; children: ReactNode }) {
const [density, setDensity] = useState<AdminDensity>(() => {
if (!userId || typeof window === "undefined") return "compact";
try {
return normalizeAdminDensity(window.localStorage.getItem(adminDensityStorageKey(userId)));
} catch (error) {
console.warn("无法读取后台密度偏好,使用紧凑布局", error);
return "compact";
}
});
const toggleDensity = () => {
const next = density === "compact" ? "comfortable" : "compact";
setDensity(next);
if (!userId) return;
try {
window.localStorage.setItem(adminDensityStorageKey(userId), next);
} catch (error) {
console.warn("后台密度已切换,但无法保存偏好", error);
}
};
return <AdminDensityContext.Provider value={{ density, toggleDensity }}>{children}</AdminDensityContext.Provider>;
}
export function useAdminDensity() {
return useContext(AdminDensityContext);
}

View File

@@ -27,6 +27,7 @@ import {
PlugZap,
RadioTower,
RefreshCw,
Rows3,
Sparkles,
Settings2,
ShieldAlert,
@@ -48,6 +49,7 @@ import { useUserStore } from "@/stores/use-user-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { getIsolatedAdminAntTheme } from "../theme/admin-ant-theme";
import { AdminTooltip } from "../ui/controls";
import { AdminDensityProvider, useAdminDensity } from "./admin-density";
import "@/styles/admin-ui.css";
import "../theme/admin-tokens.css";
import "../theme/admin-chrome.css";
@@ -62,40 +64,42 @@ type AdminNavigationItem = {
const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = [
{
label: "概览",
items: [{ path: "/admin", label: "数据概览", description: "活跃、调用与成本趋势", icon: <BarChart3 className="size-4" /> }],
label: "监控与排查",
items: [
{ path: "/admin", label: "运行总览", description: "请求质量、队列与成本趋势", icon: <BarChart3 className="size-4" /> },
{ path: "/admin/logs", label: "请求明细", description: "上游调用与费用", icon: <FileClock className="size-4" /> },
{ path: "/admin/settings/system-performance", label: "系统性能", description: "主机、数据库与缓存状态", icon: <Activity className="size-4" /> },
],
},
{
label: "平台资源",
label: "模型与资源",
items: [
{ path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: <UsersRound className="size-4" /> },
{ path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: <RadioTower className="size-4" /> },
{ path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: <Layers3 className="size-4" />, requireFeature: "frontendModelsEnabled" },
{ path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: <PlugZap className="size-4" /> },
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
{ path: "/admin/resources", label: "存储资源", description: "资源列表、容量与预览", icon: <Database className="size-4" /> },
],
},
{
label: "运营",
label: "用户与运营",
items: [
{ path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: <UsersRound className="size-4" /> },
{ path: "/admin/announcements", label: "系统公告", description: "发布、关闭与历史公告", icon: <BellRing className="size-4" /> },
{ path: "/admin/banner-announcements", label: "常驻通知", description: "首页顶部常驻滚动通知", icon: <Megaphone className="size-4" /> },
{ path: "/admin/agent-lessons", label: "Agent 记忆", description: "按用户查看个人记忆", icon: <Sparkles className="size-4" /> },
{ path: "/admin/payments", label: "支付充值", description: "支付渠道、订单与对账", icon: <CreditCard className="size-4" /> },
{ path: "/admin/credit-operations", label: "积分运营", description: "人工调账与异常计费", icon: <Coins className="size-4" /> },
{ path: "/admin/redemption-codes", label: "兑换码", description: "生成与查看兑换码批次", icon: <TicketCheck className="size-4" /> },
{ path: "/admin/logs", label: "请求明细", description: "上游调用与费用", icon: <FileClock className="size-4" /> },
],
},
{
label: "系统配置",
label: "平台配置",
items: [
{ path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: <PlugZap className="size-4" /> },
{ path: "/admin/settings/appearance", label: "站点及外观", description: "品牌、SEO、备案与皮肤", icon: <Palette className="size-4" /> },
{ path: "/admin/settings/features", label: "功能开放", description: "工作台、插件与模型能力", icon: <ToggleLeft className="size-4" /> },
{ path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: <Paintbrush className="size-4" /> },
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" /> },
{ path: "/admin/settings/system-performance", label: "系统性能", description: "主机、数据库与缓存状态", icon: <Activity className="size-4" /> },
{ path: "/admin/settings/access", label: "登录与注册", description: "账号创建与第三方登录", icon: <ShieldCheck className="size-4" /> },
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码与 SMTP", icon: <Mail className="size-4" /> },
{ path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: <HardDrive className="size-4" /> },
@@ -119,6 +123,12 @@ function adminPopupContainer(node?: HTMLElement) {
}
export function AdminShell() {
const userId = useUserStore((state) => state.user?.id);
return <AdminDensityProvider key={userId || "anonymous"} userId={userId}><AdminShellLayout /></AdminDensityProvider>;
}
function AdminShellLayout() {
const { density } = useAdminDensity();
const appearance = useAppearanceStore((state) => state.appearance);
const [collapsed, setCollapsed] = useState(readWorkspaceSidebarCollapsed);
const dark = useThemeStore((state) => state.theme === "dark");
@@ -141,7 +151,7 @@ export function AdminShell() {
return (
<ConfigProvider theme={getIsolatedAdminAntTheme(dark, appearance.activeSkin)} getPopupContainer={(node) => adminPopupContainer(node)}>
<App>
<main id="admin-root" data-admin-root className="admin-shell flex h-full min-h-0 overflow-hidden">
<main id="admin-root" data-admin-root data-admin-density={density} className="admin-shell flex h-full min-h-0 overflow-hidden">
<aside className={cn("admin-sidebar hidden shrink-0 flex-col overflow-hidden lg:flex", collapsed && "is-collapsed")}>
<div className="admin-sidebar-identity shrink-0">
<AdminTooltip title={collapsed ? "查看更新日志" : undefined} placement="right">
@@ -223,6 +233,7 @@ export function AdminPageFrame({ title, description, actions, back, scroll = fal
</div>
<div className="admin-page-actions flex shrink-0 flex-wrap items-center">
{actions}
<AdminDensityButton />
<AdminThemeButton />
</div>
</header>
@@ -232,6 +243,18 @@ export function AdminPageFrame({ title, description, actions, back, scroll = fal
);
}
function AdminDensityButton() {
const { density, toggleDensity } = useAdminDensity();
return (
<AdminTooltip title={density === "compact" ? "切换为舒适行距" : "切换为紧凑行距"}>
<button type="button" className="admin-density-toggle" onClick={toggleDensity} aria-label="紧凑表格行距" aria-pressed={density === "compact"}>
<Rows3 className="size-4" aria-hidden="true" />
{density === "compact" ? "紧凑" : "舒适"}
</button>
</AdminTooltip>
);
}
function AdminThemeButton() {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);

View File

@@ -1,7 +1,6 @@
import { Alert, App, Button, DatePicker, Select, Tabs, Tag } from "antd";
import { Tooltip } from "@/pages/admin/ui/controls";
import { useCountUp } from "@/hooks/use-count-up";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
@@ -40,6 +39,8 @@ export default function AnalyticsPanel({ users, channels }: Props) {
const [capability, setCapability] = useState(searchParams.get("capability") || undefined);
const [data, setData] = useState<AdminAnalytics | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState("");
const requestSequence = useRef(0);
const [userOptions, setUserOptions] = useState(users);
const [searchingUsers, setSearchingUsers] = useState(false);
const [modelPage, setModelPage] = useState(1);
@@ -62,14 +63,21 @@ export default function AnalyticsPanel({ users, channels }: Props) {
);
const reload = useCallback(async () => {
const sequence = ++requestSequence.current;
setLoading(true);
setLoadError("");
setData(null);
try {
const analytics = await getAdminAnalytics(filters);
if (sequence !== requestSequence.current) return;
setData(analytics);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取统计数据失败");
if (sequence !== requestSequence.current) return;
const text = error instanceof Error ? error.message : "读取统计数据失败";
setLoadError(text);
message.error(text);
} finally {
setLoading(false);
if (sequence === requestSequence.current) setLoading(false);
}
}, [filters, message]);
@@ -83,6 +91,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
else next.delete("rangePreset");
setSearchParams(next, { replace: true });
void reload();
return () => { requestSequence.current += 1; };
}, [filters, rangePreset]);
useEffect(() => {
@@ -194,7 +203,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
};
return (
<div className="admin-analytics-panel space-y-5">
<div className="admin-analytics-panel" aria-busy={loading}>
<ListToolbar
className="admin-analytics-toolbar"
active={Boolean(userId || model || channelId || capability)}
@@ -266,29 +275,43 @@ export default function AnalyticsPanel({ users, channels }: Props) {
</ListToolbar>
{financeUnavailable && <Alert type="warning" showIcon title="后端未返回完整财务统计,缺失金额显示为 --。请确认后端已更新并重启后刷新。" />}
{loadError && <Alert type="error" showIcon title="统计数据读取失败" description={loadError} action={<Button size="small" onClick={() => void reload()}>重试</Button>} />}
<section className="admin-analytics-health-grid" aria-label="运营健康指标">
<AnalyticsHealthCard
icon={<UsersRound className="size-4" />}
label="活跃用户"
value={data ? <AnimatedHealthValue target={data.kpi.activeUsers} format={formatNumber} /> : "--"}
value={data ? formatNumber(data.kpi.activeUsers) : "--"}
trend={formatCountDelta(currentTrend?.activeUsers, previousTrend?.activeUsers)}
detail={data ? `DAU ${formatNumber(data.kpi.dau)} · WAU ${formatNumber(data.kpi.wau)} · MAU ${formatNumber(data.kpi.mau)}` : undefined}
detail={data ? `日 ${formatNumber(data.kpi.dau)} · 周 ${formatNumber(data.kpi.wau)} · 月 ${formatNumber(data.kpi.mau)}` : undefined}
/>
<AnalyticsHealthCard
icon={<Workflow className="size-4" />}
label="生成任务"
value={data ? <AnimatedHealthValue target={data.kpi.generationTasks} format={formatNumber} /> : "--"}
value={data ? formatNumber(data.kpi.generationTasks) : "--"}
trend={formatCountDelta(currentTrend?.tasks, previousTrend?.tasks)}
detail={data ? `上游请求 ${formatNumber(data.kpi.upstreamRequests)} · 队列 ${formatNumber(data.kpi.currentQueuedTasks)}` : undefined}
detail={data ? `上游请求 ${formatNumber(data.kpi.upstreamRequests)}` : undefined}
/>
<AnalyticsHealthCard
icon={<Gauge className="size-4" />}
label="服务质量"
value={data ? <AnimatedHealthValue target={data.kpi.successRate} format={percent} /> : "--"}
value={data && data.kpi.upstreamRequests > 0 ? percent(data.kpi.successRate) : "--"}
trend={formatRateDelta(currentTrend?.requestSuccessRate, previousTrend?.requestSuccessRate)}
detail={data ? `P95 ${formatDuration(data.kpi.p95DurationMs)}` : undefined}
tone={data && data.kpi.successRate < 90 ? "warning" : "success"}
detail={data ? (data.kpi.upstreamRequests ? "真实上游请求成功率" : "当前范围暂无请求") : undefined}
tone={!data || !data.kpi.upstreamRequests ? "neutral" : data.kpi.successRate < 90 ? "warning" : "success"}
/>
<AnalyticsHealthCard
icon={<Clock3 className="size-4" />}
label="P95 请求耗时"
value={data && data.kpi.upstreamRequests > 0 ? formatDuration(data.kpi.p95DurationMs) : "--"}
detail="筛选范围内的上游请求"
/>
<AnalyticsHealthCard
icon={<Workflow className="size-4" />}
label="当前排队"
value={data ? formatNumber(data.kpi.currentQueuedTasks) : "--"}
detail="实时快照 · 非历史累计"
tone={data?.kpi.currentQueuedTasks ? "warning" : "neutral"}
/>
<AnalyticsHealthCard
icon={<CircleDollarSign className="size-4" />}
@@ -364,8 +387,8 @@ export default function AnalyticsPanel({ users, channels }: Props) {
<span>
<BarChart3 className="size-5" />
</span>
<div className="font-medium">当前范围暂无{trendMetric === "quality" ? "质量" : trendMetric === "activity" ? "活跃" : "使用"}数据</div>
<p>可以调整时间范围或筛选条件后重新查看。</p>
<div className="font-medium">{!data ? (loading ? "正在读取趋势…" : "趋势数据不可用") : `当前范围暂无${trendMetric === "quality" ? "质量" : trendMetric === "activity" ? "活跃" : "使用"}数据`}</div>
<p>{loadError ? "请重试,无法依据缺失数据判断运行状态。" : "按自然日统计,可调整时间范围或筛选条件。"}</p>
</div>
)}
</section>
@@ -376,23 +399,23 @@ export default function AnalyticsPanel({ users, channels }: Props) {
<h2 id="admin-analytics-attention-title">需要关注</h2>
<p>优先展示可能需要处理的运行状态。</p>
</div>
<AdminStatusBadge label={failureTotal > 0 ? `${formatNumber(failureTotal)} 次异常` : "运行平稳"} tone={failureTotal > 0 ? "warning" : "success"} />
<AdminStatusBadge label={!data ? "状态未知" : failureTotal > 0 ? `${formatNumber(failureTotal)} 次异常` : "未记录异常"} tone={!data ? "neutral" : failureTotal > 0 ? "warning" : "success"} />
</div>
<div className="admin-analytics-attention-list">
<AnalyticsAttentionItem
icon={<AlertTriangle className="size-4" />}
label="异常请求"
value={data ? formatNumber(failureTotal) : "--"}
description={topFailure ? `${topFailure.type} · ${topFailure.model}` : "当前范围未记录异常"}
tone={failureTotal > 0 ? "warning" : "success"}
description={!data ? "数据尚未就绪" : topFailure ? `${topFailure.type} · ${topFailure.model}` : "当前范围未记录异常"}
tone={!data ? "neutral" : failureTotal > 0 ? "warning" : "success"}
onClick={failureTotal > 0 ? () => openAnalysis("failures") : undefined}
/>
<AnalyticsAttentionItem
icon={<Clock3 className="size-4" />}
label="当前队列"
value={data ? formatNumber(data.kpi.currentQueuedTasks) : "--"}
description={data?.kpi.currentQueuedTasks ? "存在等待执行的生成任务" : "没有排队中的生成任务"}
tone={data?.kpi.currentQueuedTasks ? "warning" : "success"}
description={!data ? "数据尚未就绪" : data.kpi.currentQueuedTasks ? "存在等待执行的生成任务" : "没有排队中的生成任务"}
tone={!data ? "neutral" : data.kpi.currentQueuedTasks ? "warning" : "success"}
/>
<AnalyticsAttentionItem
icon={<CircleDollarSign className="size-4" />}
@@ -469,11 +492,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
);
}
function AnimatedHealthValue({ target, format }: { target: number; format: (value: number) => string }) {
const display = useCountUp(target, 420, 0);
return <>{format(display)}</>;
}
function AnalyticsHealthCard({ icon, label, value, trend, detail, tone = "neutral" }: { icon: ReactNode; label: string; value: ReactNode; trend?: { value: string; tone?: AdminStatusTone }; detail?: string; tone?: AdminStatusTone }) {
return (
<article className="admin-analytics-health-card" data-tone={tone}>

View File

@@ -0,0 +1,21 @@
import type { ApiCallLog } from "@/services/api/auth";
export type LogView = "troubleshoot" | "billing" | "all";
export function normalizeLogView(value: string | null): LogView {
return value === "billing" || value === "all" ? value : "troubleshoot";
}
export function logStatus(log: Pick<ApiCallLog, "status" | "providerStatus">) {
const providerStatus = log.providerStatus?.toLowerCase();
if (log.status === "failed" || ["failed", "cancelled", "canceled", "expired"].includes(providerStatus || "")) return { label: "失败", tone: "error" as const };
if (["queued", "pending", "processing", "running", "in_progress"].includes(providerStatus || "")) return { label: "处理中", tone: "warning" as const };
return { label: "成功", tone: "success" as const };
}
export function logBillingLabel(log: Pick<ApiCallLog, "billable" | "billingAvailable" | "billingStatus">) {
if (!log.billable) return "不计费";
if (!log.billingAvailable) return "未扣积分";
const labels = { settled: "已结算", refunded: "已退回", uncertain: "待核对", running: "运行中", reserved: "已预授权" };
return log.billingStatus ? labels[log.billingStatus] : "已预授权";
}

View File

@@ -0,0 +1,32 @@
[data-admin-root] .admin-data-table:has(.admin-logs-compact, .admin-logs-full) .admin-list-toolbar-main {
min-width: 0;
flex: 1 1 640px;
}
[data-admin-root] .admin-logs-compact .ant-table-cell {
white-space: nowrap;
}
[data-admin-root] .admin-log-detail-link {
display: block;
max-width: 100%;
overflow: hidden;
padding: 0;
border: 0;
background: transparent;
color: var(--admin-text-secondary);
font: inherit;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
cursor: pointer;
}
[data-admin-root] .admin-log-detail-link:hover {
color: var(--admin-text);
text-decoration: underline;
text-underline-offset: 3px;
}
[data-admin-root] .admin-log-error {
color: var(--admin-status-error);
}

View File

@@ -1,4 +1,4 @@
import { App, Button, Input, Modal, Select } from "antd";
import { Alert, App, Button, Input, Modal, Segmented, Select } from "antd";
import { IconButton } from "@/pages/admin/ui/controls";
import type { ColumnsType } from "antd/es/table";
import { Download, Eye, Play, Search } from "lucide-react";
@@ -14,11 +14,14 @@ import { exportAdminApiLogs, listAdminApiLogs, type ApiCallLog } from "@/service
import { ApiLogDetailDrawer } from "../components/api-log-detail-drawer";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminBatchBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminStatusBadge, AdminTableEmpty } from "../components/admin-ui";
import { logBillingLabel, logStatus, normalizeLogView } from "./log-view";
import "./logs-page.css";
export default function LogsPage() {
const { message } = App.useApp();
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get("filter") || "";
const view = normalizeLogView(searchParams.get("view"));
const status = normalizeStatus(searchParams.get("status"));
const recordType = searchParams.get("recordType") === "download" ? "download" : searchParams.get("recordType") === "all" ? "all" : "request";
const page = positiveInt(searchParams.get("page"), 1);
@@ -27,6 +30,8 @@ export default function LogsPage() {
const [logs, setLogs] = useState<ApiCallLog[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [retry, setRetry] = useState(0);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detailLogId, setDetailLogId] = useState<string | null>(null);
const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null);
@@ -46,6 +51,10 @@ export default function LogsPage() {
useEffect(() => {
const sequence = ++requestSequence.current;
setLoading(true);
setLoadError("");
setLogs([]);
setTotal(0);
setSelectedIds([]);
void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, pageSize })
.then((result) => {
if (sequence !== requestSequence.current) return;
@@ -54,12 +63,18 @@ export default function LogsPage() {
setSelectedIds([]);
if (result.total > 0 && result.logs.length === 0 && page > 1) updateUrl({ page: 1 }, true);
})
.catch((error) => sequence === requestSequence.current && message.error(error instanceof Error ? error.message : "读取请求明细失败"))
.catch((error) => {
if (sequence !== requestSequence.current) return;
const text = error instanceof Error ? error.message : "读取请求明细失败";
setLoadError(text);
message.error(text);
})
.finally(() => sequence === requestSequence.current && setLoading(false));
}, [debouncedKeyword, status, recordType, page, pageSize]);
return () => { requestSequence.current += 1; };
}, [debouncedKeyword, status, recordType, page, pageSize, retry]);
const columns: ColumnsType<ApiCallLog> = [
{ title: "时间", width: 168, render: (_, log) => formatTime(log.startedAt || log.createdAt) },
const fullColumns: ColumnsType<ApiCallLog> = [
{ title: "时间", width: 168, render: (_, log) => <button type="button" className="admin-log-detail-link" aria-label={`查看请求 ${log.id} 详情`} onClick={() => setDetailLogId(log.id)}>{formatTime(log.startedAt || log.createdAt)}</button> },
{
title: "用户",
width: 180,
@@ -124,6 +139,27 @@ export default function LogsPage() {
},
];
const identityColumns: ColumnsType<ApiCallLog> = [
{ title: "时间 / 详情", key: "time", width: 142, render: (_, log) => <button type="button" className="admin-log-detail-link" title={formatTime(log.startedAt || log.createdAt)} aria-label={`查看请求 ${log.id} 详情`} onClick={() => setDetailLogId(log.id)}>{formatCompactTime(log.startedAt || log.createdAt)}</button> },
{ title: "状态", key: "status", width: 88, render: (_, log) => <AdminStatusBadge {...logStatus(log)} /> },
{ title: "模型", dataIndex: "model", width: 170, ellipsis: true, render: (value) => value || "未识别模型" },
{ title: "渠道", dataIndex: "channelName", width: 116, ellipsis: true, render: (value) => value || "未记录渠道" },
{ title: "用户", key: "user", width: 110, ellipsis: true, render: (_, log) => <span title={[log.userDisplayName, log.userAccount].filter(Boolean).join(" · ")}>{log.userDisplayName || log.userAccount || "未知用户"}</span> },
];
const compactColumns: ColumnsType<ApiCallLog> = view === "billing" ? [
...identityColumns,
{ title: "销售积分", key: "revenue", align: "right", width: 108, render: (_, log) => log.billable && log.billingAvailable ? formatCredits(log.billingAmountMicrocredits) : "—" },
{ title: "成本积分", key: "cost", align: "right", width: 108, render: (_, log) => log.creditCostMicrocredits !== undefined ? formatCredits(log.creditCostMicrocredits) : log.creditCostConfigured ? "待核算" : "未配置" },
{ title: "账务状态", key: "billingStatus", width: 100, render: (_, log) => logBillingLabel(log) },
{ title: "Tokens 入 / 出", key: "usage", width: 142, render: (_, log) => log.usageAvailable ? `${log.inputTokens.toLocaleString()} / ${log.outputTokens.toLocaleString()}` : "未返回" },
] : [
...identityColumns,
{ title: "阶段", key: "kind", width: 90, render: (_, log) => requestKindText(log.requestKind) },
{ title: "耗时", dataIndex: "durationMs", align: "right", width: 90, render: formatDuration },
{ title: "错误摘要", key: "error", ellipsis: true, render: (_, log) => <span className={logStatus(log).tone === "error" ? "admin-log-error" : undefined} title={[log.errorCode, log.error].filter(Boolean).join(" · ")}>{[log.errorCode, log.error].filter(Boolean).join(" · ") || (logStatus(log).tone === "error" ? `HTTP ${log.statusCode || "失败"} · 未返回详情` : "—")}</span> },
];
const columns = view === "all" ? fullColumns : compactColumns;
return (
<AdminPageFrame
title="请求明细"
@@ -138,7 +174,10 @@ export default function LogsPage() {
/>
}
>
{loadError ? <Alert type="error" showIcon title="请求明细读取失败" description={loadError} action={<Button size="small" onClick={() => setRetry((value) => value + 1)}>重试</Button>} /> : null}
<AdminDataTable
className={view === "all" ? "admin-logs-full" : "admin-logs-compact"}
trailing={<Segmented aria-label="请求明细视图" value={view} onChange={(value) => updateUrl({ view: value })} options={[{ label: "排障", value: "troubleshoot" }, { label: "计费", value: "billing" }, { label: "全部字段", value: "all" }]} />}
toolbar={
<Input
allowClear
@@ -159,6 +198,7 @@ export default function LogsPage() {
<>
<Select aria-label="明细类型" className="w-32" value={recordType} onChange={(value) => updateUrl({ recordType: value, page: 1 })} options={[{ label: "仅请求", value: "request" }, { label: "仅下载", value: "download" }, { label: "全部明细", value: "all" }]} />
<Select
aria-label="请求结果"
className="w-32"
value={status}
onChange={(value) => updateUrl({ status: value, page: 1 })}
@@ -191,7 +231,7 @@ export default function LogsPage() {
size: "small",
rowKey: "id",
loading,
rowSelection: { selectedRowKeys: selectedIds, preserveSelectedRowKeys: false, onChange: (keys) => setSelectedIds(keys.map(String)) },
rowSelection: { columnWidth: 36, selectedRowKeys: selectedIds, preserveSelectedRowKeys: false, onChange: (keys) => setSelectedIds(keys.map(String)) },
onRow: (log) => ({
onClick: (event) => {
if ((event.target as HTMLElement).closest("button,a,input,.ant-checkbox-wrapper")) return;
@@ -200,11 +240,12 @@ export default function LogsPage() {
className: "admin-table-clickable-row",
}),
columns,
tableLayout: "fixed",
dataSource: logs,
pagination: false,
scroll: { x: 1600 },
scroll: { x: view === "all" ? 1775 : view === "billing" ? 1120 : 1060 },
}}
empty={<AdminTableEmpty filtered={hasFilters} />}
empty={loadError ? <span role="status">数据暂不可用,请重试</span> : <AdminTableEmpty filtered={hasFilters} />}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
/>
<ApiLogDetailDrawer logId={detailLogId} onClose={() => setDetailLogId(null)} onLogUpdated={(next) => setLogs((items) => items.map((item) => (item.id === next.id ? next : item)))} />
@@ -251,6 +292,12 @@ function normalizeStatus(value: string | null): "all" | "succeeded" | "failed" {
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
}
function formatCompactTime(value: string) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
}
function capabilityText(value: string) {
return ({ text: "文本", image: "图片", video: "视频", audio: "音频" } as Record<string, string>)[value] || "未知";
}
@@ -310,13 +357,10 @@ function downloadMedia(url: string, kind: "image" | "video") {
}
function CallStatus({ log }: { log: ApiCallLog }) {
const providerStatus = log.providerStatus?.toLowerCase();
const processing = ["queued", "pending", "processing", "running", "in_progress"].includes(providerStatus || "");
const failed = log.status === "failed" || ["failed", "cancelled", "expired"].includes(providerStatus || "");
return (
<div>
<div className="mb-1 text-xs font-medium text-foreground/70">{requestKindText(log.requestKind)}</div>
<AdminStatusBadge label={failed ? "失败" : processing ? "处理中" : "成功"} tone={failed ? "error" : processing ? "warning" : "success"} />
<AdminStatusBadge {...logStatus(log)} />
{log.capability === "video" ? <div className="mt-1 text-xs tabular-nums text-foreground/45">已轮询 {log.pollCount || 0} 次</div> : null}
</div>
);

View File

@@ -9,6 +9,7 @@ import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
const refreshIntervalMs = 15_000;
function formatBytes(value?: number) {
if (value === undefined || !Number.isFinite(value)) return "--";
if (!value || value < 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
@@ -17,6 +18,7 @@ function formatBytes(value?: number) {
}
function formatDuration(seconds?: number) {
if (seconds === undefined || !Number.isFinite(seconds)) return "--";
if (!seconds) return "不足 1 分钟";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
@@ -25,7 +27,7 @@ function formatDuration(seconds?: number) {
}
function formatNumber(value?: number) {
return new Intl.NumberFormat("zh-CN").format(value || 0);
return value === undefined || !Number.isFinite(value) ? "--" : new Intl.NumberFormat("zh-CN").format(value);
}
function metricTone(value: number) {
@@ -62,20 +64,22 @@ export default function SystemPerformancePage() {
const [autoRefresh, setAutoRefresh] = useState(true);
const [loadError, setLoadError] = useState("");
const mountedRef = useRef(true);
const requestSequence = useRef(0);
const load = useCallback(async (initial = false) => {
const sequence = ++requestSequence.current;
if (initial) setLoading(true);
else setRefreshing(true);
try {
const next = await getSystemPerformance();
if (!mountedRef.current) return;
if (!mountedRef.current || sequence !== requestSequence.current) return;
setData(next);
setLoadError("");
} catch (error) {
if (!mountedRef.current) return;
if (!mountedRef.current || sequence !== requestSequence.current) return;
setLoadError(error instanceof Error ? error.message : "读取系统性能失败");
} finally {
if (mountedRef.current) {
if (mountedRef.current && sequence === requestSequence.current) {
setLoading(false);
setRefreshing(false);
}
@@ -85,7 +89,7 @@ export default function SystemPerformancePage() {
useEffect(() => {
mountedRef.current = true;
void load(true);
return () => { mountedRef.current = false; };
return () => { mountedRef.current = false; requestSequence.current += 1; };
}, [load]);
useEffect(() => {
@@ -129,6 +133,12 @@ export default function SystemPerformancePage() {
return <AdminPageFrame title="系统性能" description="服务器、数据库与运行时缓存状态。" scroll><div className="admin-settings-stack admin-system-performance"><Skeleton active paragraph={{ rows: 14 }} /></div></AdminPageFrame>;
}
if (!data) {
return <AdminPageFrame title="系统性能" description="运行状态未知;尚未取得有效采集数据。" scroll>
<div className="admin-performance-alert" role="alert"><AlertTriangle className="size-4" /><span>{loadError || "性能数据不可用"}</span><Button size="small" loading={refreshing} onClick={() => void load(false)}>重试</Button></div>
</AdminPageFrame>;
}
const memoryPercent = data?.memory.systemAvailable ? data.memory.usagePercent : undefined;
const databaseConnections = data?.database.postgres?.connections ?? data?.database.pool.openConnections ?? 0;
const databaseLimit = data?.database.postgres?.maxConnections || data?.database.pool.maxOpenConnections || 0;
@@ -144,9 +154,9 @@ export default function SystemPerformancePage() {
actions={<><label className="admin-performance-auto"><Switch size="small" checked={autoRefresh} onChange={setAutoRefresh} /><span>15 秒刷新</span></label><Button icon={<RefreshCw className="size-4" />} loading={refreshing} onClick={() => void load(false)}>刷新</Button></>}
>
<div className="admin-settings-stack admin-system-performance">
{loadError ? <div className="admin-performance-alert"><AlertTriangle className="size-4" /><span>{loadError}</span><Button size="small" onClick={() => void load(false)}>重试</Button></div> : null}
{loadError ? <div className="admin-performance-alert" role="alert"><AlertTriangle className="size-4" /><span>刷新失败,以下为上次采集的快照,非当前状态:{loadError}</span><Button size="small" onClick={() => void load(false)}>重试</Button></div> : null}
<div className="admin-performance-statusbar">
<div><AdminStatusBadge label={data?.status === "healthy" ? "系统运行正常" : "部分服务降级"} tone={data?.status === "healthy" ? "success" : "warning"} /><span>采集于 {data ? new Date(data.collectedAt).toLocaleString("zh-CN", { hour12: false }) : "--"}</span></div>
<div><AdminStatusBadge label={loadError ? "快照已过期" : data.status === "healthy" ? "系统运行正常" : "部分服务降级"} tone={loadError ? "warning" : data.status === "healthy" ? "success" : "warning"} /><span>采集于 {new Date(data.collectedAt).toLocaleString("zh-CN", { hour12: false })}</span></div>
<span>进程已运行 {formatDuration(data?.host.uptimeSeconds)}</span>
</div>

View File

@@ -7,9 +7,9 @@ export function getIsolatedAdminAntTheme(dark: boolean, skinID: unknown = "class
const skin = getSkinAntOverrides(skinID, dark ? "dark" : "light");
const text = dark ? "#f5f5f5" : "#111111";
const textSecondary = dark ? "#c4c4c4" : "#525252";
const canvas = dark ? "#0f0f0f" : "#f5f5f5";
const surface = dark ? "#181818" : "#ffffff";
const surfaceMuted = dark ? "#222222" : "#f6f6f6";
const canvas = dark ? "#111317" : "#f7f8fa";
const surface = dark ? "#191c21" : "#ffffff";
const surfaceMuted = dark ? "#23272e" : "#f2f4f7";
const border = dark ? "rgba(245, 245, 245, 0.12)" : "rgba(17, 17, 17, 0.12)";
const primary = skin.primary || (dark ? "#f5f5f5" : "#171717");
const primaryFg = skin.primaryForeground || (dark ? "#171717" : "#ffffff");

View File

@@ -21,7 +21,7 @@
width: var(--admin-sidebar-width);
flex-basis: var(--admin-sidebar-width);
background: var(--admin-sidebar);
border-right: 1px solid var(--admin-border);
border-right: 0;
box-shadow: none;
border-radius: 0 !important;
}
@@ -36,7 +36,7 @@
height: 56px;
align-items: center;
padding: 0 12px;
border-bottom: 1px solid var(--admin-border);
border-bottom: 0;
}
[data-admin-root] .admin-sidebar-brand-button {
@@ -83,7 +83,7 @@
}
[data-admin-root] .admin-nav-group + .admin-nav-group {
margin-top: 18px;
margin-top: 14px;
}
[data-admin-root] .admin-nav-group-label,
@@ -115,7 +115,7 @@
[data-admin-root] .admin-nav-group-items:not(.is-collapsed) {
display: flex;
flex-direction: column;
gap: 6px;
gap: 2px;
margin-left: 0 !important;
padding-left: 0 !important;
border: 0 !important;
@@ -235,10 +235,34 @@
}
[data-admin-root] .admin-page-header {
min-height: 48px;
gap: 12px !important;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--admin-border);
padding-bottom: 0;
border-bottom: 0;
}
[data-admin-root] .admin-density-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
height: var(--admin-control-h);
padding: 0 8px;
border: 0;
border-radius: var(--admin-radius);
background: transparent;
color: var(--admin-text-secondary);
font-size: 12px;
cursor: pointer;
}
[data-admin-root] .admin-density-toggle:hover {
background: var(--admin-surface-2);
}
[data-admin-root] :is(.admin-density-toggle, .admin-log-detail-link):focus-visible {
outline: 2px solid var(--admin-text-secondary);
outline-offset: 2px;
}
[data-admin-root] .admin-page-location {
@@ -288,9 +312,9 @@
width: 32px;
height: 32px;
place-items: center;
border: 1px solid var(--admin-border);
border: 0;
border-radius: var(--admin-radius);
background: var(--admin-surface);
background: transparent;
color: var(--admin-text-secondary);
}
@@ -354,19 +378,30 @@
}
[data-admin-root] .admin-list-toolbar-main,
[data-admin-root] .admin-list-toolbar-actions,
[data-admin-root] .admin-list-toolbar-filters {
[data-admin-root] .admin-list-toolbar-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
[data-admin-root] .admin-list-toolbar-filters {
display: none;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
[data-admin-root] .admin-list-toolbar-filters.is-open {
display: flex;
}
[data-admin-root] .admin-list-toolbar .ant-input-affix-wrapper,
[data-admin-root] .admin-list-toolbar .ant-select-selector,
[data-admin-root] .admin-list-toolbar .ant-btn,
[data-admin-root] .admin-page-actions .ant-btn {
height: var(--admin-control-h) !important;
min-height: var(--admin-control-h) !important;
border-radius: var(--admin-radius) !important;
}
@@ -389,7 +424,7 @@
flex: 1;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--admin-border);
border: 0;
border-radius: var(--admin-radius-lg);
background: var(--admin-surface);
box-shadow: none;
@@ -414,17 +449,16 @@
border-bottom: 1px solid var(--admin-border) !important;
background: var(--admin-surface-2) !important;
color: var(--admin-text-muted) !important;
font-size: 11px !important;
font-size: 12px !important;
font-weight: 650 !important;
letter-spacing: 0.04em;
text-transform: uppercase;
letter-spacing: 0;
}
[data-admin-root] .admin-table-surface .ant-table-tbody > tr > td {
height: var(--admin-row-h);
padding: 6px 12px !important;
padding: var(--admin-row-padding) 12px !important;
border-bottom: 1px solid var(--admin-row-border) !important;
background: transparent !important;
background: var(--admin-surface) !important;
color: var(--admin-text) !important;
font-size: 13px;
font-variant-numeric: tabular-nums;
@@ -434,9 +468,13 @@
background: var(--admin-row-hover) !important;
}
[data-admin-root] .admin-table-surface .ant-table-tbody > tr.ant-table-row-selected > td {
background: var(--admin-surface-2) !important;
}
[data-admin-root] .admin-table-pagination {
display: flex;
min-height: 44px;
min-height: 40px;
align-items: center;
justify-content: flex-end;
padding: 0 10px;
@@ -508,12 +546,12 @@
height: 22px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid var(--admin-border);
border-radius: 999px;
background: var(--admin-surface-2);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--admin-text-secondary);
font-size: 11px;
font-size: 12px;
font-weight: 650;
line-height: 1;
white-space: nowrap;
@@ -572,10 +610,25 @@
line-height: 18px;
}
[data-admin-root] .admin-inline-load-error {
display: flex;
min-height: 96px;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
color: var(--admin-status-error);
font-size: 12px;
}
[data-admin-root] .admin-inline-load-error .ant-btn {
flex: none;
}
[data-admin-root] .admin-stat-tile {
min-height: 84px;
min-height: 76px;
padding: 12px 14px;
border: 1px solid var(--admin-border);
border: 0;
border-radius: var(--admin-radius-lg);
background: var(--admin-surface);
box-shadow: none;
@@ -608,12 +661,12 @@
[data-admin-root] .admin-settings-stack {
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
}
[data-admin-root] .admin-settings-section {
overflow: hidden;
border: 1px solid var(--admin-border);
border: 0;
border-radius: var(--admin-radius-lg);
background: var(--admin-surface);
box-shadow: none;
@@ -631,7 +684,7 @@
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--admin-border);
border-bottom: 0;
}
[data-admin-root] .admin-settings-section-summary h2 {
@@ -710,12 +763,12 @@
[data-admin-root] .admin-feature-board {
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
}
[data-admin-root] .admin-feature-domain {
overflow: hidden;
border: 1px solid var(--admin-border);
border: 0;
border-radius: var(--admin-radius-lg);
background: var(--admin-surface);
box-shadow: none;
@@ -726,8 +779,8 @@
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--admin-border);
background: var(--admin-surface);
border-bottom: 0;
background: var(--admin-surface-2);
}
[data-admin-root] .admin-feature-domain-header h2 {
@@ -749,7 +802,7 @@
width: 28px;
height: 28px;
place-items: center;
border: 1px solid var(--admin-border);
border: 0;
border-radius: var(--admin-radius);
background: var(--admin-surface-2);
color: var(--admin-text-secondary);
@@ -980,4 +1033,3 @@
animation-iteration-count: 1 !important;
}
}

View File

@@ -11,17 +11,18 @@
--admin-radius-lg: 8px;
--admin-control-h: 32px;
--admin-control-h-sm: 28px;
--admin-row-h: 40px;
--admin-head-h: 36px;
--admin-sidebar-width: 224px;
--admin-row-h: 36px;
--admin-row-padding: 4px;
--admin-head-h: 32px;
--admin-sidebar-width: 208px;
--admin-sidebar-collapsed: 64px;
--admin-motion: 120ms ease;
--admin-shadow-overlay: 0 16px 40px rgba(15, 23, 42, 0.16);
--admin-canvas: #f5f5f5;
--admin-sidebar: #f5f5f5;
--admin-canvas: #f7f8fa;
--admin-sidebar: #f7f8fa;
--admin-surface: #ffffff;
--admin-surface-2: #f6f6f6;
--admin-surface-2: #f2f4f7;
--admin-surface-3: #ececec;
--admin-text: #111111;
--admin-text-secondary: #525252;
@@ -99,10 +100,10 @@
.dark :is([data-admin-root], .admin-drawer, .admin-secondary-drawer, .admin-modal-root, .admin-channel-modal-root, .admin-mobile-navigation-menu),
.dark body.admin-overlays {
--admin-canvas: #0f0f0f;
--admin-sidebar: #0f0f0f;
--admin-surface: #181818;
--admin-surface-2: #222222;
--admin-canvas: #111317;
--admin-sidebar: #111317;
--admin-surface: #191c21;
--admin-surface-2: #23272e;
--admin-surface-3: #2a2a2a;
--admin-text: #f5f5f5;
--admin-text-secondary: #c4c4c4;
@@ -129,3 +130,9 @@
--admin-chart-warning: #fbbf24;
--admin-shadow-overlay: 0 22px 56px rgba(0, 0, 0, 0.48);
}
[data-admin-root][data-admin-density="comfortable"] {
--admin-row-h: 44px;
--admin-row-padding: 8px;
--admin-head-h: 36px;
}

View File

@@ -54,18 +54,17 @@ export function createUserColumns({
},
{ key: "role", title: "角色", dataIndex: "role", width: 110, align: "center", render: (role) => <AdminStatusBadge label={role === "admin" ? "管理员" : "普通用户"} tone={role === "admin" ? "info" : "neutral"} /> },
{ key: "status", title: "状态", dataIndex: "status", width: 110, align: "center", render: (status) => <AdminStatusBadge label={status === "active" ? "已启用" : "已停用"} tone={status === "active" ? "success" : "neutral"} /> },
{ key: "createdAt", title: "注册时间", dataIndex: "createdAt", width: 180, align: "center", render: formatTime },
{ key: "createdAt", title: "注册时间", dataIndex: "createdAt", width: 112, align: "center", render: (value) => <span className="tabular-nums" title={formatTime(value)}>{formatCompactTime(value)}</span> },
{
key: "actions",
title: "操作",
width: 280,
width: 184,
align: "center",
render: (_, user) => (
<AdminRowActions
primary={{ label: "详情", icon: <Eye className="size-3.5" />, onClick: () => onView(user) }}
visibleActionCount={2}
visibleActionCount={1}
actions={[
{ key: "edit", label: "编辑用户", icon: <Pencil className="size-3.5" />, onClick: () => onEdit(user) },
{
key: "toggle-status",
label: user.status === "active" ? "停用用户" : "重新启用",
@@ -79,6 +78,7 @@ export function createUserColumns({
},
onClick: () => onToggleStatus(user),
},
{ key: "edit", label: "编辑用户", icon: <Pencil className="size-3.5" />, onClick: () => onEdit(user) },
]}
/>
),
@@ -90,3 +90,10 @@ export function createUserColumns({
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
}
function formatCompactTime(value?: string) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}

View File

@@ -24,6 +24,8 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [retry, setRetry] = useState(0);
const [detailUserId, setDetailUserId] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [createUserOpen, setCreateUserOpen] = useState(false);
@@ -52,6 +54,9 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
useEffect(() => {
const sequence = ++requestSequence.current;
setLoading(true);
setLoadError("");
setUsers([]);
setTotal(0);
void listAdminUsers({
keyword: debouncedFilter || undefined,
role: state.role === "all" ? undefined : state.role,
@@ -67,12 +72,15 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
if (result.total > 0 && result.users.length === 0 && state.page > 1) update({ page: 1 }, true);
})
.catch((error) => {
if (sequence === requestSequence.current) message.error(error instanceof Error ? error.message : "读取用户失败");
if (sequence !== requestSequence.current) return;
const text = error instanceof Error ? error.message : "读取用户失败";
setLoadError(text);
message.error(text);
})
.finally(() => {
if (sequence === requestSequence.current) setLoading(false);
});
}, [debouncedFilter, message, state.page, state.pageSize, state.role, state.status, update]);
}, [debouncedFilter, message, retry, state.page, state.pageSize, state.role, state.status, update]);
const replaceUser = useCallback((nextUser: LocalUser) => {
setUsers((items) => items.map((item) => item.id === nextUser.id ? { ...item, ...nextUser } : item));
@@ -207,7 +215,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
batchActions={<AdminBatchBar count={selectedUserIds.length} onClear={() => setSelectedUserIds([])}><Button danger size="small" icon={<Ban className="size-3.5" />} loading={bulkDisabling} onClick={bulkDisable}>批量停用</Button></AdminBatchBar>}
skeletonColumns={Math.max(4, columns.length)}
table={{
className: "app-data-table",
className: "app-data-table admin-users-table",
size: "small",
rowKey: "id",
loading,
@@ -222,7 +230,7 @@ export default function UsersPanel({ onUserChanged }: { onUserChanged?: (user: L
pagination: false,
scroll: { x: 860 },
}}
empty={<AdminTableEmpty filtered={hasFilters} />}
empty={loadError ? <div className="admin-inline-load-error" role="status"><span>用户数据暂不可用:{loadError}</span><Button size="small" onClick={() => setRetry((value) => value + 1)}>重试</Button></div> : <AdminTableEmpty filtered={hasFilters} />}
footer={<PaginationBar alwaysShow current={state.page} pageSize={state.pageSize} total={total} onChange={(page, pageSize) => update({ page: pageSize !== state.pageSize ? 1 : page, pageSize })} />}
/>

View File

@@ -8506,19 +8506,27 @@
line-height: 18px;
}
.admin-analytics-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-analytics-health-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
gap: 4px;
border-radius: var(--admin-radius-lg);
background: var(--admin-layer-1);
}
.admin-analytics-health-card {
min-width: 0;
min-height: 118px;
border: 1px solid var(--admin-card-border);
border-radius: 11px;
min-height: 104px;
border: 0;
border-radius: var(--admin-radius-lg);
background: var(--admin-layer-1);
padding: 16px 18px;
padding: 12px;
box-shadow: var(--admin-card-shadow);
}
@@ -8533,12 +8541,12 @@
.admin-analytics-health-card-heading > span:first-child {
display: grid;
width: 28px;
height: 28px;
width: 18px;
height: 18px;
place-items: center;
border: 1px solid var(--admin-divider);
border: 0;
border-radius: 7px;
background: var(--admin-layer-2);
background: transparent;
color: var(--admin-text-secondary);
}
@@ -8571,13 +8579,13 @@
gap: 7px;
margin-top: 10px;
color: var(--admin-text-muted);
font-size: 11px;
font-size: 12px;
line-height: 16px;
}
.admin-analytics-finance-values {
display: grid;
gap: 6px;
gap: 2px;
margin: 0;
font-size: 14px;
letter-spacing: 0;
@@ -8606,15 +8614,15 @@
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
gap: 12px;
}
.admin-analytics-trend-section {
overflow: hidden;
border: 1px solid var(--admin-card-border);
border-radius: 12px;
border: 0;
border-radius: var(--admin-radius-lg);
background: var(--admin-layer-1);
padding: 18px 20px;
padding: 12px 16px;
box-shadow: var(--admin-card-shadow);
}
@@ -8629,9 +8637,9 @@
}
.admin-analytics-section-heading {
margin-bottom: 14px;
border-bottom: 1px solid var(--admin-divider);
padding-bottom: 13px;
margin-bottom: 8px;
border-bottom: 0;
padding-bottom: 0;
}
.admin-analytics-section-heading h2,
@@ -8648,17 +8656,17 @@
.admin-analytics-analysis-heading p {
margin-top: 4px;
color: var(--admin-text-muted);
font-size: 11px;
font-size: 12px;
line-height: 17px;
}
.admin-analytics-chart {
height: 280px;
height: 208px;
}
.admin-analytics-empty-chart {
display: flex;
min-height: 220px;
min-height: 208px;
flex-direction: column;
align-items: center;
justify-content: center;
@@ -8700,24 +8708,24 @@
display: flex;
overflow: hidden;
flex-direction: column;
border: 1px solid var(--admin-card-border);
border-radius: 12px;
border: 0;
border-radius: var(--admin-radius-lg);
background: var(--admin-layer-1);
padding: 18px 20px;
padding: 12px 16px;
box-shadow: var(--admin-card-shadow);
}
.admin-analytics-attention-heading {
min-height: 50px;
min-height: 44px;
align-items: flex-start;
border-bottom: 1px solid var(--admin-divider);
padding-bottom: 13px;
border-bottom: 0;
padding-bottom: 6px;
}
.admin-analytics-attention-list {
display: grid;
flex: 1 1 auto;
grid-template-rows: repeat(3, minmax(76px, 1fr));
grid-template-rows: repeat(3, minmax(64px, 1fr));
margin-top: 2px;
}
@@ -8725,7 +8733,7 @@
display: grid;
width: 100%;
min-width: 0;
min-height: 76px;
min-height: 64px;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
@@ -8794,7 +8802,7 @@
.admin-analytics-attention-description {
margin-top: 4px;
color: var(--admin-text-muted);
font-size: 10px;
font-size: 12px;
}
.admin-analytics-attention-item strong {
@@ -8805,18 +8813,18 @@
.admin-analytics-analysis-section {
overflow: hidden;
border: 1px solid var(--admin-card-border);
border-radius: 12px;
border: 0;
border-radius: var(--admin-radius-lg);
background: var(--admin-layer-1);
box-shadow: var(--admin-card-shadow);
scroll-margin-top: 16px;
}
.admin-analytics-analysis-heading {
min-height: 72px;
min-height: 56px;
align-items: center;
border-bottom: 1px solid var(--admin-divider);
padding: 14px 18px;
border-bottom: 0;
padding: 10px 16px;
}
.admin-analytics-tabs {
@@ -10098,7 +10106,7 @@
}
.admin-analytics-trend-section {
padding: 22px 24px 18px;
padding: 12px 16px;
}
.admin-settings-section:not(.is-stacked) {
@@ -10139,7 +10147,7 @@
@media (min-width: 1200px) {
.admin-analytics-health-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
@@ -10717,11 +10725,11 @@ html[data-magicui-theme-vt="active"] .admin-shell * {
gap: 12px;
min-height: 34px;
padding: 6px 10px;
border: 1px solid var(--admin-divider);
border: 0;
border-radius: 8px;
background: var(--admin-layer-2);
color: var(--admin-text-muted);
font-size: 11px;
font-size: 12px;
}
.admin-performance-statusbar > div {
@@ -10733,7 +10741,7 @@ html[data-magicui-theme-vt="active"] .admin-shell * {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--admin-card-border);
border: 0;
border-radius: 10px;
background: var(--admin-layer-1);
box-shadow: var(--admin-card-shadow);
@@ -10745,7 +10753,7 @@ html[data-magicui-theme-vt="active"] .admin-shell * {
}
.admin-performance-kpi + .admin-performance-kpi {
border-left: 1px solid var(--admin-divider);
border-left: 0;
}
.admin-performance-kpi-heading {
@@ -10772,7 +10780,7 @@ html[data-magicui-theme-vt="active"] .admin-shell * {
}
.admin-performance-kpi-heading p {
font-size: 11px;
font-size: 12px;
font-weight: 600;
}
@@ -10788,7 +10796,7 @@ html[data-magicui-theme-vt="active"] .admin-shell * {
.admin-performance-kpi > p {
margin-top: 3px;
color: var(--admin-text-muted);
font-size: 10px;
font-size: 12px;
}
.admin-performance-kpi .ant-progress {
@@ -10827,9 +10835,9 @@ html[data-magicui-theme-vt="active"] .admin-shell * {
.admin-performance-facts dd {
min-width: 0;
margin: 0;
padding: 8px 14px;
padding: 6px 14px;
border-top: 1px solid var(--admin-row-border);
font-size: 11px;
font-size: 12px;
line-height: 1.45;
}