Files
browser/platform_web/pages/PluginPageHostPage.tsx
T

162 lines
9.6 KiB
TypeScript

import { ArrowLeft, PlugZap } from "lucide-react";
import * as React from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types";
import { PageFrame } from "../components/PageFrame";
import { SCUMPersistedDataView, scumDatasetForPluginRoute } from "../components/SCUMPersistedDataView";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
type PluginPageState =
| { status: "loading" }
| { status: "error"; reason: string }
| { status: "ready"; plugin: GamePluginResponse };
interface PluginPageHostPageProps extends PageComponentProps {
initialPlugin?: GamePluginResponse;
embedded?: boolean;
}
export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded = false }: PluginPageHostPageProps) {
const pluginId = params.pluginId ?? "";
const routeKey = params.routeKey ?? "";
const serverId = params.serverId ?? "";
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null);
const [bundleError, setBundleError] = useState("");
const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证 Companion 可用性。" });
const readyPluginRef = useRef<GamePluginResponse | undefined>(undefined);
const hostContextRef = useRef<ReturnType<typeof createPluginBridgeHostContext> | undefined>(undefined);
const load = useCallback(async () => {
if (!pluginId || !routeKey) {
setState({ status: "error", reason: "插件页面路由缺少 pluginId 或 routeKey。" });
return;
}
setState({ status: "loading" });
try {
const response = await platformApiClient.listGamePlugins();
const plugin = response.items.find((candidate) => candidate.id === pluginId);
setState(plugin ? { status: "ready", plugin } : { status: "error", reason: "未找到已注册的插件声明。" });
} catch (error) {
setState({ status: "error", reason: error instanceof Error ? error.message : "插件页面声明加载失败。" });
}
}, [pluginId, routeKey]);
useEffect(() => {
if (!initialPlugin) {
void load();
}
}, [initialPlugin, load]);
const readyPlugin = state.status === "ready" ? state.plugin : undefined;
const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey);
const manifestContract = useMemo(() => (readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin) : undefined), [readyPlugin]);
const declaredBundlePage = manifestContract?.pages.find((candidate) => candidate.key === routeKey);
const hostContext = useMemo(() => manifestContract ? createPluginBridgeHostContext({
plugin: manifestContract,
routeKey,
serverInstanceId: serverId || undefined,
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
}) : undefined, [manifestContract, routeKey, serverId]);
readyPluginRef.current = readyPlugin;
hostContextRef.current = hostContext;
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
if (!pluginId) return undefined;
return {
listSCUMUsers: () => platformApiClient.listSCUMUsers(serverId),
listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId),
listSCUMSquads: () => platformApiClient.listSCUMSquads(serverId),
listSCUMActivity: () => platformApiClient.listSCUMActivity(serverId),
listSCUMGifts: () => platformApiClient.listSCUMGifts(serverId),
listSCUMMapPoints: () => platformApiClient.listSCUMMapPoints(serverId),
listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId),
listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId),
listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId),
listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId),
listSCUMOperations: () => platformApiClient.listSCUMOperations(serverId),
createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never),
approveSCUMOperation: (operationId) => platformApiClient.approveSCUMOperation(serverId, operationId),
listSCUMWorkflows: () => platformApiClient.listSCUMWorkflows(serverId),
createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never),
listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)
};
}, [pluginId, serverId]);
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
const loadableBundlePage = useMemo(() => declaredBundlePage, [bundleLoadKey]);
useEffect(() => {
let active = true;
if (!loadableBundlePage) return () => { active = false; };
globalThis.__PLUGIN_PAGE_REACT__ = React;
setBundle(null); setBundleError("");
void loadPluginPageBundle(loadableBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); });
if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; }
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => {
if (active) setAvailability({ available: status.available, reason: status.reason, features: status.features });
}).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
return () => { active = false; };
}, [bundleLoadKey, loadableBundlePage, serverId]);
if (state.status === "loading") {
return <LoadingState label="正在加载插件页面声明…" />;
}
if (state.status === "error") {
return <ErrorState title="插件页面不可用" reason={state.reason} onRetry={() => void load()} />;
}
const page = declaredPage;
if (!page) {
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
}
if (!hostContext) {
return <ErrorState title="插件页面不可用" reason="插件页面上下文初始化失败。" />;
}
const scumDataset = state.plugin.serverType === "scum" ? scumDatasetForPluginRoute(routeKey) : null;
if (embedded) {
return (
<>
{scumDataset && <SCUMPersistedDataView serverInstanceId={serverId} dataset={scumDataset} />}
{!scumDataset && bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!scumDataset && !bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
{!scumDataset && bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
</>
);
}
return (
<div className="console-page">
<PageFrame
kicker={`${state.plugin.name} / PLUGIN PAGE`}
title={page.title}
status={serverId ? `服务器 ${serverId}` : "未绑定服务器"}
metrics={[
{ label: "有效权限", value: String(hostContext.permissions.length), tone: hostContext.permissions.length > 0 ? "success" : "warning" },
{ label: "桥接动作", value: String(hostContext.bridgeActions.length), tone: hostContext.bridgeActions.length > 0 ? "success" : "warning" },
{ label: "页面路由", value: routeKey, tone: "neutral" }
]}
/>
<section className="console-panel" aria-label="plugin page host context">
<div className="panel-header">
<h2><PlugZap size={16} aria-hidden="true" /> 平台托管上下文</h2>
<button type="button" className="icon-command" onClick={() => onNavigate(serverId ? "serverDetail" : "plugins", serverId ? { serverId } : {})}>
<ArrowLeft size={14} aria-hidden="true" />
<span>{serverId ? "返回服务器" : "返回插件市场"}</span>
</button>
</div>
<div className="action-list" aria-label="plugin page declarations"><span><strong>Bundle</strong> {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}</span><span><strong>完整性</strong> {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}</span><span><strong>Companion</strong> {availability.available ? "可用" : "不可用"}</span></div>
</section>
{scumDataset && <SCUMPersistedDataView serverInstanceId={serverId} dataset={scumDataset} />}
{!scumDataset && bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!scumDataset && !bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
{!scumDataset && bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
</div>
);
}