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
@@ -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"],