Files
browser/platform_web/pages/PluginsPage.tsx
T
2026-07-11 14:56:10 +08:00

387 lines
17 KiB
TypeScript

import { Boxes, Eye, Filter, PlugZap, Search, Sparkles } from "lucide-react";
import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginStatus, MarketplacePluginFilterRequest, MarketplacePluginResponse, MarketplacePluginStateAction } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { pluginCatalog } from "../contracts/shell";
import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
type StatusFilter = GamePluginStatus | "all";
interface PluginsPageInitialState {
listState?: ListState;
listError?: string;
plugins?: MarketplacePluginResponse[];
selectedId?: string;
detail?: MarketplacePluginResponse | null;
usingFallback?: boolean;
actionResult?: { status: "succeeded" | "failed"; label: string } | null;
}
interface PluginsPageProps extends Partial<PageComponentProps> {
initialState?: PluginsPageInitialState;
}
const statusFilters: Array<{ id: StatusFilter; label: string }> = [
{ id: "all", label: "全部" },
{ id: "installed", label: "已安装" },
{ id: "disabled", label: "停用" },
{ id: "invalid", label: "异常" },
{ id: "updating", label: "更新中" }
];
export function PluginsPage({ initialState }: PluginsPageProps = {}) {
const [listState, setListState] = useState<ListState>(initialState?.listState ?? "loading");
const [listError, setListError] = useState(initialState?.listError ?? "");
const [plugins, setPlugins] = useState<MarketplacePluginResponse[]>(initialState?.plugins ?? []);
const [selectedId, setSelectedId] = useState<string>(initialState?.selectedId ?? "");
const [detail, setDetail] = useState<MarketplacePluginResponse | null>(initialState?.detail ?? null);
const [detailPending, setDetailPending] = useState(false);
const [detailError, setDetailError] = useState("");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [serverType, setServerType] = useState("");
const [capability, setCapability] = useState("");
const [actionPending, setActionPending] = useState<MarketplacePluginStateAction | null>(null);
const [actionResult, setActionResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(initialState?.actionResult ?? null);
const [usingFallback, setUsingFallback] = useState(initialState?.usingFallback ?? false);
const refresh = useCallback(async () => {
setListState("loading");
setListError("");
const filter: MarketplacePluginFilterRequest = {
keyword: keyword.trim() || undefined,
status: statusFilter,
serverType: serverType.trim() || undefined,
capability: capability.trim() || undefined
};
try {
const response = await platformApiClient.listMarketplacePlugins(filter);
setPlugins(response.items);
setSelectedId((current) => (current && response.items.some((plugin) => plugin.id === current) ? current : response.items[0]?.id || ""));
setUsingFallback(false);
setListState("ready");
} catch (error) {
if (import.meta.env.DEV) {
const fallback = fallbackMarketplacePlugins(filter);
setPlugins(fallback);
setSelectedId((current) => (current && fallback.some((plugin) => plugin.id === current) ? current : fallback[0]?.id || ""));
setUsingFallback(true);
setListState("ready");
setListError("");
return;
}
setListState("error");
setListError(error instanceof Error ? error.message : "插件市场加载失败");
setPlugins([]);
setDetail(null);
}
}, [capability, keyword, serverType, statusFilter]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!selectedId) {
setDetail(null);
setDetailError("");
return;
}
const selectedSummary = plugins.find((plugin) => plugin.id === selectedId) ?? null;
if (usingFallback) {
setDetail(selectedSummary);
setDetailError("");
return;
}
setDetailPending(true);
setDetailError("");
platformApiClient
.getMarketplacePlugin(selectedId)
.then((plugin) => setDetail(plugin))
.catch((error: unknown) => {
setDetail(selectedSummary);
setDetailError(error instanceof Error ? error.message : "插件详情加载失败");
})
.finally(() => setDetailPending(false));
}, [plugins, selectedId, usingFallback]);
const serverTypes = useMemo(() => unique(plugins.map((plugin) => plugin.serverType)), [plugins]);
const capabilities = useMemo(() => unique(plugins.flatMap((plugin) => [...plugin.capabilities, ...plugin.bridgeActions])), [plugins]);
const installedCount = plugins.filter((plugin) => plugin.status === "installed").length;
const bridgeActionCount = plugins.reduce((sum, plugin) => sum + plugin.bridgeActions.length, 0);
const invalidCount = plugins.filter((plugin) => plugin.status === "invalid").length;
function updateSelect(event: ChangeEvent<HTMLSelectElement>) {
const { name, value } = event.target;
if (name === "serverType") {
setServerType(value);
} else if (name === "capability") {
setCapability(value);
}
}
async function changeState(action: MarketplacePluginStateAction) {
if (!detail || usingFallback) {
return;
}
setActionPending(action);
setActionResult(null);
try {
const updated = await platformApiClient.setMarketplacePluginState(detail.id, { action });
setDetail(updated);
setPlugins((current) => current.map((plugin) => (plugin.id === updated.id ? updated : plugin)));
setActionResult({ status: "succeeded", label: `${updated.name}${stateActionLabel(action)}` });
} catch (error) {
setActionResult({ status: "failed", label: error instanceof Error ? error.message : "状态更新失败" });
} finally {
setActionPending(null);
}
}
return (
<div className="console-page">
<PageFrame
kicker="扩展"
title="插件市场"
status={usingFallback ? "本地演示数据" : "平台 API"}
metrics={[
{ label: "已安装", value: `${installedCount}`, tone: "success" },
{ label: "桥接动作", value: `${bridgeActionCount}`, tone: "success" },
{ label: "校验失败", value: `${invalidCount}`, tone: invalidCount > 0 ? "warning" : "success" }
]}
/>
<div className="server-toolbar" role="search">
<Search size={16} aria-hidden="true" />
<input type="search" value={keyword} placeholder="搜索插件名称、ID、标签或能力" aria-label="搜索插件" onChange={(event) => setKeyword(event.target.value)} />
{statusFilters.map((filter) => (
<button
key={filter.id}
type="button"
className={cx("segmented-button", statusFilter === filter.id && "segmented-button-active")}
onClick={() => setStatusFilter(filter.id)}
>
{filter.label}
</button>
))}
</div>
<div className="server-toolbar" aria-label="插件筛选器">
<Filter size={16} aria-hidden="true" />
<select name="serverType" value={serverType} onChange={updateSelect} aria-label="服务器类型筛选">
<option value="">全部服务器类型</option>
{serverTypes.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<select name="capability" value={capability} onChange={updateSelect} aria-label="能力筛选">
<option value="">全部能力</option>
{capabilities.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<button type="button" className="icon-command" title="刷新插件市场" onClick={() => void refresh()}>
<Sparkles size={16} />
<span>刷新</span>
</button>
</div>
{usingFallback && <ResultBadge status="pending" label="本地演示数据仅用于前端开发,连接平台 API 后会自动替换" />}
{actionResult && <ResultBadge status={actionResult.status} label={actionResult.label} />}
{listState === "loading" && <LoadingState label="正在加载插件市场…" />}
{listState === "error" && <ErrorState title="插件市场加载失败" reason={listError} diagnosticId="plugin-marketplace" onRetry={() => void refresh()} />}
{listState === "ready" && plugins.length === 0 && (
<EmptyState icon={<Boxes size={26} />} title="暂无匹配插件" description="调整搜索、状态、服务器类型或能力筛选后再试。" actionLabel="清除筛选" onAction={() => {
setKeyword("");
setStatusFilter("all");
setServerType("");
setCapability("");
}} />
)}
{plugins.length > 0 && (
<section className="catalog-grid" aria-label="marketplace plugins">
{plugins.map((plugin) => (
<article key={plugin.id} className={cx("catalog-card", selectedId === plugin.id && "catalog-card-active")}>
<div className="panel-header">
<div>
<h2>{plugin.name}</h2>
<span className="provider-id">{plugin.id}</span>
</div>
<span className={cx("status-pill", statusClass(plugin.status))}>{statusLabel(plugin.status)}</span>
</div>
<p>{plugin.description || plugin.serverDisplayName || plugin.serverType}</p>
<dl className="detail-list">
<div>
<dt>版本</dt>
<dd>{plugin.version}</dd>
</div>
<div>
<dt>服务器类型</dt>
<dd>{plugin.serverType}</dd>
</div>
<div>
<dt>能力</dt>
<dd>{plugin.capabilities.slice(0, 3).join(", ") || "--"}</dd>
</div>
<div>
<dt>桥接</dt>
<dd>{plugin.bridgeActions.slice(0, 3).join(", ") || "--"}</dd>
</div>
</dl>
<button type="button" className="primary-command" onClick={() => setSelectedId(plugin.id)} title="查看插件详情">
<Eye size={16} />
<span>查看详情</span>
</button>
</article>
))}
</section>
)}
{selectedId && (
<section className="drawer-panel plugin-detail-panel" aria-label="plugin marketplace detail">
{detailPending && <LoadingState label="正在加载插件详情…" compact />}
{detailError && <ErrorState title="插件详情加载失败" reason={detailError} diagnosticId={`plugin-detail:${selectedId}`} compact />}
{detail && <PluginDetail plugin={detail} actionPending={actionPending} actionsDisabled={usingFallback} onAction={(action) => void changeState(action)} />}
</section>
)}
</div>
);
}
interface PluginDetailProps {
plugin: MarketplacePluginResponse;
actionPending: MarketplacePluginStateAction | null;
actionsDisabled: boolean;
onAction: (action: MarketplacePluginStateAction) => void;
}
function PluginDetail({ plugin, actionPending, actionsDisabled, onAction }: PluginDetailProps) {
return (
<div className="plugin-group">
<div className="panel-header">
<div>
<h2>{plugin.name}</h2>
<span className="provider-id">{plugin.manifestRef}</span>
</div>
<span className={cx("status-pill", statusClass(plugin.status))}>{statusLabel(plugin.status)}</span>
</div>
<div className="server-card-stats">
<DetailStat label="服务器类型" value={plugin.serverDisplayName || plugin.serverType} />
<DetailStat label="页面" value={`${plugin.pages.length}`} />
<DetailStat label="权限" value={`${plugin.declaredPermissions.length}`} />
<DetailStat label="AI 用途" value={plugin.aiPurposes.length ? plugin.aiPurposes.join(", ") : "--"} />
</div>
<dl className="detail-list">
<div>
<dt>能力</dt>
<dd>{plugin.capabilities.join(", ") || "--"}</dd>
</div>
<div>
<dt>桥接动作</dt>
<dd>{plugin.bridgeActions.join(", ") || "--"}</dd>
</div>
<div>
<dt>插件页面</dt>
<dd>{plugin.pages.map((page) => `${page.title}(${page.key})`).join(", ") || "--"}</dd>
</div>
<div>
<dt>校验</dt>
<dd>{plugin.validationViolations?.length ? plugin.validationViolations.join(", ") : "manifest validated"}</dd>
</div>
</dl>
<div className="action-strip plugin-detail-actions">
<button type="button" className="primary-command" disabled={actionsDisabled || actionPending !== null || plugin.status === "installed"} onClick={() => onAction("install")} title="安装插件状态">
<PlugZap size={16} />
<span>{actionPending === "install" ? "安装中…" : "安装"}</span>
</button>
<button type="button" className="primary-command" disabled={actionsDisabled || actionPending !== null || plugin.status === "installed"} onClick={() => onAction("enable")} title="启用插件">
<Sparkles size={16} />
<span>{actionPending === "enable" ? "启用中…" : "启用"}</span>
</button>
<button type="button" className="segmented-button" disabled={actionsDisabled || actionPending !== null || plugin.status === "disabled"} onClick={() => onAction("disable")} title="停用插件">
停用
</button>
</div>
</div>
);
}
function DetailStat({ label, value }: { label: string; value: string }) {
return (
<span className="server-card-stat">
<span>{label}</span>
<strong>{value}</strong>
</span>
);
}
function fallbackMarketplacePlugins(filter: MarketplacePluginFilterRequest): MarketplacePluginResponse[] {
const keyword = filter.keyword?.trim().toLowerCase() ?? "";
return pluginCatalog
.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.validation,
version: plugin.version,
serverType: plugin.serverType,
serverDisplayName: plugin.serverType,
supportedOs: ["linux"],
manifestRef: `manifest://${plugin.id}/${plugin.version}`,
createFormSchemaRef: "schemas/create-form.schema.json",
capabilities: ["process.install", "process.start", "logs.read"],
declaredPermissions: plugin.permissions,
permissions: {
ai: plugin.permissions.includes("ai.invoke"),
logs: plugin.permissions.includes("server.logs.read"),
files: plugin.permissions.some((permission) => permission.includes("files")),
jobs: plugin.permissions.includes("server.lifecycle"),
artifacts: plugin.permissions.some((permission) => permission.includes("artifacts"))
},
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
bridgeActions: plugin.bridgeActions,
pages: [{ key: "overview", title: "Overview", path: "/overview", permissions: plugin.permissions, bridgeActions: plugin.bridgeActions }],
tags: [plugin.serverType],
aiPurposes: plugin.permissions.includes("ai.invoke") ? ["logs.diagnose"] : [],
validationViolations: plugin.status === "invalid" ? [plugin.validation] : undefined,
status: plugin.status,
source: "local-development"
}))
.filter((plugin) => !filter.status || filter.status === "all" || plugin.status === filter.status)
.filter((plugin) => !filter.serverType || plugin.serverType === filter.serverType)
.filter((plugin) => !filter.capability || plugin.capabilities.includes(filter.capability) || plugin.bridgeActions.includes(filter.capability))
.filter((plugin) => !keyword || [plugin.id, plugin.name, plugin.serverType, ...plugin.tags, ...plugin.capabilities].some((value) => value.toLowerCase().includes(keyword)));
}
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean))).sort((left, right) => left.localeCompare(right));
}
function statusClass(status: GamePluginStatus): string {
if (status === "installed") {
return "status-active";
}
if (status === "invalid") {
return "status-error";
}
return "status-disabled";
}
function statusLabel(status: string): string {
return status === "installed" ? "已安装" : status === "disabled" ? "停用" : status === "invalid" ? "异常" : status === "updating" ? "更新中" : status;
}
function stateActionLabel(action: MarketplacePluginStateAction): string {
return action === "disable" ? "停用" : "启用";
}