diff --git a/platform_web/contracts/pluginPageHost.ts b/platform_web/contracts/pluginPageHost.ts index 26a36ea..cf83716 100644 --- a/platform_web/contracts/pluginPageHost.ts +++ b/platform_web/contracts/pluginPageHost.ts @@ -19,6 +19,7 @@ export interface PluginPageFileReadSnapshot { } export interface PluginPageWorkspaceActions { + refreshWorkspace?: () => Promise; requestFile?: (fileKey: string) => Promise; getFileSnapshot?: (fileKey: string) => Promise; writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise; diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index a0ff362..ee1bd53 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -99,6 +99,8 @@ describe("PluginPageHostPage", () => { it("keeps file workspace callbacks stable across parent operational refreshes", () => { expect(hostSource).toContain("readyPluginRef.current = readyPlugin"); expect(hostSource).toContain("hostContextRef.current = hostContext"); + expect(hostSource).toContain("refreshWorkspace: async ()"); + expect(hostSource).toContain("platformApiClient.listGamePlugins()"); expect(hostSource).toContain("}, [pluginId, serverId]);"); expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);"); }); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index af3dc52..488abe7 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -67,11 +67,24 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded readyPluginRef.current = readyPlugin; hostContextRef.current = hostContext; const workspaceActions = useMemo(() => { - if (!pluginId || !serverId) return undefined; + if (!pluginId) return undefined; return { + refreshWorkspace: async () => { + const response = await platformApiClient.listGamePlugins(); + const plugin = response.items.find((candidate) => candidate.id === pluginId); + if (!plugin) { + throw new Error("未找到已注册的插件声明。"); + } + readyPluginRef.current = plugin; + setState({ status: "ready", plugin }); + return plugin.fileWorkspace; + }, requestFile: async (fileKey) => { const plugin = readyPluginRef.current; const context = hostContextRef.current; + if (!serverId) { + return { status: "denied", message: "插件页面没有绑定服务器,无法读取文件。" }; + } if (!plugin || !context) { return { status: "denied", message: "插件页面上下文尚未就绪。" }; } @@ -92,6 +105,9 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded }, getFileSnapshot: async (fileKey) => { const plugin = readyPluginRef.current; + if (!serverId) { + return { serverInstanceId: "", pluginId, key: fileKey, state: "unavailable", reason: "插件页面没有绑定服务器,无法读取文件快照。" }; + } if (!plugin) { return { serverInstanceId: serverId, pluginId, key: fileKey, state: "unavailable", reason: "插件页面上下文尚未就绪。" }; } @@ -113,6 +129,9 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded writeFile: async (fileKey, content, options) => { const plugin = readyPluginRef.current; const context = hostContextRef.current; + if (!serverId) { + return { status: "denied", message: "插件页面没有绑定服务器,无法写入文件。" }; + } if (!plugin || !context) { return { status: "denied", message: "插件页面上下文尚未就绪。" }; } diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 7da7737..12f9276 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -471,7 +471,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0} .section-tab-active{background:var(--primary-command-surface);border-color:var(--accent-deep);color:#fff;font-weight:700;box-shadow:0 10px 24px var(--candy-glow),inset 0 1px 0 var(--crystal-rim),0 0 18px var(--moonbeam)} .file-workbench{display:grid;grid-template-columns:minmax(200px,.82fr) minmax(0,2fr);gap:12px;align-items:start;min-width:0}.file-workbench-nav{display:grid;gap:12px;min-width:0;padding-right:12px;border-right:1px solid var(--line)}.file-workbench-directory{display:grid;gap:4px;min-width:0}.file-workbench-directory-heading{display:grid;gap:2px;padding:0 8px 4px;color:var(--ink-soft);font-size:12px}.file-workbench-directory-heading strong{color:var(--ink);font-size:13px}.file-workbench-directory-heading span{font-family:var(--font-mono);overflow-wrap:anywhere}.file-workbench-file{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;width:100%;min-height:42px;align-items:center;padding:7px 8px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--ink-soft);font:inherit;text-align:left;cursor:pointer}.file-workbench-file strong{min-width:0;color:var(--ink);overflow-wrap:anywhere}.file-workbench-file span{font-size:11px;color:var(--ink-faint);white-space:nowrap}.file-workbench-file:hover,.file-workbench-file:focus-visible{border-color:var(--line-strong);background:var(--control-surface);outline:0}.file-workbench-file[aria-current=page]{border-color:var(--accent);background:var(--primary-command-surface);color:#fff}.file-workbench-file[aria-current=page] strong,.file-workbench-file[aria-current=page] span{color:#fff}.file-workbench-detail{display:grid;gap:12px;min-width:0}.file-workbench-detail>.panel-header{padding-bottom:10px;border-bottom:1px solid var(--line)}.file-workbench-mode{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding-bottom:10px;border-bottom:1px solid var(--line)}.file-workbench-mode>span{margin-right:auto;color:var(--ink-soft);font-size:12px}.file-workbench-mode button{min-height:32px;padding:0 10px;border:1px solid var(--line-strong);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);font:inherit;cursor:pointer}.file-workbench-mode button:hover,.file-workbench-mode button:focus-visible{border-color:var(--accent);outline:0}.file-workbench-mode .file-workbench-mode-active{border-color:var(--accent);background:var(--primary-command-surface);color:#fff}.file-workbench-fields{display:grid;gap:0}.file-workbench-field{display:grid;grid-template-columns:minmax(0,1fr) minmax(150px,.72fr);gap:8px 12px;align-items:center;padding:10px 0;border-bottom:1px solid color-mix(in srgb,var(--line) 74%,transparent)}.file-workbench-field>span{display:grid;gap:3px;min-width:0}.file-workbench-field strong{color:var(--ink);font-size:13px}.file-workbench-field small{color:var(--ink-faint);overflow-wrap:anywhere}.file-workbench-field input{min-width:0;min-height:34px;border:1px solid var(--line-strong);border-radius:6px;padding:0 9px;background:var(--surface-solid);color:var(--ink);font:inherit}.file-workbench-field input:read-only,.file-workbench-field input:disabled{color:var(--ink-soft);background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.file-workbench-field>small{grid-column:1/-1}.file-workbench-raw{display:grid;gap:8px;min-width:0}.file-workbench-raw .runtime-task-log{margin:0;max-height:560px;overflow:auto}.file-workbench-raw small{color:var(--ink-faint);font-family:var(--font-mono);overflow-wrap:anywhere} -.file-workbench-picker{display:grid;gap:6px;color:var(--ink-soft);font-size:12px;font-weight:700}.file-workbench-picker select,.file-workbench-field select{width:100%;min-height:36px;border:1px solid var(--line-strong);border-radius:7px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit}.file-workbench-picker select:focus,.file-workbench-field select:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}.file-workbench-number-control{display:grid;grid-template-columns:minmax(0,1fr) 92px;gap:8px;align-items:center}.file-workbench-number-control input[type=range]{padding:0;accent-color:var(--accent)}.file-workbench-raw-editor{width:100%;min-height:420px;resize:vertical;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--code-surface);color:var(--code-ink);font-family:var(--font-mono);font-size:12px;line-height:1.55}.file-workbench-raw-editor:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}.file-workbench-actions{justify-content:flex-start}.file-workbench-unknown,.file-workbench-diff{display:grid;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}.file-workbench-unknown h3{margin:0;color:var(--ink);font-size:13px}.file-workbench-diff .runtime-task-log{max-height:260px}.file-workbench-encoding{padding-top:0} +.file-workbench-picker{display:grid;gap:6px;color:var(--ink-soft);font-size:12px;font-weight:700}.file-workbench-picker select,.file-workbench-field select{width:100%;min-height:36px;border:1px solid var(--line-strong);border-radius:7px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit}.file-workbench-picker select:focus,.file-workbench-field select:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}.file-workbench-nav-actions,.file-workbench-actions{justify-content:flex-start}.file-workbench-number-control{display:grid;grid-template-columns:minmax(0,1fr) 92px;gap:8px;align-items:center}.file-workbench-number-control input[type=range]{padding:0;accent-color:var(--accent)}.file-workbench-raw-editor{width:100%;min-height:420px;resize:vertical;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--code-surface);color:var(--code-ink);font-family:var(--font-mono);font-size:12px;line-height:1.55}.file-workbench-keyvalue-editor{min-height:260px}.file-workbench-raw-editor:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}.file-workbench-unknown,.file-workbench-diff{display:grid;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}.file-workbench-unknown h3{margin:0;color:var(--ink);font-size:13px}.file-workbench-diff .runtime-task-log{max-height:260px}.file-workbench-encoding{padding-top:0} @media (max-width:760px){.file-workbench{grid-template-columns:1fr}.file-workbench-nav{padding:0 0 10px;border-right:0;border-bottom:1px solid var(--line)}.file-workbench-field{grid-template-columns:1fr}.file-workbench-mode>span{width:100%;margin-right:0}.file-workbench-mode button{flex:1 1 0}} .overview-two-col{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} .log-filter-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center} diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index ee69550..71a2e78 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -1,4 +1,3 @@ -import { configurationCatalog } from "./schemas.js"; import type { SCUMConfigField, SCUMFeatureWorkspace, SCUMLogicalDirectory, SCUMLogicalFile } from "./contracts.js"; type StateSetter = (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; requestFile?: (fileKey: string) => Promise<{ status: string; message: string; jobId?: string }>; getFileSnapshot?: (fileKey: string) => Promise; 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 = { "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(react, "fields"); const [fieldDraft, setFieldDraft] = usePluginState>(react, {}); + const [pairDraft, setPairDraft] = usePluginState>(react, {}); const [rawDraft, setRawDraft] = usePluginState>(react, {}); const [rawEncoding, setRawEncoding] = usePluginState(react, "utf-8"); const [snapshot, setSnapshot] = usePluginState(react, null); const [requestState, setRequestState] = usePluginState(react, null); + const [workspaceRefreshState, setWorkspaceRefreshState] = usePluginState(react, null); const [writeState, setWriteState] = usePluginState(react, null); const [preview, setPreview] = usePluginState(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; fieldDraft: Record; setFieldDraft: StateSetter>; + file: SCUMLogicalFile; fields: readonly SCUMConfigField[]; mode: ConfigMode; setMode: StateSetter; fieldDraft: Record; setFieldDraft: StateSetter>; pairDraft: Record; setPairDraft: StateSetter>; rawDraft: Record; setRawDraft: StateSetter>; rawEncoding: RawEncoding; setRawEncoding: StateSetter; 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, setDraft: StateSetter>, values: Record, 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(react: ReactLike, initialState: T): [T, StateSetter 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 { + return fields.map((field) => `${field.configKey}=${values[field.configKey] ?? field.defaultValue}`).join("\n"); +} + +function parseModeledPairDraft(content: string, fields: readonly SCUMConfigField[]): { changes: Record; unknown: string[] } { + const byConfigKey = new Map(fields.map((field) => [field.configKey, field])); + const changes: Record = {}; + 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 ?? "不限"}` : ""; diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 8f4ffa5..0a48ec3 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -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; permissions?: string[]; directoryKey?: string; fileKey?: string } = {}) { +function renderAndCollect(options: { snapshot?: Record; permissions?: string[]; directoryKey?: string; fileKey?: string; workspace?: Record } = {}) { const nodes: string[] = []; const texts: string[] = []; const buttons: Array<{ label: string; disabled: boolean }> = []; @@ -116,15 +139,16 @@ function renderAndCollect(options: { snapshot?: Record; 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 })