style(admin): 管理后台 - 统一工作区与模型编辑器布局

This commit is contained in:
LUHANG0 authored and ddCat committed 2026-08-26 14:41:46 +08:00
1 parent c8d09bf886
commit 6141bd688c
10 files changed
+2505 -544

No files matched your search

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { App, Button, DatePicker, Form, Input, InputNumber, Modal, Select, Tabs, Tag, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { BarChart3, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip as ChartTooltip, XAxis, YAxis } from "recharts";
import { useSearchParams } from "react-router";
@@ -272,11 +272,26 @@ export default function AnalyticsPanel({ users, channels }: Props) {
{
title: "操作",
width: 170,
render: (_, row) => <AdminRowActions primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openPricing(row) }} actions={[{ key: "delete", label: "删除", icon: <Trash2 className="size-3.5" />, danger: true, confirm: { title: "删除价格配置?", description: "删除后新的调用不再使用这条价格配置,历史费用不受影响。", okText: "确认删除" }, onClick: () => removePricing(row.id) }]} />,
render: (_, row) => (
<AdminRowActions
primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openPricing(row) }}
actions={[
{
key: "delete",
label: "删除",
icon: <Trash2 className="size-3.5" />,
danger: true,
confirm: { title: "删除价格配置?", description: "删除后新的调用不再使用这条价格配置,历史费用不受影响。", okText: "确认删除" },
onClick: () => removePricing(row.id),
},
]}
/>
),
},
];
const trend = data?.trend || [];
const hasTrendActivity = trend.some((item) => item.tasks > 0 || item.requests > 0);
const currentTrend = trend[trend.length - 1];
const previousTrend = trend[trend.length - 2];
const modelRows = data?.models || [];
@@ -289,17 +304,44 @@ export default function AnalyticsPanel({ users, channels }: Props) {
<div className="space-y-5">
<ListToolbar
active={Boolean(userId || model || channelId || capability)}
activeFilters={(
activeFilters={
<>
{userId ? <AdminFilterChip label={`用户:${userOptions.find((user) => user.id === userId)?.displayName || userId}`} onRemove={() => setUserId(undefined)} /> : null}
{model ? <AdminFilterChip label={`模型:${model}`} onRemove={() => setModel(undefined)} /> : null}
{channelId ? <AdminFilterChip label={`渠道:${channels.find((channel) => channel.id === channelId)?.name || channelId}`} onRemove={() => setChannelId(undefined)} /> : null}
{capability ? <AdminFilterChip label={`能力:${capabilityLabel(capability)}`} onRemove={() => setCapability(undefined)} /> : null}
</>
)}
onReset={() => { setUserId(undefined); setModel(undefined); setChannelId(undefined); setCapability(undefined); }}
trailing={<><Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>刷新</Button><AdminExportButton exportFile={() => exportAdminAnalytics(filters)} fileName={() => `usage-${filters.from}-${filters.to}.csv`} label="导出 CSV" /></>}
filters={<><FilterSelect label="用户" value={userId} onChange={setUserId} options={userOptions.map((user) => ({ label: user.displayName || user.username, value: user.id }))} filterOption={false} loading={searchingUsers} onSearch={(value) => void searchUsers(value)} /><FilterSelect label="模型" value={model} onChange={setModel} options={modelOptions} width={210} /><FilterSelect label="渠道" value={channelId} onChange={setChannelId} options={channels.map((channel) => ({ label: channel.name, value: channel.id }))} /><FilterSelect label="能力" value={capability} onChange={setCapability} options={capabilityOptions} /></>}
}
onReset={() => {
setUserId(undefined);
setModel(undefined);
setChannelId(undefined);
setCapability(undefined);
}}
trailing={
<>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
刷新
</Button>
<AdminExportButton exportFile={() => exportAdminAnalytics(filters)} fileName={() => `usage-${filters.from}-${filters.to}.csv`} label="导出 CSV" />
</>
}
filters={
<>
<FilterSelect
label="用户"
value={userId}
onChange={setUserId}
options={userOptions.map((user) => ({ label: user.displayName || user.username, value: user.id }))}
filterOption={false}
loading={searchingUsers}
onSearch={(value) => void searchUsers(value)}
/>
<FilterSelect label="模型" value={model} onChange={setModel} options={modelOptions} width={210} />
<FilterSelect label="渠道" value={channelId} onChange={setChannelId} options={channels.map((channel) => ({ label: channel.name, value: channel.id }))} />
<FilterSelect label="能力" value={capability} onChange={setCapability} options={capabilityOptions} />
</>
}
>
<div>
<div className="mb-1 text-xs text-foreground/55">时间范围</div>
@@ -308,37 +350,68 @@ export default function AnalyticsPanel({ users, channels }: Props) {
</ListToolbar>
<div className="grid overflow-hidden rounded-md border border-border sm:grid-cols-2 xl:grid-cols-4">
<AdminStatTile label="活跃用户" 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} />
<AdminStatTile
label="活跃用户"
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}
/>
<AdminStatTile label="上游请求" value={data ? formatNumber(data.kpi.upstreamRequests) : "--"} trend={formatCountDelta(currentTrend?.requests, previousTrend?.requests)} detail="当前统计范围" />
<AdminStatTile label="生成任务" value={data ? formatNumber(data.kpi.generationTasks) : "--"} trend={formatCountDelta(currentTrend?.tasks, previousTrend?.tasks)} detail={data ? `队列 ${formatNumber(data.kpi.currentQueuedTasks)}` : undefined} />
<AdminStatTile label="请求成功率" value={data ? percent(data.kpi.successRate) : "--"} trend={formatRateDelta(currentTrend?.requestSuccessRate, previousTrend?.requestSuccessRate)} detail={data ? `P95 ${formatDuration(data.kpi.p95DurationMs)}` : undefined} />
<AdminStatTile
label="生成任务"
value={data ? formatNumber(data.kpi.generationTasks) : "--"}
trend={formatCountDelta(currentTrend?.tasks, previousTrend?.tasks)}
detail={data ? `队列 ${formatNumber(data.kpi.currentQueuedTasks)}` : undefined}
/>
<AdminStatTile
label="请求成功率"
value={data ? percent(data.kpi.successRate) : "--"}
trend={formatRateDelta(currentTrend?.requestSuccessRate, previousTrend?.requestSuccessRate)}
detail={data ? `P95 ${formatDuration(data.kpi.p95DurationMs)}` : undefined}
/>
</div>
<div className="grid gap-x-4 gap-y-2 border-b border-border pb-4 text-xs text-foreground/55 sm:grid-cols-3">
<div>当前队列 <span className="ml-1 font-medium text-foreground">{data ? formatNumber(data.kpi.currentQueuedTasks) : "--"}</span></div>
<div>P95 耗时 <span className="ml-1 font-medium text-foreground">{data ? formatDuration(data.kpi.p95DurationMs) : "--"}</span></div>
<div>估算费用 <span className="ml-1 font-medium text-foreground">{data ? formatCost(data.kpi.estimatedCostMicros, data.kpi.currency, data.kpi.costAvailable) : "--"}</span></div>
<div>
当前队列 <span className="ml-1 font-medium text-foreground">{data ? formatNumber(data.kpi.currentQueuedTasks) : "--"}</span>
</div>
<div>
P95 耗时 <span className="ml-1 font-medium text-foreground">{data ? formatDuration(data.kpi.p95DurationMs) : "--"}</span>
</div>
<div>
估算费用 <span className="ml-1 font-medium text-foreground">{data ? formatCost(data.kpi.estimatedCostMicros, data.kpi.currency, data.kpi.costAvailable) : "--"}</span>
</div>
</div>
<section className="border-y border-border py-4">
<section className="admin-analytics-trend-section">
<div className="mb-3">
<h3 className="text-sm font-medium">使用趋势</h3>
<p className="text-xs text-foreground/50">生成任务与真实上游请求分开统计,成功率按上游请求计算。</p>
</div>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data?.trend || []} margin={{ top: 8, right: 12, bottom: 0, left: -16 }}>
<CartesianGrid stroke="currentColor" className="text-foreground/10" vertical={false} />
<XAxis dataKey="day" tickFormatter={(value) => value.slice(5)} tick={{ fontSize: 11 }} />
<YAxis yAxisId="count" allowDecimals={false} tick={{ fontSize: 11 }} />
<YAxis yAxisId="rate" orientation="right" domain={[0, 100]} tickFormatter={(value) => `${value}%`} tick={{ fontSize: 11 }} />
<ChartTooltip labelFormatter={(value) => `日期 ${value}`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area yAxisId="count" type="monotone" dataKey="tasks" name="生成任务" stroke="var(--admin-chart-primary)" fill="var(--admin-chart-primary)" fillOpacity={0.1} />
<Area yAxisId="count" type="monotone" dataKey="requests" name="上游请求" stroke="var(--admin-chart-secondary)" fill="var(--admin-chart-secondary)" fillOpacity={0.08} />
<Line yAxisId="rate" type="monotone" dataKey="requestSuccessRate" name="成功率" stroke="var(--admin-chart-warning)" dot={false} strokeWidth={2} />
</ComposedChart>
</ResponsiveContainer>
</div>
{hasTrendActivity ? (
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data?.trend || []} margin={{ top: 8, right: 12, bottom: 0, left: -16 }}>
<CartesianGrid stroke="currentColor" className="text-foreground/10" vertical={false} />
<XAxis dataKey="day" tickFormatter={(value) => value.slice(5)} tick={{ fontSize: 11 }} />
<YAxis yAxisId="count" allowDecimals={false} tick={{ fontSize: 11 }} />
<YAxis yAxisId="rate" orientation="right" domain={[0, 100]} tickFormatter={(value) => `${value}%`} tick={{ fontSize: 11 }} />
<ChartTooltip labelFormatter={(value) => `日期 ${value}`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area yAxisId="count" type="monotone" dataKey="tasks" name="生成任务" stroke="var(--admin-chart-primary)" fill="var(--admin-chart-primary)" fillOpacity={0.1} />
<Area yAxisId="count" type="monotone" dataKey="requests" name="上游请求" stroke="var(--admin-chart-secondary)" fill="var(--admin-chart-secondary)" fillOpacity={0.08} />
<Line yAxisId="rate" type="monotone" dataKey="requestSuccessRate" name="成功率" stroke="var(--admin-chart-warning)" dot={false} strokeWidth={2} />
</ComposedChart>
</ResponsiveContainer>
</div>
) : (
<div className="admin-analytics-empty-chart">
<span>
<BarChart3 className="size-5" />
</span>
<div className="font-medium">当前范围暂无使用数据</div>
<p>可以调整时间范围或筛选条件后重新查看。</p>
</div>
)}
</section>
<Tabs
@@ -346,13 +419,38 @@ export default function AnalyticsPanel({ users, channels }: Props) {
{
key: "models",
label: "模型分析",
children: <AdminDataTable table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }} empty={<AdminTableEmpty />} skeletonColumns={9} footer={<PaginationBar alwaysShow current={modelPage} pageSize={analyticsPageSize} total={modelRows.length} onChange={(page) => setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />} />,
children: (
<AdminDataTable
table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }}
empty={<AdminTableEmpty />}
skeletonColumns={9}
footer={<PaginationBar alwaysShow current={modelPage} pageSize={analyticsPageSize} total={modelRows.length} onChange={(page) => setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
),
},
{
key: "users",
label: "用户活动",
children: (
<AdminDataTable
table={{ rowKey: "userId", size: "small", loading, columns: userColumns, dataSource: pageRows(userRows, userPage), pagination: false, scroll: { x: 900 } }}
empty={<AdminTableEmpty />}
skeletonColumns={7}
footer={<PaginationBar alwaysShow current={userPage} pageSize={analyticsPageSize} total={userRows.length} onChange={(page) => setUserPage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
),
},
{ key: "users", label: "用户活动", children: <AdminDataTable table={{ rowKey: "userId", size: "small", loading, columns: userColumns, dataSource: pageRows(userRows, userPage), pagination: false, scroll: { x: 900 } }} empty={<AdminTableEmpty />} skeletonColumns={7} footer={<PaginationBar alwaysShow current={userPage} pageSize={analyticsPageSize} total={userRows.length} onChange={(page) => setUserPage(page)} pageSizeOptions={[analyticsPageSize]} />} /> },
{
key: "failures",
label: `异常定位${data?.failures.length ? ` (${data.failures.reduce((sum, item) => sum + item.count, 0)})` : ""}`,
children: <AdminDataTable table={{ rowKey: (row) => `${row.type}:${row.model}`, size: "small", loading, columns: failureColumns, dataSource: pageRows(failureRows, failurePage), pagination: false, scroll: { x: 900 } }} empty={<AdminTableEmpty />} skeletonColumns={5} footer={<PaginationBar alwaysShow current={failurePage} pageSize={analyticsPageSize} total={failureRows.length} onChange={(page) => setFailurePage(page)} pageSizeOptions={[analyticsPageSize]} />} />,
children: (
<AdminDataTable
table={{ rowKey: (row) => `${row.type}:${row.model}`, size: "small", loading, columns: failureColumns, dataSource: pageRows(failureRows, failurePage), pagination: false, scroll: { x: 900 } }}
empty={<AdminTableEmpty />}
skeletonColumns={5}
footer={<PaginationBar alwaysShow current={failurePage} pageSize={analyticsPageSize} total={failureRows.length} onChange={(page) => setFailurePage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
),
},
{
key: "pricing",
@@ -364,7 +462,12 @@ export default function AnalyticsPanel({ users, channels }: Props) {
新增价格
</Button>
</div>
<AdminDataTable table={{ rowKey: "id", size: "small", loading, columns: pricingColumns, dataSource: pageRows(pricingRows, pricingPage), pagination: false, scroll: { x: 980 } }} empty={<AdminTableEmpty />} skeletonColumns={5} footer={<PaginationBar alwaysShow current={pricingPage} pageSize={analyticsPageSize} total={pricingRows.length} onChange={(page) => setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />} />
<AdminDataTable
table={{ rowKey: "id", size: "small", loading, columns: pricingColumns, dataSource: pageRows(pricingRows, pricingPage), pagination: false, scroll: { x: 980 } }}
empty={<AdminTableEmpty />}
skeletonColumns={5}
footer={<PaginationBar alwaysShow current={pricingPage} pageSize={analyticsPageSize} total={pricingRows.length} onChange={(page) => setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
</div>
),
},
@@ -399,7 +502,25 @@ export default function AnalyticsPanel({ users, channels }: Props) {
);
}
function FilterSelect({ label, value, onChange, options, width = 150, filterOption = true, loading, onSearch }: { label: string; value?: string; onChange: (value?: string) => void; options: Array<{ label: string; value: string }>; width?: number; filterOption?: boolean; loading?: boolean; onSearch?: (value: string) => void }) {
function FilterSelect({
label,
value,
onChange,
options,
width = 150,
filterOption = true,
loading,
onSearch,
}: {
label: string;
value?: string;
onChange: (value?: string) => void;
options: Array<{ label: string; value: string }>;
width?: number;
filterOption?: boolean;
loading?: boolean;
onSearch?: (value: string) => void;
}) {
return (
<div>
<div className="mb-1 text-xs text-foreground/55">{label}</div>
@@ -438,14 +559,14 @@ function formatCountDelta(current?: number, previous?: number) {
const delta = current - previous;
const direction = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
const value = previous === 0 ? formatNumber(Math.abs(delta)) : `${Math.abs((delta / previous) * 100).toFixed(1)}%`;
return { value: `${direction} ${value}`, tone: delta >= 0 ? "success" as const : "warning" as const };
return { value: `${direction} ${value}`, tone: delta >= 0 ? ("success" as const) : ("warning" as const) };
}
function formatRateDelta(current?: number, previous?: number) {
if (current === undefined || previous === undefined) return undefined;
const delta = current - previous;
const direction = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
return { value: `${direction} ${Math.abs(delta).toFixed(1)}pp`, tone: delta >= 0 ? "success" as const : "warning" as const };
return { value: `${direction} ${Math.abs(delta).toFixed(1)}pp`, tone: delta >= 0 ? ("success" as const) : ("warning" as const) };
}
function formatCost(micros: number, currency?: string, available?: boolean) {