Fix SCUM file workbench interactions
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { configurationCatalog } from "./schemas.js";
|
||||
import type { SCUMConfigField, SCUMFeatureWorkspace, SCUMLogicalDirectory, SCUMLogicalFile } from "./contracts.js";
|
||||
|
||||
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
|
||||
@@ -29,6 +28,7 @@ export type SCUMPageContext = {
|
||||
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
|
||||
workspace?: SCUMFeatureWorkspace;
|
||||
workspaceActions?: {
|
||||
refreshWorkspace?: () => Promise<SCUMFeatureWorkspace | undefined>;
|
||||
requestFile?: (fileKey: string) => Promise<{ status: string; message: string; jobId?: string }>;
|
||||
getFileSnapshot?: (fileKey: string) => Promise<SCUMFileReadSnapshot>;
|
||||
writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise<{ status: string; message: string; jobId?: string }>;
|
||||
@@ -36,29 +36,13 @@ export type SCUMPageContext = {
|
||||
};
|
||||
|
||||
type NormalizedWorkspace = { defaultDirectoryKey: string; directories: readonly SCUMLogicalDirectory[]; files: readonly SCUMLogicalFile[]; configFields: readonly SCUMConfigField[] };
|
||||
type ConfigMode = "fields" | "source";
|
||||
type ConfigMode = "fields" | "pairs" | "source";
|
||||
type RawEncoding = "utf-8" | "utf-16le";
|
||||
type FileRequestState = { fileKey: string; status: string; message: string; jobId?: string } | null;
|
||||
type WorkspaceRefreshState = { status: string; message: string } | null;
|
||||
type PreviewState = { fileKey: string; mode: ConfigMode; summary: string; proposedContent: string; lines: readonly DiffLine[] } | null;
|
||||
type DiffLine = { kind: "same" | "added" | "removed"; text: string };
|
||||
|
||||
const fallbackDirectories: readonly SCUMLogicalDirectory[] = [
|
||||
{ key: "scum-config", label: "服务器配置", scope: "config" },
|
||||
{ key: "scum-logs", label: "日志文件", scope: "logs" }
|
||||
];
|
||||
const fallbackFiles: readonly SCUMLogicalFile[] = [
|
||||
{ key: "scum-server-settings", directoryKey: "scum-config", label: "ServerSettings.ini", kind: "config", editable: true },
|
||||
{ key: "scum-game-config", directoryKey: "scum-config", label: "Game.ini", kind: "config" },
|
||||
{ key: "scum-engine-config", directoryKey: "scum-config", label: "Engine.ini", kind: "config" },
|
||||
{ key: "scum-game-user-settings", directoryKey: "scum-config", label: "GameUserSettings.ini", kind: "config" },
|
||||
{ key: "scum-admin-log", directoryKey: "scum-logs", label: "Admin.log", kind: "log", streamKey: "scum.admin" },
|
||||
{ key: "scum-chat-log", directoryKey: "scum-logs", label: "Chat.log", kind: "log", streamKey: "scum.chat" },
|
||||
{ key: "scum-kill-log", directoryKey: "scum-logs", label: "Kill.log", kind: "log", streamKey: "scum.kill" },
|
||||
{ key: "scum-login-log", directoryKey: "scum-logs", label: "Login.log", kind: "log", streamKey: "scum.login" },
|
||||
{ key: "scum-server-log", directoryKey: "scum-logs", label: "Server.log", kind: "log", streamKey: "scum.server" }
|
||||
];
|
||||
const directoryRelativePaths: Record<string, string> = { "scum-config": "/SCUM/Saved/Config/WindowsServer", "scum-logs": "/SCUM/Saved/SaveFiles/Logs" };
|
||||
|
||||
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
|
||||
const e = react.createElement;
|
||||
const workspace = normalizeWorkspace(input.workspace);
|
||||
@@ -71,10 +55,12 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
const selectedFields = selectedFile ? workspace.configFields.filter((field) => field.fileKey === selectedFile.key) : [];
|
||||
const [configMode, setConfigMode] = usePluginState<ConfigMode>(react, "fields");
|
||||
const [fieldDraft, setFieldDraft] = usePluginState<Record<string, string>>(react, {});
|
||||
const [pairDraft, setPairDraft] = usePluginState<Record<string, string>>(react, {});
|
||||
const [rawDraft, setRawDraft] = usePluginState<Record<string, string>>(react, {});
|
||||
const [rawEncoding, setRawEncoding] = usePluginState<RawEncoding>(react, "utf-8");
|
||||
const [snapshot, setSnapshot] = usePluginState<SCUMFileReadSnapshot | null>(react, null);
|
||||
const [requestState, setRequestState] = usePluginState<FileRequestState>(react, null);
|
||||
const [workspaceRefreshState, setWorkspaceRefreshState] = usePluginState<WorkspaceRefreshState>(react, null);
|
||||
const [writeState, setWriteState] = usePluginState<FileRequestState>(react, null);
|
||||
const [preview, setPreview] = usePluginState<PreviewState>(react, null);
|
||||
const scoped = Boolean(input.serverInstanceId);
|
||||
@@ -99,6 +85,29 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
}, [input.serverInstanceId, input.workspaceActions?.getFileSnapshot, selectedFile?.key]);
|
||||
}
|
||||
|
||||
function refreshWorkspaceCatalog() {
|
||||
if (!input.workspaceActions?.refreshWorkspace) return;
|
||||
setWorkspaceRefreshState({ status: "pending", message: "正在从平台刷新声明文件列表..." });
|
||||
void input.workspaceActions.refreshWorkspace().then((next) => {
|
||||
const count = next?.files?.length ?? 0;
|
||||
setWorkspaceRefreshState({ status: "ok", message: `文件列表已刷新:${count} 个声明文件。` });
|
||||
}).catch((error) => {
|
||||
setWorkspaceRefreshState({ status: "error", message: error instanceof Error ? error.message : "文件列表刷新失败。" });
|
||||
});
|
||||
}
|
||||
|
||||
function loadFileSnapshot(fileKey: string, poll = false, attempt = 0) {
|
||||
if (!input.workspaceActions?.getFileSnapshot) return;
|
||||
void input.workspaceActions.getFileSnapshot(fileKey).then((next) => {
|
||||
setSnapshot(next);
|
||||
if (poll && next.state !== "ready" && attempt < 8) {
|
||||
globalThis.setTimeout(() => loadFileSnapshot(fileKey, true, attempt + 1), Math.min(3200, 600 + attempt * 350));
|
||||
}
|
||||
}).catch((error) => {
|
||||
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: fileKey, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" });
|
||||
});
|
||||
}
|
||||
|
||||
function selectDirectory(directoryKey: string) {
|
||||
const nextFiles = workspace.files.filter((file) => file.directoryKey === directoryKey);
|
||||
const nextFile = nextFiles.find((file) => file.kind === "config") ?? nextFiles[0];
|
||||
@@ -124,6 +133,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
setRequestState({ fileKey: selectedFile.key, status: result.status, message: result.message, jobId: result.jobId });
|
||||
if (result.status === "queued" || result.status === "ok") {
|
||||
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "pending", jobId: result.jobId, reason: "等待运行端完成文件读取。" });
|
||||
loadFileSnapshot(selectedFile.key, true);
|
||||
}
|
||||
}).catch((error) => setRequestState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件读取请求失败。" }));
|
||||
}
|
||||
@@ -131,9 +141,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
function refreshSelectedSnapshot() {
|
||||
if (!selectedFile || !input.workspaceActions?.getFileSnapshot) return;
|
||||
setPreview(null);
|
||||
void input.workspaceActions.getFileSnapshot(selectedFile.key).then(setSnapshot).catch((error) => {
|
||||
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" });
|
||||
});
|
||||
loadFileSnapshot(selectedFile.key);
|
||||
}
|
||||
|
||||
function previewModeChange(mode: ConfigMode, content: string, current: string) {
|
||||
@@ -157,7 +165,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
e("span", { className: "page-status" }, scoped ? "声明文件工作区" : "插件页面未绑定服务器")
|
||||
),
|
||||
e("div", { className: "file-workbench" },
|
||||
navigationPane(e, workspace, effectiveDirectoryKey, directoryFiles, selectedFile?.key, selectDirectory, selectFile),
|
||||
navigationPane(e, workspace, effectiveDirectoryKey, directoryFiles, selectedFile?.key, workspaceRefreshState, Boolean(input.workspaceActions?.refreshWorkspace), selectDirectory, selectFile, refreshWorkspaceCatalog),
|
||||
selectedFile
|
||||
? fileDetail(e, {
|
||||
file: selectedFile,
|
||||
@@ -166,6 +174,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
setMode: setConfigMode,
|
||||
fieldDraft,
|
||||
setFieldDraft,
|
||||
pairDraft,
|
||||
setPairDraft,
|
||||
rawDraft,
|
||||
setRawDraft,
|
||||
rawEncoding,
|
||||
@@ -189,32 +199,38 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
);
|
||||
}
|
||||
|
||||
function navigationPane(e: ReactLike["createElement"], workspace: NormalizedWorkspace, directoryKey: string, directoryFiles: readonly SCUMLogicalFile[], selectedFileKey: string | undefined, onDirectoryChange: (directoryKey: string) => void, onFileSelect: (file: SCUMLogicalFile) => void) {
|
||||
function navigationPane(e: ReactLike["createElement"], workspace: NormalizedWorkspace, directoryKey: string, directoryFiles: readonly SCUMLogicalFile[], selectedFileKey: string | undefined, refreshState: WorkspaceRefreshState, canRefreshWorkspace: boolean, onDirectoryChange: (directoryKey: string) => void, onFileSelect: (file: SCUMLogicalFile) => void, onRefreshWorkspace: () => void) {
|
||||
const activeDirectory = workspace.directories.find((directory) => directory.key === directoryKey);
|
||||
const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0];
|
||||
return e("aside", { className: "file-workbench-nav", "aria-label": "SCUM 文件两级菜单" },
|
||||
e("div", { className: "console-row-actions file-workbench-nav-actions" },
|
||||
e("button", { type: "button", className: "icon-command", disabled: !canRefreshWorkspace, onClick: onRefreshWorkspace }, "刷新文件列表")
|
||||
),
|
||||
refreshState ? e("p", { className: "page-status", "data-state": refreshState.status }, refreshState.message) : null,
|
||||
e("label", { className: "file-workbench-picker" },
|
||||
e("span", null, "目录"),
|
||||
e("select", { value: directoryKey, onChange: (event: { target: { value: string } }) => onDirectoryChange(event.target.value), "aria-label": "选择目录" },
|
||||
workspace.directories.map((directory) => e("option", { key: directory.key, value: directory.key }, directoryRelativePath(directory.key) ?? directory.label))
|
||||
)
|
||||
),
|
||||
e("label", { className: "file-workbench-picker" },
|
||||
e("span", null, "文件"),
|
||||
e("select", { value: selectedFile?.key ?? "", onChange: (event: { target: { value: string } }) => { const file = directoryFiles.find((candidate) => candidate.key === event.target.value); if (file) onFileSelect(file); }, "aria-label": "选择文件" },
|
||||
directoryFiles.length ? directoryFiles.map((file) => e("option", { key: file.key, value: file.key }, file.label)) : e("option", { value: "" }, "没有已声明文件")
|
||||
workspace.directories.length ? workspace.directories.map((directory) => e("option", { key: directory.key, value: directory.key }, `${directory.label} · ${directoryScopeLabel(directory.scope)}`)) : e("option", { value: "" }, "没有已声明目录")
|
||||
)
|
||||
),
|
||||
e("div", { className: "file-workbench-directory-heading" },
|
||||
e("strong", null, activeDirectory?.label ?? "插件声明目录"),
|
||||
e("span", null, directoryRelativePath(directoryKey) ?? "插件逻辑目录"),
|
||||
e("span", null, activeDirectory ? `${activeDirectory.key} · ${directoryScopeLabel(activeDirectory.scope)}` : "等待平台声明"),
|
||||
e("small", null, `${directoryFiles.length} 个声明文件`)
|
||||
),
|
||||
e("div", { className: "file-workbench-directory", role: "listbox", "aria-label": "声明文件列表" },
|
||||
directoryFiles.length
|
||||
? directoryFiles.map((file) => e("button", { key: file.key, type: "button", className: "file-workbench-file", role: "option", "aria-selected": file.key === selectedFile?.key, "aria-current": file.key === selectedFile?.key ? "page" : undefined, onClick: () => onFileSelect(file) },
|
||||
e("strong", null, file.label),
|
||||
e("span", null, file.kind === "config" ? file.editable === false ? "配置只读" : "配置可写" : `日志${file.streamKey ? ` · ${file.streamKey}` : ""}`)
|
||||
))
|
||||
: e("p", { className: "page-status" }, "当前目录没有平台声明文件。")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function fileDetail(e: ReactLike["createElement"], props: {
|
||||
file: SCUMLogicalFile; fields: readonly SCUMConfigField[]; mode: ConfigMode; setMode: StateSetter<ConfigMode>; fieldDraft: Record<string, string>; setFieldDraft: StateSetter<Record<string, string>>;
|
||||
file: SCUMLogicalFile; fields: readonly SCUMConfigField[]; mode: ConfigMode; setMode: StateSetter<ConfigMode>; fieldDraft: Record<string, string>; setFieldDraft: StateSetter<Record<string, string>>; pairDraft: Record<string, string>; setPairDraft: StateSetter<Record<string, string>>;
|
||||
rawDraft: Record<string, string>; setRawDraft: StateSetter<Record<string, string>>; rawEncoding: RawEncoding; setRawEncoding: StateSetter<RawEncoding>; snapshot: SCUMFileReadSnapshot | null;
|
||||
requestState: FileRequestState; writeState: FileRequestState; preview: PreviewState; canFilesRead: boolean; canFilesWrite: boolean; canRequestFile: boolean; canRefreshSnapshot: boolean; canWriteFile: boolean;
|
||||
onRequestFile: () => void; onRefreshSnapshot: () => void; onPreview: (mode: ConfigMode, content: string, current: string) => void; onWrite: () => void;
|
||||
@@ -224,7 +240,7 @@ function fileDetail(e: ReactLike["createElement"], props: {
|
||||
const snapshotValues = props.snapshot?.state === "ready" ? parseIniAssignments(currentContent) : {};
|
||||
return e("article", { className: "file-workbench-detail", "aria-label": `文件 ${props.file.label}` },
|
||||
e("div", { className: "panel-header" },
|
||||
e("div", null, e("h2", null, props.file.label), e("p", { className: "provider-id" }, `${directoryRelativePath(props.file.directoryKey) ?? "插件声明目录"} · ${props.file.kind === "config" ? "配置文件" : "日志文件"}`)),
|
||||
e("div", null, e("h2", null, props.file.label), e("p", { className: "provider-id" }, `${props.file.directoryKey} · ${props.file.kind === "config" ? "配置文件" : "日志文件"}${props.file.streamKey ? ` · ${props.file.streamKey}` : ""}`)),
|
||||
e("div", { className: "console-row-actions" },
|
||||
e("button", { type: "button", className: "icon-command", disabled: !props.canFilesRead || !props.canRequestFile, onClick: props.onRequestFile }, "读取文件"),
|
||||
e("button", { type: "button", className: "icon-command", disabled: !props.canRefreshSnapshot, onClick: props.onRefreshSnapshot }, "刷新结果")
|
||||
@@ -237,12 +253,15 @@ function fileDetail(e: ReactLike["createElement"], props: {
|
||||
? e("div", { className: "file-workbench-mode", role: "tablist", "aria-label": "表单配置与原文模式" },
|
||||
e("span", null, "编辑视图"),
|
||||
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "fields", className: props.mode === "fields" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("fields") }, "配置表单"),
|
||||
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "pairs", className: props.mode === "pairs" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("pairs") }, "键值视图"),
|
||||
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "source", className: props.mode === "source" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("source") }, "原文模式")
|
||||
)
|
||||
: null,
|
||||
canShowFields && props.mode === "fields"
|
||||
? modeledConfigurationFields(e, props.file, props.fields, props.fieldDraft, props.setFieldDraft, snapshotValues, currentContent, props.snapshot?.state === "ready", props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
|
||||
: rawFileView(e, props.file, currentContent, props.rawDraft, props.setRawDraft, props.snapshot, props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
|
||||
: canShowFields && props.mode === "pairs"
|
||||
? modeledKeyValueEditor(e, props.file, props.fields, props.pairDraft, props.setPairDraft, snapshotValues, currentContent, props.snapshot?.state === "ready", props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
|
||||
: rawFileView(e, props.file, currentContent, props.rawDraft, props.setRawDraft, props.snapshot, props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,6 +298,25 @@ function modeledConfigurationFields(e: ReactLike["createElement"], file: SCUMLog
|
||||
);
|
||||
}
|
||||
|
||||
function modeledKeyValueEditor(e: ReactLike["createElement"], file: SCUMLogicalFile, fields: readonly SCUMConfigField[], draft: Record<string, string>, setDraft: StateSetter<Record<string, string>>, values: Record<string, string>, currentContent: string, hasReadSnapshot: boolean, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) {
|
||||
const editable = hasReadSnapshot && canFilesWrite && file.editable !== false;
|
||||
const currentPairs = modeledPairText(fields, values);
|
||||
const rawValue = draft[file.key] ?? currentPairs;
|
||||
const parsed = parseModeledPairDraft(rawValue, fields);
|
||||
const proposedContent = composeIniContent(currentContent, fields, parsed.changes);
|
||||
const hasChanges = proposedContent !== currentContent && Object.keys(parsed.changes).length > 0;
|
||||
return e("div", { className: "file-workbench-raw file-workbench-pairs" },
|
||||
e("textarea", { className: "file-workbench-raw-editor file-workbench-keyvalue-editor", value: rawValue, readOnly: !editable, spellCheck: false, "aria-label": `${file.label} 键值视图编辑`, onChange: (event: { target: { value: string } }) => setDraft((current) => ({ ...current, [file.key]: event.target.value })) }),
|
||||
parsed.unknown.length ? e("section", { className: "file-workbench-unknown", "aria-label": "不可写键值" }, e("h3", null, "不可写键值"), parsed.unknown.map((key) => e("div", { className: "console-row", key }, e("span", null, key), e("strong", null, "未在声明字段中")))) : null,
|
||||
e("div", { className: "console-row-actions file-workbench-actions" },
|
||||
e("button", { type: "button", className: "icon-command", disabled: !editable || parsed.unknown.length > 0 || !hasChanges, onClick: () => onPreview("pairs", proposedContent, currentContent) }, "预览改动"),
|
||||
e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "pairs", onClick: onWrite }, "提交写入")
|
||||
),
|
||||
preview && preview.mode === "pairs" ? diffPreview(e, preview) : null,
|
||||
e("p", { className: "page-status" }, hasReadSnapshot ? "键值视图适合批量调整已声明字段;未知键只展示,不会被作为安全写入目标。" : "先读取文件后才启用键值编辑。")
|
||||
);
|
||||
}
|
||||
|
||||
function fieldControl(e: ReactLike["createElement"], field: SCUMConfigField, value: string, editable: boolean, onChange: (next: string) => void) {
|
||||
if (field.control === "boolean") {
|
||||
return e("select", { value: normalizeBoolean(value) ? "true" : "false", disabled: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) },
|
||||
@@ -328,17 +366,42 @@ function usePluginState<T>(react: ReactLike, initialState: T): [T, StateSetter<T
|
||||
}
|
||||
|
||||
function normalizeWorkspace(workspace?: SCUMFeatureWorkspace): NormalizedWorkspace {
|
||||
const directories = workspace?.directories?.length ? workspace.directories : fallbackDirectories;
|
||||
const files = workspace?.files?.length ? workspace.files : fallbackFiles;
|
||||
const configFields = workspace?.configFields?.length ? workspace.configFields : configurationCatalog;
|
||||
return { defaultDirectoryKey: workspace?.defaultDirectoryKey && directories.some((directory) => directory.key === workspace.defaultDirectoryKey) ? workspace.defaultDirectoryKey : directories[0]?.key ?? "scum-config", directories, files, configFields };
|
||||
const directories = workspace?.directories ?? [];
|
||||
const directoryKeys = new Set(directories.map((directory) => directory.key));
|
||||
const files = (workspace?.files ?? []).filter((file) => directoryKeys.has(file.directoryKey));
|
||||
const fileKeys = new Set(files.map((file) => file.key));
|
||||
const configFields = (workspace?.configFields ?? []).filter((field) => fileKeys.has(field.fileKey));
|
||||
return { defaultDirectoryKey: workspace?.defaultDirectoryKey && directories.some((directory) => directory.key === workspace.defaultDirectoryKey) ? workspace.defaultDirectoryKey : directories[0]?.key ?? "", directories, files, configFields };
|
||||
}
|
||||
|
||||
function initialFileKey(workspace: NormalizedWorkspace, directoryKey: string): string {
|
||||
return workspace.files.find((file) => file.directoryKey === directoryKey && file.kind === "config")?.key ?? workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? workspace.files[0]?.key ?? "";
|
||||
}
|
||||
|
||||
function directoryRelativePath(key: string): string | undefined { return directoryRelativePaths[key]; }
|
||||
function directoryScopeLabel(scope: SCUMLogicalDirectory["scope"]): string {
|
||||
return scope === "logs" ? "日志声明" : "配置声明";
|
||||
}
|
||||
|
||||
function modeledPairText(fields: readonly SCUMConfigField[], values: Record<string, string>): string {
|
||||
return fields.map((field) => `${field.configKey}=${values[field.configKey] ?? field.defaultValue}`).join("\n");
|
||||
}
|
||||
|
||||
function parseModeledPairDraft(content: string, fields: readonly SCUMConfigField[]): { changes: Record<string, string>; unknown: string[] } {
|
||||
const byConfigKey = new Map(fields.map((field) => [field.configKey, field]));
|
||||
const changes: Record<string, string> = {};
|
||||
const unknown: string[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
const parsed = parseIniAssignment(line);
|
||||
if (!parsed) continue;
|
||||
const field = byConfigKey.get(parsed.key);
|
||||
if (!field) {
|
||||
unknown.push(parsed.key);
|
||||
continue;
|
||||
}
|
||||
changes[field.key] = parsed.value;
|
||||
}
|
||||
return { changes, unknown: [...new Set(unknown)] };
|
||||
}
|
||||
|
||||
function controlLabel(field: SCUMConfigField): string {
|
||||
const range = field.minimum !== undefined || field.maximum !== undefined ? `范围 ${field.minimum ?? "不限"}-${field.maximum ?? "不限"}` : "";
|
||||
|
||||
@@ -9,6 +9,19 @@ import { configurationCatalog, validateConfigPatch, validateStatePatch, validate
|
||||
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
|
||||
|
||||
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
|
||||
const declaredWorkspace = {
|
||||
defaultDirectoryKey: "scum-config",
|
||||
directories: [
|
||||
{ key: "scum-config", label: "服务器配置", scope: "config" },
|
||||
{ key: "scum-logs", label: "日志文件", scope: "logs" }
|
||||
],
|
||||
files: [
|
||||
{ key: "scum-server-settings", directoryKey: "scum-config", label: "ServerSettings.ini", kind: "config", editable: true },
|
||||
{ key: "scum-game-config", directoryKey: "scum-config", label: "Game.ini", kind: "config" },
|
||||
{ key: "scum-admin-log", directoryKey: "scum-logs", label: "Admin.log", kind: "log", streamKey: "scum.admin" }
|
||||
],
|
||||
configFields: configurationCatalog
|
||||
};
|
||||
|
||||
describe("SCUM plugin feature module", () => {
|
||||
it("owns runtime allowlists without a version gate", () => {
|
||||
@@ -56,17 +69,27 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(view.nodes).toContain("section:SCUM 文件管理");
|
||||
expect(view.nodes).toContain("aside:SCUM 文件两级菜单");
|
||||
expect(view.nodes).toContain("article:文件 ServerSettings.ini");
|
||||
expect(view.texts.join("\n")).toContain("/SCUM/Saved/Config/WindowsServer");
|
||||
expect(view.texts.join("\n")).toContain("/SCUM/Saved/SaveFiles/Logs");
|
||||
expect(view.texts.join("\n")).toContain("scum-config · 配置声明");
|
||||
expect(view.texts).toContain("刷新文件列表");
|
||||
expect(view.texts).toContain("ServerSettings.ini");
|
||||
expect(view.texts).toContain("Game.ini");
|
||||
expect(view.texts).toContain("配置表单");
|
||||
expect(view.texts).toContain("键值视图");
|
||||
expect(view.texts).toContain("原文模式");
|
||||
expect(view.buttons.find((button) => button.label === "刷新文件列表")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "读取文件")?.disabled).toBe(false);
|
||||
expect(view.buttons.find((button) => button.label === "刷新结果")?.disabled).toBe(false);
|
||||
for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText);
|
||||
});
|
||||
|
||||
it("does not invent a hardcoded SCUM file list when the platform workspace is missing", () => {
|
||||
const view = renderAndCollect({ workspace: {} });
|
||||
expect(view.texts.join("\n")).toContain("当前插件没有可展示的声明文件。");
|
||||
expect(view.texts).not.toContain("ServerSettings.ini");
|
||||
expect(view.texts).not.toContain("Game.ini");
|
||||
expect(pageSource).not.toContain("fallbackFiles");
|
||||
});
|
||||
|
||||
it("keeps declared log files in read-only raw view with encoding controls", () => {
|
||||
const view = renderAndCollect({ directoryKey: "scum-logs", fileKey: "scum-admin-log" });
|
||||
expect(view.nodes).toContain("article:文件 Admin.log");
|
||||
@@ -90,15 +113,15 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(view.buttons.find((button) => button.label === "提交写入")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("loads snapshots on selection or manual refresh without interval polling", () => {
|
||||
it("loads snapshots on selection or manual refresh and bounded post-request refresh", () => {
|
||||
expect(pageSource).toContain("getFileSnapshot(selectedFile.key)");
|
||||
expect(pageSource).toContain("刷新结果");
|
||||
expect(pageSource).toContain("loadFileSnapshot(selectedFile.key, true)");
|
||||
expect(pageSource).not.toContain("setInterval");
|
||||
expect(pageSource).not.toContain("setTimeout");
|
||||
});
|
||||
});
|
||||
|
||||
function renderAndCollect(options: { snapshot?: Record<string, unknown>; permissions?: string[]; directoryKey?: string; fileKey?: string } = {}) {
|
||||
function renderAndCollect(options: { snapshot?: Record<string, unknown>; permissions?: string[]; directoryKey?: string; fileKey?: string; workspace?: Record<string, unknown> } = {}) {
|
||||
const nodes: string[] = [];
|
||||
const texts: string[] = [];
|
||||
const buttons: Array<{ label: string; disabled: boolean }> = [];
|
||||
@@ -116,15 +139,16 @@ function renderAndCollect(options: { snapshot?: Record<string, unknown>; permiss
|
||||
stateCall += 1;
|
||||
if (stateCall === 1 && options.directoryKey) return [options.directoryKey as T, () => undefined];
|
||||
if (stateCall === 2 && options.fileKey) return [options.fileKey as T, () => undefined];
|
||||
if (stateCall === 7 && options.snapshot) return [options.snapshot as T, () => undefined];
|
||||
if (stateCall === 8 && options.snapshot) return [options.snapshot as T, () => undefined];
|
||||
return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined];
|
||||
}
|
||||
};
|
||||
renderPluginPage(react, {
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.files.read", "server.files.write", "server.logs.read"] },
|
||||
availability: { available: true, features: [{ key: "config.manage", available: true }] },
|
||||
workspace: {},
|
||||
workspace: options.workspace ?? declaredWorkspace,
|
||||
workspaceActions: {
|
||||
refreshWorkspace: async () => declaredWorkspace,
|
||||
requestFile: async (fileKey: string) => ({ status: "queued", message: fileKey }),
|
||||
getFileSnapshot: async (fileKey: string) => ({ serverInstanceId: "server-1", pluginId: "game.scum", key: fileKey, state: "not-read" }),
|
||||
writeFile: async (fileKey: string) => ({ status: "queued", message: fileKey })
|
||||
|
||||
Reference in New Issue
Block a user