feat(scum): add file config workbench
This commit is contained in:
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user