Files

160 lines
12 KiB
TypeScript

import { Code2, FileCog, Save, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, 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<ServerFileWorkspaceResponse | null>(null);
const [selectedKey, setSelectedKey] = useState("");
const [mode, setMode] = useState<EditorMode>("friendly");
const [snapshot, setSnapshot] = useState<DeclaredFileReadSnapshotResponse | null>(null);
const [raw, setRaw] = useState("");
const [values, setValues] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const readInFlightRef = useRef("");
const readAttemptRef = useRef(new Map<string, number>());
const operationsRef = useRef(operations);
operationsRef.current = operations;
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, forceRetry = false) => {
const readKey = `${instance.id}:${file.key}`;
if (readInFlightRef.current === readKey) return;
readInFlightRef.current = readKey;
setLoading(true);
setError("");
setMessage("正在通过 Run 读取配置文件…");
try {
let next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
const failedState = next.state === "failed" || next.state === "cancelled";
let dispatched = false;
if (next.state === "not-read" || (forceRetry && failedState)) {
const readAttempt = (readAttemptRef.current.get(readKey) ?? 0) + 1;
readAttemptRef.current.set(readKey, readAttempt);
const operationId = operationsRef.current.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, readAttempt) });
operationsRef.current.succeed(operationId, `配置读取任务 ${dispatch.job.id} 已派发`, dispatch.job);
dispatched = true;
}
if (dispatched || next.state === "pending") {
for (let pollAttempt = 0; pollAttempt < 60 && next.state !== "ready"; pollAttempt += 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 ?? (next.state === "pending" ? "Run 仍在读取配置文件,请稍后重试。" : "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);
if (readInFlightRef.current === readKey) readInFlightRef.current = "";
}
}, [fields, instance.id, 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 (
<div className="server-file-editor-overlay" role="dialog" aria-modal="true" aria-label="修改服务器配置">
<section className="server-file-editor" aria-label="server configuration workbench">
<div className="panel-header"><h2><FileCog size={16} style={{ verticalAlign: "-2px" }} /> 修改配置</h2><span className="page-status">{instance.name}</span><button type="button" className="icon-command" onClick={onClose}><X size={14} /><span>关闭</span></button></div>
{loading && !workspace && <LoadingState label="正在加载配置文件声明…" compact />}
{error && !workspace && <ErrorState title="配置工作区不可用" reason={error} diagnosticId={`server-config:${instance.id}`} compact />}
{workspace && configFiles.length === 0 && <ErrorState title="插件没有声明可编辑配置" reason="请先在 SCUM 插件中声明配置文件。" diagnosticId={`server-config:${instance.id}`} compact />}
{workspace && configFiles.length > 0 && (
<div className="file-workbench">
<nav className="file-workbench-nav" aria-label="配置文件">
<div className="file-workbench-directory-heading"><strong>配置文件</strong><span>由插件声明</span></div>
{configFiles.map((file) => <button key={file.key} type="button" className="file-workbench-file" aria-current={file.key === selectedFile?.key ? "page" : undefined} onClick={() => { setSelectedKey(file.key); setMode("friendly"); }}>{<strong>{file.label}</strong>}<span>{file.key === "scum-server-settings" ? "可视化字段" : "源码"}</span></button>)}
</nav>
<div className="file-workbench-detail">
<div className="panel-header"><div><h3>{selectedFile?.label}</h3><span className="page-status">配置内容由 Run 返回</span></div></div>
<div className="file-workbench-mode"><span>{mode === "friendly" ? "人类可读模式:只修改插件声明的配置项" : "源码模式:编辑完整原始文件"}</span><button type="button" className={mode === "friendly" ? "file-workbench-mode-active" : undefined} onClick={() => setMode("friendly")}><FileCog size={13} /> 人类可读</button><button type="button" className={mode === "source" ? "file-workbench-mode-active" : undefined} onClick={() => setMode("source")}><Code2 size={13} /> 源码模式</button></div>
{message && <ResultBadge status={error ? "failed" : "pending"} label={message} />}
{error && workspace && <ErrorState title="配置操作失败" reason={error} diagnosticId={`server-config:${instance.id}:${selectedFile?.key ?? "unknown"}`} onRetry={selectedFile ? () => void readSelectedFile(selectedFile, true) : undefined} compact />}
{loading && <LoadingState label="正在读取配置文件…" compact />}
{!loading && mode === "friendly" && <div className="file-workbench-fields">{fields.length === 0 && <span className="provider-id">该配置文件没有声明可视化字段,请切换到源码模式。</span>}{configFieldGroups(fields).map((group) => <div key={group.label} className="file-workbench-group"><h4>{group.label}</h4>{group.fields.map((field) => <ConfigField key={field.key} field={field} value={values[field.key] ?? ""} onChange={(value) => setValues((current) => ({ ...current, [field.key]: value }))} />)}</div>)}</div>}
{!loading && mode === "source" && <div className="file-workbench-raw"><textarea className="file-workbench-raw-editor" value={raw} spellCheck={false} onChange={(event) => setRaw(event.target.value)} aria-label={`${selectedFile?.label ?? "配置文件"}源码`} /><small>保存会把完整原文交给 Run 写回服务器工作区。</small></div>}
<div className="action-strip file-workbench-actions"><button type="button" className="primary-command" disabled={loading || saving || !snapshot} onClick={() => void save()}><Save size={14} /><span>{saving ? "保存中…" : "保存配置"}</span></button><button type="button" className="icon-command" onClick={onClose}>取消</button></div>
</div>
</div>
)}
</section>
</div>
);
}
function ConfigField({ field, value, onChange }: { field: PluginConfigFieldResponse; value: string; onChange: (value: string) => void }) {
const inputType = field.control === "number" || field.control === "port" ? "number" : "text";
return <label className="file-workbench-field"><span><strong>{field.label}</strong><small>{field.description}{field.restartImpact === "restart-required" ? " · 修改后需要重启" : ""}</small></span>{field.control === "boolean" ? <select value={value || field.defaultValue || "false"} onChange={(event) => onChange(event.target.value)}><option value="true"></option><option value="false"></option></select> : <input type={inputType} min={field.minimum} max={field.maximum} value={value} placeholder={field.defaultValue ?? ""} onChange={(event) => onChange(event.target.value)} />}</label>;
}
function configFieldGroups(fields: PluginConfigFieldResponse[]): Array<{ label: string; fields: PluginConfigFieldResponse[] }> {
const groups = new Map<string, PluginConfigFieldResponse[]>();
for (const field of fields) {
const label = field.configKey.startsWith("scum.Server") || field.configKey.startsWith("scum.Welcome") || field.configKey.startsWith("scum.Message") ? "服务器基础" : field.configKey.includes("Zombie") || field.configKey.includes("Character") || field.configKey.includes("Animal") ? "世界与生物" : field.configKey.includes("Vehicle") || field.configKey.includes("Fuel") ? "载具" : field.configKey.includes("Raid") || field.configKey.includes("Quest") || field.configKey.includes("NewPlayer") ? "玩法规则" : "其他设置";
groups.set(label, [...(groups.get(label) ?? []), field]);
}
return [...groups.entries()].map(([label, groupFields]) => ({ label, fields: groupFields }));
}
function configOperationKey(operation: string, serverId: string, fileKey: string, attempt = 1): string { return `web:server-config:${operation}:${serverId}:${fileKey}:${attempt}`; }
function formatReadTime(value: string): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN"); }