Files

415 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 会拒绝启动。`;
}