415 lines
18 KiB
TypeScript
415 lines
18 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 { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
|
||
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
|
||
import { RuntimeDLLExtensionsPanel } from "../components/RuntimeDLLExtensionsPanel";
|
||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||
import { PageFrame } from "../components/PageFrame";
|
||
import type { PageComponentProps } from "../contracts/page";
|
||
import { isPlatformAdmin } from "../contracts/workspace";
|
||
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, session, operations }: 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 [confirmAction, setConfirmAction] = 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
|
||
};
|
||
if (initialState?.plugins) {
|
||
setListState(initialState.listState ?? "ready");
|
||
setListError(initialState.listError ?? "");
|
||
setPlugins(initialState.plugins);
|
||
setSelectedId(initialState.selectedId ?? "");
|
||
setDetail(initialState.detail ?? null);
|
||
setUsingFallback(initialState.usingFallback ?? false);
|
||
return;
|
||
}
|
||
try {
|
||
const response = await platformApiClient.listMarketplacePlugins(filter);
|
||
setPlugins(response.items);
|
||
setSelectedId((current) => (current && response.items.some((plugin) => plugin.id === current) ? current : ""));
|
||
setUsingFallback(false);
|
||
setListState("ready");
|
||
} catch (error) {
|
||
setListState("error");
|
||
setListError(error instanceof Error ? error.message : "插件市场加载失败");
|
||
setPlugins([]);
|
||
setDetail(null);
|
||
}
|
||
}, [capability, initialState, 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 retryDetail = useCallback(async () => {
|
||
if (!selectedId || usingFallback) {
|
||
return;
|
||
}
|
||
setDetailPending(true);
|
||
setDetailError("");
|
||
try {
|
||
const plugin = await platformApiClient.getMarketplacePlugin(selectedId);
|
||
setDetail(plugin);
|
||
} catch (error) {
|
||
setDetailError(error instanceof Error ? error.message : "插件详情加载失败");
|
||
} finally {
|
||
setDetailPending(false);
|
||
}
|
||
}, [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;
|
||
}
|
||
const intent = `${stateActionLabel(action)}插件`;
|
||
const operationId = operations?.begin({ intent, targetKind: "plugin", targetId: detail.id, requester: session?.displayName });
|
||
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)}` });
|
||
if (operationId) operations?.succeed(operationId, `${updated.name} 已${stateActionLabel(action)}`);
|
||
setConfirmAction(null);
|
||
} catch (error) {
|
||
const reason = error instanceof Error ? error.message : "状态更新失败";
|
||
setActionResult({ status: "failed", label: reason });
|
||
if (operationId) operations?.fail(operationId, reason, operationId);
|
||
} finally {
|
||
setActionPending(null);
|
||
}
|
||
}
|
||
|
||
function requestStateChange(action: MarketplacePluginStateAction) {
|
||
if (!canManage || usingFallback || actionPending !== null) {
|
||
return;
|
||
}
|
||
setActionResult(null);
|
||
setConfirmAction(action);
|
||
}
|
||
|
||
const canManage = Boolean(session && isPlatformAdmin(session));
|
||
|
||
return (
|
||
<div className="console-page">
|
||
<PageFrame
|
||
kicker="扩展"
|
||
title="插件市场"
|
||
status={usingFallback ? "本地演示数据" : listState === "loading" ? "正在连接平台 API" : listState === "error" ? "平台 API 不可用" : "平台 API"}
|
||
metrics={[
|
||
{ label: "已安装", value: listState === "ready" ? `${installedCount}` : "--", tone: "success" },
|
||
{ label: "桥接动作", value: listState === "ready" ? `${bridgeActionCount}` : "--", tone: "success" },
|
||
{ label: "校验失败", value: listState === "ready" ? `${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 后会自动替换" />}
|
||
{!canManage && !usingFallback && <ResultBadge status="pending" label="当前账号为只读模式,插件状态动作需要平台管理员权限" />}
|
||
{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>
|
||
)}
|
||
|
||
<ManagementDialog open={selectedId !== ""} title={detail?.name ?? "插件详情"} wide onClose={() => { if (actionPending === null) { setSelectedId(""); setDetail(null); setDetailError(""); setConfirmAction(null); } }}>
|
||
<div className="plugin-detail-panel" aria-label="plugin marketplace detail">
|
||
{detailPending && <LoadingState label="正在加载插件详情…" compact />}
|
||
{detailError && <ErrorState title="插件详情加载失败" reason={detailError} diagnosticId={`plugin-detail:${selectedId}`} onRetry={() => void retryDetail()} compact />}
|
||
{detail && <PluginDetail plugin={detail} actionPending={actionPending} actionsDisabled={usingFallback || !canManage} onAction={requestStateChange} />}
|
||
</div>
|
||
</ManagementDialog>
|
||
<ConfirmDialog
|
||
open={confirmAction !== null}
|
||
title="确认插件状态变更"
|
||
description={detail ? pluginStateChangeDescription(detail, confirmAction ?? "disable") : "请确认插件状态变更。"}
|
||
confirmLabel={stateActionLabel(confirmAction ?? "disable")}
|
||
danger={confirmAction === "disable"}
|
||
busy={actionPending !== null}
|
||
onCancel={() => { if (actionPending === null) setConfirmAction(null); }}
|
||
onConfirm={() => { if (confirmAction) void changeState(confirmAction); }}
|
||
/>
|
||
</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>
|
||
<PluginLifecycleWorkbench pluginId={plugin.id} pluginName={plugin.name} operations={plugin.productionLifecycle?.operations} disabled={actionsDisabled} />
|
||
<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>
|
||
<RuntimeDLLExtensionsPanel runtimeProfiles={plugin.runtimeProfiles} embedded />
|
||
<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 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 {
|
||
if (action === "install") {
|
||
return "安装";
|
||
}
|
||
return action === "disable" ? "停用" : "启用";
|
||
}
|
||
|
||
function pluginStateChangeDescription(plugin: MarketplacePluginResponse, action: MarketplacePluginStateAction): string {
|
||
const description = `将对 ${plugin.name} 执行“${stateActionLabel(action)}”。平台会返回持久状态,失败时保留当前状态并允许重试。`;
|
||
if (action !== "install" && action !== "enable") {
|
||
return description;
|
||
}
|
||
const extensions = plugin.runtimeProfiles?.dllExtensions ?? [];
|
||
if (extensions.length === 0) {
|
||
return description;
|
||
}
|
||
const releases = extensions.map((extension) => [extension.releaseHost, extension.releaseFilename].filter(Boolean).join(" / ") || extension.displayName).join(";");
|
||
return `${description} UE4SS DLL 声明:Windows amd64 的 SCUM 启动前会校验并按固定发布下载或更新 ${releases};Run 不直接执行 DLL,Linux 会拒绝启动。`;
|
||
}
|