feat(scum): add file config workbench

This commit is contained in:
npc0-hue
2026-07-28 14:43:24 +08:00
parent e739dd9c79
commit e4ef4024a8
22 changed files with 397 additions and 109 deletions
+6
View File
@@ -282,6 +282,11 @@ export interface RuntimeLogSourceResponse {
retentionDays?: number;
}
export interface PluginLogicalDirectoryResponse { key: string; label: string; scope: "config" | "logs"; }
export interface PluginLogicalFileResponse { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean; }
export interface PluginConfigFieldResponse { key: string; fileKey: string; configKey: string; label: string; description: string; control: "text" | "number" | "boolean" | "port"; minimum?: number; maximum?: number; defaultValue?: string; restartImpact: "none" | "restart-required"; }
export interface PluginFileWorkspaceResponse { defaultDirectoryKey: string; directories: PluginLogicalDirectoryResponse[]; files: PluginLogicalFileResponse[]; configFields: PluginConfigFieldResponse[]; }
export interface RuntimeLogEventResponse {
key: string;
title: string;
@@ -364,6 +369,7 @@ export interface GamePluginResponse {
lifecycleActions: Record<string, string>;
bridgeActions: string[];
pages: GamePluginPageResponse[];
fileWorkspace?: PluginFileWorkspaceResponse;
tags: string[];
aiPurposes: string[];
productionLifecycle: PluginProductionLifecycleDeclaration;
@@ -0,0 +1,46 @@
import { FileCode2, FileText, RefreshCw, Save, ScrollText } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { LogEntryBody, PluginFileWorkspaceResponse, ServerConfigDiffPreviewResponse, ServerConfigResponse } from "../api/types";
import type { ScumOperationsPageContract } from "../contracts/scumOperations";
import { DiffView } from "./OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; config: ServerConfigResponse; values: Record<string, string>; unknown: Array<[string, string]> };
export function ScumFileConfigWorkbench({ contract, workspace }: { contract: ScumOperationsPageContract; workspace: PluginFileWorkspaceResponse }) {
const [state, setState] = useState<State>({ status: "loading" });
const [directoryKey, setDirectoryKey] = useState(workspace.defaultDirectoryKey);
const [selectedFileKey, setSelectedFileKey] = useState(workspace.files.find((file) => file.directoryKey === workspace.defaultDirectoryKey)?.key ?? "");
const [preview, setPreview] = useState<ServerConfigDiffPreviewResponse | null>(null);
const [result, setResult] = useState<string>("");
const [logEntries, setLogEntries] = useState<LogEntryBody[] | null>(null);
const files = useMemo(() => workspace.files.filter((file) => file.directoryKey === directoryKey), [directoryKey, workspace.files]);
const selectedFile = workspace.files.find((file) => file.key === selectedFileKey);
const fields = workspace.configFields.filter((field) => field.fileKey === selectedFileKey);
const load = useCallback(async () => {
setState({ status: "loading" }); setPreview(null); setResult("");
try { const config = await platformApiClient.getServerConfig(contract.serverInstanceId); const parsed = parseIni(config.content); const values = Object.fromEntries(workspace.configFields.map((field) => [field.key, parsed[field.configKey] ?? field.defaultValue ?? ""])); const known = new Set(workspace.configFields.map((field) => field.configKey)); setState({ status: "ready", config, values, unknown: Object.entries(parsed).filter(([key]) => !known.has(key)) }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "无法读取平台配置投影。" }); }
}, [contract.serverInstanceId, workspace.configFields]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { setSelectedFileKey(workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? ""); setLogEntries(null); setPreview(null); }, [directoryKey, workspace.files]);
async function openLog() { if (!selectedFile?.streamKey) return; setLogEntries(null); try { const streams = await platformApiClient.listLogStreams(); const stream = streams.items.find((item) => item.serverInstanceId === contract.serverInstanceId && item.streamKey === selectedFile.streamKey); if (!stream) { setLogEntries([]); return; } const data = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 200), limit: 200 }); setLogEntries(data.entries); } catch (error) { setResult(error instanceof Error ? error.message : "日志文件内容读取失败。" ); setLogEntries([]); } }
async function previewChanges() { if (state.status !== "ready") return; const proposed = patchIni(state.config.content, fields, state.values); try { setPreview(await platformApiClient.previewServerConfigDiff(contract.serverInstanceId, { expectedConfigVersion: state.config.configVersion, expectedChecksum: state.config.checksum, key: state.config.key ?? selectedFileKey, proposedContent: proposed })); } catch (error) { setResult(error instanceof Error ? error.message : "配置差异预览失败。" ); } }
async function approve() { if (!preview) return; try { const response = await platformApiClient.approveServerConfigWrite(contract.serverInstanceId, { expectedConfigVersion: preview.configVersion, expectedChecksum: preview.checksum, key: preview.key, proposedContent: preview.proposedContent, idempotencyKey: `scum-files-config-${Date.now()}` }); setResult(`已派发配置写入任务 ${response.job.id}`); setPreview(null); await load(); } catch (error) { setResult(error instanceof Error ? error.message : "配置写入审批失败。" ); } }
if (state.status === "loading") return <LoadingState label="正在加载 SCUM 文件与配置工作台…" />;
if (state.status === "error") return <ErrorState title="SCUM 文件与配置不可用" reason={state.reason} onRetry={() => void load()} />;
return <div className="console-page" aria-label="SCUM 文件与配置工作台">
<section className="console-panel"><div className="panel-header"><div><h2><FileCode2 size={16} /> </h2><p className="provider-id"></p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span></span></button></div><div className="action-strip">{workspace.directories.map((directory) => <button key={directory.key} type="button" className="icon-command" aria-pressed={directoryKey === directory.key} onClick={() => setDirectoryKey(directory.key)}><span>{directory.label}</span></button>)}</div><div className="console-record-list">{files.map((file) => <button key={file.key} type="button" className="console-record" aria-pressed={selectedFileKey === file.key} onClick={() => { setSelectedFileKey(file.key); setPreview(null); setLogEntries(null); }}><span>{file.kind === "log" ? <ScrollText size={15} /> : <FileText size={15} />}</span><strong>{file.label}</strong><small>{file.kind === "log" ? "日志文件" : file.editable ? "已建模配置" : "只读配置"}</small></button>)}</div></section>
{selectedFile?.kind === "config" && <section className="console-panel"><div className="panel-header"><h2> · {selectedFile.label}</h2><span className="page-status">{fields.length} </span></div><div className="provider-form">{fields.map((field) => <label key={field.key}>{field.label}<small>{field.description} · {field.defaultValue || "--"} · {field.restartImpact === "restart-required" ? "修改后需重启" : "无需重启"} · {selectedFile.label}</small>{field.control === "boolean" ? <select value={state.values[field.key]} onChange={(event) => setState((current) => current.status === "ready" ? { ...current, values: { ...current.values, [field.key]: event.target.value } } : current)}><option value="true"></option><option value="false"></option></select> : <input type={field.control === "text" ? "text" : "number"} min={field.minimum} max={field.maximum} value={state.values[field.key]} onChange={(event) => setState((current) => current.status === "ready" ? { ...current, values: { ...current.values, [field.key]: event.target.value } } : current)} />}</label>)}</div><div className="console-module"><strong></strong>{state.unknown.length ? <div className="console-row-list">{state.unknown.map(([key, value]) => <div className="console-row" key={key}><span>{key}</span><code>{value}</code></div>)}</div> : <p className="page-status"></p>}</div><div className="action-strip"><button type="button" className="icon-command" onClick={() => void previewChanges()}><Save size={14} /><span></span></button>{preview && <button type="button" className="icon-command" onClick={() => void approve()}><span></span></button>}</div>{preview && <DiffView lines={preview.diff.map((line) => ({ kind: line.kind === "context" ? "same" : line.kind, text: line.content }))} />}</section>}
{selectedFile?.kind === "log" && <section className="console-panel"><div className="panel-header"><h2> · {selectedFile.label}</h2><button type="button" className="icon-command" onClick={() => void openLog()}><ScrollText size={14} /><span></span></button></div>{logEntries === null ? <EmptyState title="尚未读取日志文件" description="只读取该插件声明的日志流,不显示主机路径。" /> : logEntries.length === 0 ? <EmptyState title="暂无日志内容" description="当前声明的日志流没有可读取记录。" /> : <pre className="log-view">{logEntries.map((entry) => `${entry.timestamp} ${entry.line}`).join("\n")}</pre>}</section>}
{result && <ResultBadge status={result.startsWith("已派发") ? "succeeded" : "failed"} label={result} />}
</div>;
}
function parseIni(content: string): Record<string, string> { return Object.fromEntries(content.split(/\r?\n/).flatMap((line) => { const match = line.match(/^\s*([^=;#\s]+)\s*=\s*(.*?)\s*$/); return match ? [[match[1], match[2]]] : []; })); }
function patchIni(content: string, fields: PluginFileWorkspaceResponse["configFields"], values: Record<string, string>): string { let next = content; for (const field of fields) { const pattern = new RegExp(`(^\\s*${escapeRegExp(field.configKey)}\\s*=\\s*).*?$`, "m"); next = pattern.test(next) ? next.replace(pattern, `$1${values[field.key]}`) : `${next.replace(/\s*$/, "")}\n${field.configKey}=${values[field.key]}`; } return next; }
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
@@ -10,7 +10,7 @@ const now = "2026-07-20T08:00:00Z";
const contract: ScumOperationsPageContract = {
pluginId: "game.scum",
routeKey: "operations",
routeKey: "files-config",
serverInstanceId: "server-1",
title: "SCUM 运维",
permissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"],
+12 -6
View File
@@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types";
import { resolveScumOperationsPageContract } from "./scumOperations";
import { normalizeScumRouteKey, resolveScumOperationsPageContract } from "./scumOperations";
const plugin = {
id: "game.scum",
pages: [{
key: "operations",
title: "SCUM 运维",
path: "/operations",
key: "files-config",
title: "文件与配置",
path: "/files-config",
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access", "unknown.permission"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request", "unknown.action"]
}],
@@ -26,7 +26,7 @@ const plugin = {
],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
},
runtimeProfiles: {
logSources: [{ key: "scum-chat-events", kind: "file.tail", streamKey: "scum.chat", retentionDays: 30 }],
@@ -42,7 +42,7 @@ describe("SCUM operations page contract", () => {
available: true,
contract: {
pluginId: "game.scum",
routeKey: "operations",
routeKey: "files-config",
serverInstanceId: "server-1",
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
@@ -61,4 +61,10 @@ describe("SCUM operations page contract", () => {
expect(resolveScumOperationsPageContract(plugin, "")).toMatchObject({ available: false, reason: "缺少服务器实例上下文。" });
expect(resolveScumOperationsPageContract({ ...plugin, gameClientBridge: undefined }, "server-1")).toMatchObject({ available: false });
});
it("migrates only legacy SCUM page keys to the files-and-config workbench", () => {
expect(normalizeScumRouteKey("game.scum", "overview")).toBe("files-config");
expect(normalizeScumRouteKey("game.scum", "logs")).toBe("files-config");
expect(normalizeScumRouteKey("game.other", "logs")).toBe("logs");
});
});
+4 -2
View File
@@ -15,7 +15,7 @@ import {
} from "./pluginBridge";
export const scumOperationsPluginId = "game.scum";
export const scumOperationsRouteKey = "operations";
export const scumOperationsRouteKey = "files-config";
export interface ScumOperationsPageContract {
pluginId: typeof scumOperationsPluginId;
@@ -102,7 +102,9 @@ export type ScumOperationsPageResolution =
| { available: true; contract: ScumOperationsPageContract }
| { available: false; reason: string };
type ScumPluginProjection = Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle">;
type ScumPluginProjection = Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle" | "fileWorkspace">;
export function normalizeScumRouteKey(pluginId: string, routeKey: string): string { return pluginId === scumOperationsPluginId && ["overview", "operations", "config", "logs"].includes(routeKey) ? scumOperationsRouteKey : routeKey; }
export function resolveScumOperationsPageContract(plugin: ScumPluginProjection, serverInstanceId: string): ScumOperationsPageResolution {
if (plugin.id !== scumOperationsPluginId) {
@@ -30,9 +30,9 @@ const plugin: GamePluginResponse = {
lifecycleActions: {},
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
pages: [{
key: "operations",
title: "SCUM 运维",
path: "/operations",
key: "files-config",
title: "文件与配置",
path: "/files-config",
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read", "server.remote.access"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"]
}],
@@ -45,7 +45,7 @@ const plugin: GamePluginResponse = {
queryTemplates: [{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
},
status: "installed"
};
@@ -62,7 +62,7 @@ function props(serverId = "server-1"): PageComponentProps {
};
return {
session,
params: { pluginId: "game.scum", routeKey: "operations", serverId },
params: { pluginId: "game.scum", routeKey: "files-config", serverId },
operations,
onNavigate: () => undefined,
onLogout: async () => undefined,
@@ -74,7 +74,7 @@ function props(serverId = "server-1"): PageComponentProps {
describe("PluginPageHostPage", () => {
it("renders SCUM operations from manifest-owned declarations", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
expect(html).toContain("SCUM 运维");
expect(html).toContain("文件与配置");
expect(html).toContain("平台托管上下文");
expect(html).toContain("命令目录");
expect(html).toContain("快照目录");
+4 -4
View File
@@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types";
import { PageFrame } from "../components/PageFrame";
import { ScumOperationsPanel } from "../components/ScumOperationsPanel";
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import { resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
import { normalizeScumRouteKey, resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
type PluginPageState =
@@ -22,7 +22,7 @@ interface PluginPageHostPageProps extends PageComponentProps {
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
const pluginId = params.pluginId ?? "";
const routeKey = params.routeKey ?? "";
const routeKey = normalizeScumRouteKey(pluginId, params.routeKey ?? "");
const serverId = params.serverId ?? "";
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
@@ -105,7 +105,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
/>
)}
</section>
{scumResolution?.available && <ScumOperationsPanel contract={scumResolution.contract} />}
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
</div>
);
}