refactor(admin): 管理后台 - 收敛重复边框并提升运维信息密度
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
42
web/src/pages/admin/components/admin-density.tsx
Normal file
42
web/src/pages/admin/components/admin-density.tsx
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
21
web/src/pages/admin/logs/log-view.ts
Normal file
21
web/src/pages/admin/logs/log-view.ts
Normal 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] : "已预授权";
|
||||
}
|
||||
32
web/src/pages/admin/logs/logs-page.css
Normal file
32
web/src/pages/admin/logs/logs-page.css
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 })} />}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user