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
+37 -16
View File
@@ -20,7 +20,7 @@ import type {
ServerMemberResponse,
ServerMetricsResponse,
RuntimeBindingResponse,
ServerDeploymentResponse,
ServerDeploymentResponse,
ServerRuntimeActionsResponse,
MetricSampleResponse,
RemoteAdapterDeclarationResponse
@@ -73,6 +73,7 @@ import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePlugi
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes";
import { stateLabel, statusClass } from "./ServersPage";
import { PluginPageHostPage } from "./PluginPageHostPage";
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
@@ -81,7 +82,8 @@ const defaultConfigKey = "server.properties";
const serverDetailRefreshMs = 5000;
const serverMetricFreshMs = 30000;
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) {
export function ServerDetailPage(props: PageComponentProps) {
const { session, params, operations, onNavigate } = props;
const serverId = params.serverId ?? "";
const [section, setSection] = useState<ServerDetailSection>("logs");
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
@@ -94,7 +96,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [liveLogOpen, setLiveLogOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
@@ -107,7 +109,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(),
platformApiClient.listJobs(serverId),
@@ -119,10 +121,10 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
.getServerRuntimeBinding(serverId)
.then((data): LoadState<RuntimeBindingResponse> => ({ status: "ready", data }))
.catch((error): LoadState<RuntimeBindingResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })),
platformApiClient
.getServerDeployment(serverId)
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
platformApiClient
.getServerDeployment(serverId)
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 }))
@@ -132,7 +134,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
setJobs(jobResponse.items);
setRuntimeActions(runtimeResponse);
setRuntimeBinding(bindingResponse);
setDeployment(deploymentResponse);
setDeployment(deploymentResponse);
setMetricHistory(metricHistoryResponse.items);
setBackups(backupResponse.items);
setRemoteAdapters(adapterResponse.items);
@@ -150,7 +152,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
setArtifacts([]);
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
setRuntimeBinding({ status: "error", reason: "运行配置加载失败" });
setDeployment({ status: "error", reason: "部署定义加载失败" });
setDeployment({ status: "error", reason: "部署定义加载失败" });
setMetricHistory([]);
setBackups([]);
setRemoteAdapters([]);
@@ -267,7 +269,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
aria-current={section === entry.id ? "page" : undefined}
onClick={() => setSection(entry.id)}
>
{entry.label}
{serverDetailSectionLabel(entry)}
</button>
))}
</nav>
@@ -330,12 +332,12 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
aria-current={section === entry.id ? "page" : undefined}
onClick={() => setSection(entry.id)}
>
{entry.label}
{serverDetailSectionLabel(entry, instance.data.pluginId)}
</button>
))}
</nav>
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "logs" && (instance.data.pluginId === "game.scum" ? <ScumFileManagementSection pageProps={props} serverId={serverId} plugin={plugins.find((plugin) => plugin.id === instance.data.pluginId)} /> : <LogsSection serverId={serverId} />)}
{section === "terminal" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{section === "runtime" && (
<RuntimeBindingSection
@@ -409,6 +411,24 @@ function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
return [...byID.values()];
}
function serverDetailSectionLabel(entry: { id: ServerDetailSection; label: string }, pluginId?: string): string {
return pluginId === "game.scum" && entry.id === "logs" ? "文件管理" : entry.label;
}
interface ScumFileManagementSectionProps {
pageProps: PageComponentProps;
serverId: string;
plugin?: GamePluginResponse;
}
function ScumFileManagementSection({ pageProps, serverId, plugin }: ScumFileManagementSectionProps) {
const params = useMemo(() => ({ ...pageProps.params, pluginId: plugin?.id ?? "", routeKey: "files-config", serverId }), [pageProps.params.pluginId, pageProps.params.routeKey, pageProps.params.serverId, plugin?.id, serverId]);
if (!plugin) {
return <LoadingState label="正在加载 SCUM 文件工作台…" compact />;
}
return <PluginPageHostPage {...pageProps} params={params} initialPlugin={plugin} embedded />;
}
interface ServerMetadataSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
@@ -1999,7 +2019,7 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
requestId: `web:bridge:${serverId}:${plugin.id}:${action}:${Date.now()}`,
action,
aiPurpose: action === "ai.invoke" ? plugin.aiPurposes[0] : undefined,
payload: bridgePayloadForAction(action, serverInstance, artifacts[0])
payload: bridgePayloadForAction(action, plugin, serverInstance, artifacts[0])
});
setPendingAction(null);
if (response.status === "ok" || response.status === "queued") {
@@ -2044,9 +2064,10 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
);
}
function bridgePayloadForAction(action: PluginBridgeAction, serverInstance: ServerInstanceResponse, artifact?: ArtifactResponse): Record<string, string> | undefined {
function bridgePayloadForAction(action: PluginBridgeAction, plugin: GamePluginResponse, serverInstance: ServerInstanceResponse, artifact?: ArtifactResponse): Record<string, string> | undefined {
if (action === "files.request") {
return { operation: "read", key: "logs/latest.log", expectedConfigVersion: String(serverInstance.configVersion) };
const declaredFileKey = plugin.fileWorkspace?.files.find((file) => file.kind === "config")?.key ?? plugin.fileWorkspace?.files[0]?.key ?? "logs/latest.log";
return { operation: "read", key: declaredFileKey, expectedConfigVersion: String(serverInstance.configVersion) };
}
if (action === "artifacts.open" && artifact) {
return { artifactId: artifact.id };