feat(plugin): add SCUM ownership migration foundation

This commit is contained in:
npc0-hue
2026-07-28 18:11:00 +08:00
parent d24074134a
commit 271e1e684f
29 changed files with 328 additions and 315 deletions
+26 -27
View File
@@ -1,17 +1,15 @@
import { ArrowLeft, PlugZap } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import * as React from "react";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types";
import { PageFrame } from "../components/PageFrame";
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
import { GamePlayerIntelligencePanel } from "../components/GamePlayerIntelligencePanel";
import { ScumMapTrajectoryPanel } from "../components/ScumMapTrajectoryPanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import { normalizeScumRouteKey, resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
import { loadPluginPageBundle } from "../utils/pluginPageBundles";
type PluginPageState =
| { status: "loading" }
@@ -24,9 +22,12 @@ interface PluginPageHostPageProps extends PageComponentProps {
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
const pluginId = params.pluginId ?? "";
const routeKey = normalizeScumRouteKey(pluginId, params.routeKey ?? "");
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>; workspace?: unknown; availability: { available: boolean; reason?: string } }> | null>(null);
const [bundleError, setBundleError] = useState("");
const [availability, setAvailability] = useState<{ available: boolean; reason?: string }>({ available: false, reason: "正在验证 Companion 可用性。" });
const load = useCallback(async () => {
if (!pluginId || !routeKey) {
@@ -49,6 +50,20 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
}
}, [initialPlugin, load]);
const readyPlugin = state.status === "ready" ? state.plugin : undefined;
const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey);
const declaredBundlePage = readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin).pages.find((candidate) => candidate.key === routeKey) : undefined;
useEffect(() => {
let active = true;
if (!declaredBundlePage) return () => { active = false; };
globalThis.__PLUGIN_PAGE_REACT__ = React;
setBundle(null); setBundleError("");
void loadPluginPageBundle(declaredBundlePage).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 }); }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
return () => { active = false; };
}, [declaredBundlePage, serverId]);
if (state.status === "loading") {
return <LoadingState label="正在加载插件页面声明…" />;
}
@@ -56,7 +71,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
return <ErrorState title="插件页面不可用" reason={state.reason} onRetry={() => void load()} />;
}
const page = state.plugin.pages.find((candidate) => candidate.key === routeKey);
const page = declaredPage;
if (!page) {
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
}
@@ -67,9 +82,6 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
serverInstanceId: serverId || undefined,
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
});
const isScumOperations = state.plugin.id === scumOperationsPluginId && routeKey === scumOperationsRouteKey;
const scumResolution = isScumOperations ? resolveScumOperationsPageContract(state.plugin, serverId) : null;
return (
<div className="console-page">
<PageFrame
@@ -92,24 +104,11 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
</button>
</div>
{scumResolution && !scumResolution.available && <ErrorState title="SCUM 运维声明不可用" reason={scumResolution.reason} compact />}
{scumResolution?.available && (
<div className="action-list" aria-label="SCUM operations declarations">
<span><strong></strong> {scumResolution.contract.commands.length} operations </span>
<span><strong></strong> {scumResolution.contract.snapshots.length} schemaVersion </span>
<span><strong></strong> {scumResolution.contract.queryTemplates.length} SQLite </span>
</div>
)}
{!scumResolution && (
<EmptyState
title="插件页面已接入 Host Bridge"
description="当前页面只接收 manifest 声明与平台安全上下文;具体操作由对应插件页面实现。"
/>
)}
<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>
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
{scumResolution?.available && <GamePlayerIntelligencePanel serverInstanceId={serverId} />}
{scumResolution?.available && <ScumMapTrajectoryPanel serverInstanceId={serverId} />}
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, availability })}
</div>
);
}