style(admin): 管理后台 - 统一工作区与模型编辑器布局
This commit is contained in:
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) {
|
||||
|
||||
Reference in new issue
Block a user