From 8e6d12f6f1957a3ce6179b18f5a4aec4b1487680 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Fri, 11 Sep 2026 11:40:06 +0800 Subject: [PATCH] Add server configuration editor --- .../components/ServerConfigEditor.tsx | 135 ++++++++++++++++++ platform_web/pages/ServerDetailPage.test.tsx | 1 + platform_web/pages/ServerDetailPage.tsx | 6 +- platform_web/utils/iniConfig.test.ts | 17 +++ platform_web/utils/iniConfig.ts | 23 +++ .../scum-server-plugin/features/migration.ts | 2 +- .../scum-server-plugin/features/schemas.ts | 14 +- .../examples/scum-server-plugin/manifest.json | 110 ++++++++++---- plugins/tests/manifest-validation.test.ts | 2 +- 9 files changed, 277 insertions(+), 33 deletions(-) create mode 100644 platform_web/components/ServerConfigEditor.tsx create mode 100644 platform_web/utils/iniConfig.test.ts create mode 100644 platform_web/utils/iniConfig.ts diff --git a/platform_web/components/ServerConfigEditor.tsx b/platform_web/components/ServerConfigEditor.tsx new file mode 100644 index 0000000..e8f1d76 --- /dev/null +++ b/platform_web/components/ServerConfigEditor.tsx @@ -0,0 +1,135 @@ +import { Code2, FileCog, Save, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { platformApiClient } from "../api/client"; +import type { DeclaredFileReadSnapshotResponse, PluginConfigFieldResponse, PluginLogicalFileResponse, ServerFileWorkspaceResponse, ServerInstanceResponse } from "../api/types"; +import type { OperationTracker } from "../stores/operations"; +import { iniValue, updateIniValues } from "../utils/iniConfig"; +import { ErrorState, LoadingState, ResultBadge } from "./StateViews"; + +interface ServerConfigEditorProps { + instance: ServerInstanceResponse; + operations: OperationTracker; + requester: string; + onClose: () => void; +} + +type EditorMode = "friendly" | "source"; + +export function ServerConfigEditor({ instance, operations, requester, onClose }: ServerConfigEditorProps) { + const [workspace, setWorkspace] = useState(null); + const [selectedKey, setSelectedKey] = useState(""); + const [mode, setMode] = useState("friendly"); + const [snapshot, setSnapshot] = useState(null); + const [raw, setRaw] = useState(""); + const [values, setValues] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + const configFiles = useMemo(() => (workspace?.files ?? []).filter((file) => file.kind === "config" && file.editable !== false), [workspace]); + const selectedFile = configFiles.find((file) => file.key === selectedKey) ?? configFiles[0]; + const fields = useMemo(() => (workspace?.configFields ?? []).filter((field) => field.fileKey === selectedFile?.key), [selectedFile?.key, workspace?.configFields]); + + const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse) => { + setLoading(true); + setError(""); + setMessage("正在通过 Run 读取配置文件…"); + try { + let next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key); + if (next.state !== "ready") { + const operationId = operations.begin({ intent: "读取服务器配置", targetKind: "config", targetId: instance.id, requester }); + const dispatch = await platformApiClient.readServerFile(instance.id, { key: file.key, idempotencyKey: configOperationKey("read", instance.id, file.key) }); + operations.succeed(operationId, `配置读取任务 ${dispatch.job.id} 已派发`, dispatch.job); + for (let attempt = 0; attempt < 60 && next.state !== "ready"; attempt += 1) { + await new Promise((resolve) => window.setTimeout(resolve, 500)); + next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key); + } + } + if (next.state !== "ready" || next.content === undefined) throw new Error(next.reason ?? "Run 尚未返回配置文件内容。"); + setSnapshot(next); + setRaw(next.content); + setValues(Object.fromEntries(fields.map((field) => [field.key, iniValue(next.content ?? "", field.configKey)]))); + setMessage(`已读取 ${file.label}${next.readAt ? ` · ${formatReadTime(next.readAt)}` : ""}`); + } catch (readError) { + setSnapshot(null); + setRaw(""); + setError(readError instanceof Error ? readError.message : "配置文件读取失败"); + setMessage(""); + } finally { + setLoading(false); + } + }, [fields, instance.id, operations, requester]); + + useEffect(() => { + if (!workspace) { + void platformApiClient.getServerFileWorkspace(instance.id).then((next) => { + setWorkspace(next); + setSelectedKey(next.files.find((file) => file.kind === "config" && file.editable !== false)?.key ?? ""); + }).catch((loadError) => setError(loadError instanceof Error ? loadError.message : "配置工作区加载失败")).finally(() => setLoading(false)); + } + }, [instance.id, workspace]); + + useEffect(() => { + if (selectedFile) void readSelectedFile(selectedFile); + }, [readSelectedFile, selectedFile?.key]); + + async function save() { + if (!selectedFile || !snapshot || saving) return; + const content = mode === "source" ? raw : updateIniValues(raw, Object.fromEntries(fields.map((field) => [field.configKey, values[field.key] ?? ""]))); + const operationId = operations.begin({ intent: "保存服务器配置", targetKind: "config", targetId: instance.id, requester }); + setSaving(true); + setError(""); + setMessage("正在向 Run 派发配置写入任务…"); + try { + const dispatch = await platformApiClient.writeServerFile(instance.id, { key: selectedFile.key, content, expectedVersion: snapshot.version, expectedChecksum: snapshot.checksum, idempotencyKey: configOperationKey("write", instance.id, selectedFile.key) }); + operations.succeed(operationId, `配置写入任务 ${dispatch.job.id} 已派发`, dispatch.job); + setRaw(content); + setSnapshot({ ...snapshot, content }); + setMessage(`已派发 ${selectedFile.label} 写入任务;重启影响:${fields.some((field) => field.restartImpact === "restart-required") ? "需要重启服务器" : "无需重启"}。`); + } catch (saveError) { + operations.fail(operationId, saveError instanceof Error ? saveError.message : "配置写入失败", operationId); + setError(saveError instanceof Error ? saveError.message : "配置写入失败"); + } finally { + setSaving(false); + } + } + + return ( +
+
+

修改配置

{instance.name}
+ {loading && !workspace && } + {error && !workspace && } + {workspace && configFiles.length === 0 && } + {workspace && configFiles.length > 0 && ( +
+ +
+

{selectedFile?.label}

配置内容由 Run 返回
+
{mode === "friendly" ? "人类可读模式:只修改插件声明的配置项" : "源码模式:编辑完整原始文件"}
+ {message && } + {error && workspace && } + {loading && } + {!loading && mode === "friendly" &&
{fields.length === 0 && 该配置文件没有声明可视化字段,请切换到源码模式。}{fields.map((field) => setValues((current) => ({ ...current, [field.key]: value }))} />)}
} + {!loading && mode === "source" &&