feat(admin): 管理后台 - 统一桌面端信息架构与视觉规范

This commit is contained in:
HANG LU
2026-08-30 05:05:46 +08:00
parent d89f170a08
commit 10cebf8c69
32 changed files with 17070 additions and 1860 deletions

View File

@@ -1,7 +1,9 @@
import { App, Button, Input, Select, Switch, Tag } from "antd";
import { CloudUpload, PlugZap, RefreshCw, Search, ShieldCheck, Trash2, UsersRound } from "lucide-react";
import { App, Button, Input, Select, Switch } from "antd";
import type { ColumnsType } from "antd/es/table";
import { CloudUpload, PlugZap, RefreshCw, Search, Trash2, UsersRound } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { PaginationBar } from "@/components/layout/workspace-page";
import "@/lib/plugins/builtin";
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
@@ -12,6 +14,7 @@ import { fetchAdminPlugins, setPluginPlatformAvailability, uninstallPlugin, uplo
import { UploadPluginModal } from "@/pages/plugins/plugin-documentation-modals";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminDataTable, AdminStatusBadge, AdminTableEmpty } from "../components/admin-ui";
type AdminPluginItem = {
manifest: PluginManifest;
@@ -32,6 +35,9 @@ export default function AdminPluginsPage() {
const [uploadOpen, setUploadOpen] = useState(false);
const [search, setSearch] = useState("");
const [kind, setKind] = useState<"all" | "application" | "protocol" | "uploaded">("all");
const [availability, setAvailability] = useState<"all" | "available" | "unavailable">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const reload = async () => {
setLoading(true);
@@ -56,10 +62,17 @@ export default function AdminPluginsPage() {
return items.filter((item) => {
if (kind === "uploaded" && item.management.origin !== "uploaded") return false;
if (kind !== "all" && kind !== "uploaded" && item.management.kind !== kind) return false;
const available = states[item.manifest.id]?.platformAvailable ?? item.status === "enabled";
if (availability === "available" && !available) return false;
if (availability === "unavailable" && available) return false;
if (!keyword) return true;
return [item.manifest.name, item.manifest.id, item.manifest.description, item.manifest.author].filter(Boolean).join(" ").toLocaleLowerCase().includes(keyword);
});
}, [items, kind, search]);
}, [availability, items, kind, search, states]);
useEffect(() => {
setPage((current) => Math.min(current, Math.max(1, Math.ceil(filtered.length / pageSize))));
}, [filtered.length, pageSize]);
const changeAvailability = async (item: AdminPluginItem, available: boolean) => {
setSavingId(item.manifest.id);
@@ -107,13 +120,100 @@ export default function AdminPluginsPage() {
const applicationCount = items.filter((item) => item.management.kind === "application").length;
const protocolCount = items.filter((item) => item.management.kind === "protocol").length;
const unavailableCount = items.filter((item) => states[item.manifest.id] && !states[item.manifest.id].platformAvailable).length;
const unavailableCount = items.filter((item) => !(states[item.manifest.id]?.platformAvailable ?? item.status === "enabled")).length;
const hasFilters = Boolean(search.trim() || kind !== "all" || availability !== "all");
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
const columns: ColumnsType<AdminPluginItem> = [
{
title: "插件",
key: "plugin",
width: 410,
render: (_, item) => (
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground/65">
<PlugZap className="size-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<div className="flex min-w-0 items-baseline gap-2">
<span className="truncate font-medium text-foreground">{item.manifest.name}</span>
<span className="shrink-0 text-xs text-foreground/42">v{item.manifest.version}</span>
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-xs">
<span className="max-w-44 shrink-0 truncate font-mono text-[11px] text-foreground/42" title={item.manifest.id}>{item.manifest.id}</span>
<span className="text-foreground/20" aria-hidden="true">·</span>
<span className={`truncate ${item.error ? "text-status-error" : "text-foreground/52"}`} title={item.error || item.manifest.description || "未提供插件说明"}>{item.error || item.manifest.description || "未提供插件说明"}</span>
</div>
</div>
</div>
),
},
{
title: "类型",
key: "type",
width: 160,
align: "center",
render: (_, item) => (
<div>
<AdminStatusBadge label={managementLabel(item.management)} tone={item.management.origin === "uploaded" ? "warning" : item.management.kind === "application" ? "info" : "neutral"} />
{!item.manifest.trusted ? <div className="mt-1.5"><AdminStatusBadge label="来源未验证" tone="warning" /></div> : null}
</div>
),
},
{
title: "作用范围",
key: "scope",
width: 180,
align: "center",
render: (_, item) => item.management.activationScope === "user" ? (
<div>
<div className="font-medium text-foreground/78">用户自主启用</div>
<div className="mt-1 inline-flex items-center gap-1 text-xs text-foreground/48">
<UsersRound className="size-3.5" aria-hidden="true" />
{states[item.manifest.id]?.enabledUserCount || 0} 位已启用
</div>
</div>
) : (
<div>
<div className="font-medium text-foreground/78">全局生效</div>
<div className="mt-1 text-xs text-foreground/48">管理员统一控制</div>
</div>
),
},
{
title: "平台状态",
key: "status",
width: 170,
align: "center",
render: (_, item) => {
const available = states[item.manifest.id]?.platformAvailable ?? item.status === "enabled";
return (
<div className="flex items-center justify-center gap-3">
<AdminStatusBadge label={available ? "已开放" : "已停用"} tone={available ? "success" : "neutral"} />
<Switch className="plugin-state-switch" loading={savingId === item.manifest.id} checked={available} aria-label={`${item.manifest.name},当前${available ? "平台开放,点击停用" : "平台停用,点击开放"}`} onChange={(checked) => void changeAvailability(item, checked)} />
</div>
);
},
},
{
title: "卸载",
key: "actions",
width: 72,
align: "center",
render: (_, item) => item.management.origin === "uploaded" ? (
<Button danger type="text" size="small" aria-label={`卸载 ${item.manifest.name}`} icon={<Trash2 className="size-3.5" aria-hidden="true" />} onClick={() => remove(item)} />
) : (
<span title="内置插件不可卸载">
<Button type="text" size="small" disabled aria-label={`${item.manifest.name}为内置插件,不可卸载`} icon={<Trash2 className="size-3.5" aria-hidden="true" />} />
</span>
),
},
];
return (
<AdminPageFrame
title="插件管理"
description="管理平台级可用性、自定义插件安装与用户启用范围"
scroll
actions={
<>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
@@ -125,96 +225,32 @@ export default function AdminPluginsPage() {
</>
}
>
<div className="grid gap-3 py-4 sm:grid-cols-3">
<SummaryCard label="官方应用插件" value={applicationCount} detail="用户可自行启用" />
<SummaryCard label="协议与自定义插件" value={protocolCount} detail="管理员统一控制" />
<SummaryCard label="平台已停用" value={unavailableCount} detail="所有用户均不可使用" />
<div className="my-4 grid min-h-16 grid-cols-4 divide-x divide-border/70 overflow-hidden rounded-lg border border-border/70 bg-card">
<OverviewItem label="全部插件" value={items.length} />
<OverviewItem label="官方应用" value={applicationCount} />
<OverviewItem label="协议与自定义" value={protocolCount} />
<OverviewItem label="平台已停用" value={unavailableCount} tone={unavailableCount ? "warning" : "default"} />
</div>
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-border/70 bg-card/60 p-3">
<Input className="min-w-[240px] flex-1" allowClear prefix={<Search className="size-4 text-foreground/40" />} value={search} placeholder="搜索插件名称、ID 或作者" onChange={(event) => setSearch(event.target.value)} />
<Select
className="w-44"
value={kind}
onChange={setKind}
options={[
{ value: "all", label: "全部类型" },
{ value: "application", label: "官方应用插件" },
{ value: "protocol", label: "系统协议插件" },
{ value: "uploaded", label: "上传的自定义插件" },
]}
/>
</div>
<div className="grid gap-3 pb-6 xl:grid-cols-2">
{filtered.map((item) => {
const state = states[item.manifest.id];
const available = state?.platformAvailable ?? item.status === "enabled";
return (
<section key={item.manifest.id} className="rounded-xl border border-border/70 bg-card p-4 shadow-sm">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-muted text-foreground/65">
<PlugZap className="size-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-semibold">{item.manifest.name}</h2>
<span className="text-xs text-foreground/42">v{item.manifest.version}</span>
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
<Tag color={item.management.origin === "uploaded" ? "orange" : item.management.kind === "application" ? "blue" : "default"}>{managementLabel(item.management)}</Tag>
{item.manifest.trusted ? (
<Tag color="green" icon={<ShieldCheck className="mr-1 inline size-3" />}>
可信
</Tag>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={`whitespace-nowrap text-xs font-medium ${available ? "text-status-success" : "text-foreground/52"}`}>
{available ? "平台开放" : "平台停用"}
</span>
<Switch className="plugin-state-switch" loading={savingId === item.manifest.id} checked={available} aria-label={`${item.manifest.name},当前${available ? "平台开放,点击停用" : "平台停用,点击开放"}`} onChange={(checked) => void changeAvailability(item, checked)} />
</div>
</div>
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-foreground/58">{item.manifest.description || "未提供插件说明"}</p>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3 text-xs text-foreground/48">
<span className="truncate" title={item.manifest.id}>
{item.manifest.id}
</span>
<div className="flex items-center gap-2">
{item.management.activationScope === "user" ? (
<span className="inline-flex items-center gap-1">
<UsersRound className="size-3.5" />
{state?.enabledUserCount || 0} 位用户启用
</span>
) : (
<span>全局生效</span>
)}
{item.management.origin === "uploaded" ? (
<Button danger type="text" size="small" icon={<Trash2 className="size-3.5" />} onClick={() => remove(item)}>
卸载
</Button>
) : null}
</div>
</div>
{item.error ? <p className="mt-2 text-xs text-red-500">{item.error}</p> : null}
</section>
);
})}
</div>
{!loading && filtered.length === 0 ? <div className="py-16 text-center text-sm text-foreground/45">没有匹配的插件</div> : null}
<AdminDataTable
toolbar={<Input className="app-list-search" allowClear prefix={<Search className="size-4 text-foreground/40" />} value={search} aria-label="搜索插件" placeholder="搜索插件名称、ID 或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />}
toolbarFilters={<><Select aria-label="筛选插件类型" className="w-44" value={kind} onChange={(value) => { setKind(value); setPage(1); }} options={[{ value: "all", label: "全部类型" }, { value: "application", label: "官方应用插件" }, { value: "protocol", label: "系统协议插件" }, { value: "uploaded", label: "上传的自定义插件" }]} /><Select aria-label="筛选插件状态" className="w-32" value={availability} onChange={(value) => { setAvailability(value); setPage(1); }} options={[{ value: "all", label: "全部状态" }, { value: "available", label: "平台开放" }, { value: "unavailable", label: "平台停用" }]} /></>}
toolbarActive={hasFilters}
onReset={() => { setSearch(""); setKind("all"); setAvailability("all"); setPage(1); }}
skeletonColumns={5}
table={{ className: "app-data-table", size: "small", sticky: true, rowKey: (item) => item.manifest.id, loading, columns, dataSource: paginated, pagination: false, scroll: { x: 1120 } }}
empty={<AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有可管理的插件"} />}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={filtered.length} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
/>
<UploadPluginModal open={uploadOpen} onClose={() => setUploadOpen(false)} onUpload={(file) => void upload(file)} />
</AdminPageFrame>
);
}
function SummaryCard({ label, value, detail }: { label: string; value: number; detail: string }) {
function OverviewItem({ label, value, tone = "default" }: { label: string; value: number; tone?: "default" | "warning" }) {
return (
<div className="rounded-lg border border-border/70 bg-card p-4">
<div className="text-xs text-foreground/45">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">{value}</div>
<div className="mt-1 text-xs text-foreground/40">{detail}</div>
<div className="flex min-w-0 items-center gap-3 px-4 py-3">
<div className={`text-xl font-semibold tabular-nums ${tone === "warning" ? "text-status-warning" : "text-foreground"}`}>{value}</div>
<div className="truncate text-xs text-foreground/52">{label}</div>
</div>
);
}
@@ -246,5 +282,5 @@ function managementOrder(value: PluginManagement) {
function managementLabel(value: PluginManagement) {
if (value.origin === "uploaded") return "自定义插件";
return value.kind === "application" ? "官方插件" : "系统插件";
return value.kind === "application" ? "官方应用" : "系统协议";
}