Add SCUM file management workbench

This commit is contained in:
npc0-hue
2026-08-04 11:33:34 +08:00
parent 2921edb401
commit f028a343d7
36 changed files with 1384 additions and 158 deletions
+113 -16
View File
@@ -1,6 +1,6 @@
import { ArrowLeft, PlugZap } from "lucide-react";
import * as React from "react";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types";
@@ -8,7 +8,8 @@ import { PageFrame } from "../components/PageFrame";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
type PluginPageState =
@@ -18,16 +19,19 @@ type PluginPageState =
interface PluginPageHostPageProps extends PageComponentProps {
initialPlugin?: GamePluginResponse;
embedded?: boolean;
}
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
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>; workspace?: unknown; availability: PluginPageAvailability }> | null>(null);
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; 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) {
@@ -52,19 +56,107 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
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;
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 || !serverId) return undefined;
return {
requestFile: async (fileKey) => {
const plugin = readyPluginRef.current;
const context = hostContextRef.current;
if (!plugin || !context) {
return { status: "denied", message: "插件页面上下文尚未就绪。" };
}
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
}
const dispatch = createPluginBridgeDispatcher(context, platformApiClient);
const response = await dispatch({
requestId: `web:plugin-file-read:${serverId}:${fileKey}:${Date.now()}`,
action: "files.request",
payload: { operation: "read", key: fileKey }
});
const jobId = response.result?.jobId;
if (response.status === "queued" || response.status === "ok") {
return { status: response.status, jobId, message: jobId ? `读取任务 ${jobId} 已提交。` : "已提交文件读取请求。" };
}
return { status: response.status, message: response.error?.message ?? "文件读取请求未能提交。" };
},
getFileSnapshot: async (fileKey) => {
const plugin = readyPluginRef.current;
if (!plugin) {
return { serverInstanceId: serverId, pluginId, key: fileKey, state: "unavailable", reason: "插件页面上下文尚未就绪。" };
}
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
return { serverInstanceId: serverId, pluginId: plugin.id, key: fileKey, state: "unavailable", reason: "该文件不在当前插件声明的工作区内。" };
}
try {
return await platformApiClient.getDeclaredFileReadSnapshot(serverId, fileKey);
} catch (error) {
return {
serverInstanceId: serverId,
pluginId: plugin.id,
key: fileKey,
state: "unavailable",
reason: error instanceof Error ? error.message : "无法读取文件快照。"
};
}
},
writeFile: async (fileKey, content, options) => {
const plugin = readyPluginRef.current;
const context = hostContextRef.current;
if (!plugin || !context) {
return { status: "denied", message: "插件页面上下文尚未就绪。" };
}
const file = plugin.fileWorkspace?.files.find((candidate) => candidate.key === fileKey);
if (!file) {
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
}
if (file.kind !== "config" || !file.editable) {
return { status: "denied", message: "该声明文件不允许通过配置工作台写入。" };
}
if (!context.permissions.includes("server.files.write")) {
return { status: "denied", message: "当前页面没有声明文件写入权限。" };
}
try {
const response = await platformApiClient.dispatchFileOperation({
serverInstanceId: serverId,
pluginId: plugin.id,
operation: "write",
key: fileKey,
content,
expectedChecksum: options?.expectedChecksum,
idempotencyKey: `web:plugin-file-write:${serverId}:${fileKey}:${Date.now()}`
});
return { status: response.status, jobId: response.job.id, message: `写入任务 ${response.job.id} 已提交。` };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "文件写入请求未能提交。" };
}
}
};
}, [pluginId, serverId]);
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
const loadableBundlePage = useMemo(() => declaredBundlePage, [bundleLoadKey]);
useEffect(() => {
let active = true;
if (!declaredBundlePage) return () => { active = false; };
if (!loadableBundlePage) 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 加载失败。"); });
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; };
}, [declaredBundlePage, serverId]);
}, [bundleLoadKey, loadableBundlePage, serverId]);
if (state.status === "loading") {
return <LoadingState label="正在加载插件页面声明…" />;
@@ -77,13 +169,18 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
if (!page) {
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
}
const manifestContract = pluginBridgeManifestContractFromResponse(state.plugin);
const hostContext = createPluginBridgeHostContext({
plugin: manifestContract,
routeKey,
serverInstanceId: serverId || undefined,
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
});
if (!hostContext) {
return <ErrorState title="插件页面不可用" reason="插件页面上下文初始化失败。" />;
}
if (embedded) {
return (
<>
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
</>
);
}
return (
<div className="console-page">
<PageFrame
@@ -110,7 +207,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
</section>
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, availability })}
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
</div>
);
}