From bb12137c4ede891147503f6a2291e54ade5b732a Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Sat, 25 Jul 2026 10:57:22 +0800 Subject: [PATCH] polish server deployment workflow --- .../components/ServerDeploymentWorkflow.tsx | 66 ++++++++++++------- platform_web/pages/ConsolePages.test.tsx | 6 ++ platform_web/pages/ServerDetailPage.test.tsx | 3 + platform_web/pages/ServerDetailPage.tsx | 12 +++- platform_web/pages/ServersPage.tsx | 3 +- platform_web/routes/routes.test.ts | 2 + platform_web/routes/routes.ts | 8 ++- 7 files changed, 74 insertions(+), 26 deletions(-) diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx index b922000..32c7818 100644 --- a/platform_web/components/ServerDeploymentWorkflow.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -20,13 +20,6 @@ interface ServerDeploymentWorkflowProps { onSubmit: (form: ServerCreateFormState, saveAsDraft: boolean) => Promise; } -const workflowSteps = [ - { label: "选择目标", icon: Compass }, - { label: "部署方式", icon: ServerCog }, - { label: "相关配置", icon: FolderCog }, - { label: "确认", icon: Rocket } -]; - export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initialForm, deployment, busy = false, onClose, onSubmit }: ServerDeploymentWorkflowProps) { const [step, setStep] = useState(0); const [form, setForm] = useState(initialForm); @@ -37,11 +30,21 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi const bindingFields = runtimeBindingFields(selectedPlugin, form.profileKey); const activeServer = kind === "edit" && Boolean(deployment); const isScum = selectedPlugin?.id === "game.scum"; + const needsTargetSelection = kind === "create" || !initialForm.runEndpointId; + const workflowSteps = kind === "create" + ? [{ label: "选择目标", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }] + : needsTargetSelection + ? [{ label: "选择运行节点", icon: Compass }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }] + : [{ label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]; + const targetStep = needsTargetSelection ? 0 : -1; + const modeStep = kind === "create" ? 1 : -1; + const configurationStep = kind === "create" ? 2 : needsTargetSelection ? 1 : 0; + const reviewStep = workflowSteps.length - 1; useEffect(() => { if (!open) return; setStep(0); - setSaveAsDraft(!initialForm.runEndpointId); + setSaveAsDraft(kind === "create" && !initialForm.runEndpointId); setForm(initialForm); }, [initialForm, kind, open]); @@ -61,8 +64,8 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi function updateBinding(key: string, value: string) { setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } })); } function canContinue() { - if (step === 0) return Boolean(form.pluginId && (saveAsDraft || form.runEndpointId)); - if (step === 2) { + if (step === targetStep) return kind === "create" ? Boolean(form.pluginId && (saveAsDraft || form.runEndpointId)) : Boolean(form.runEndpointId); + if (step === configurationStep) { if (kind === "create" && !form.name.trim()) return false; if (isScum && form.deploymentMode === "guided-install" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false; if (form.deploymentMode === "existing-server" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false; @@ -74,29 +77,30 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi async function submit(event: FormEvent) { event.preventDefault(); - if (step < workflowSteps.length - 1) { if (canContinue()) setStep((current) => current + 1); return; } + if (step < reviewStep) { if (canContinue()) setStep((current) => current + 1); return; } await onSubmit({ ...form, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft); } const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置"; - const actionLabel = kind === "create" ? (saveAsDraft ? "保存草稿" : "创建并部署") : (saveAsDraft ? "保存草稿设置" : "保存部署设置"); + const actionLabel = kind === "create" ? (saveAsDraft ? "保存草稿" : "创建、部署并前往构建 Run") : "保存部署设置"; return { if (!busy) onClose(); }}>
void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}> -
    {workflowSteps.map((item, index) => { const Icon = item.icon; return
  1. {index < step ? : }{index + 1}. {item.label}
  2. ; })}
- {step === 0 &&
-
先确定游戏插件决定可填写的游戏配置与启动预设。
再选择节点节点负责预检和执行,平台不会直接访问主机。
也可存为草稿草稿不会发起部署,稍后可从服务器详情继续。
-
- +
    {workflowSteps.map((item, index) => { const Icon = item.icon; return
  1. {index < step ? : }{index + 1}. {item.label}
  2. ; })}
+ {step === targetStep &&
+ {kind === "create" ?
选择游戏插件决定新建时需要的游戏配置与启动预设。
选择运行节点Run 在目标主机预检并执行;平台不会直接访问主机。
准备服务器目录下一步选择新建、接管或自定义方式,再填写对应目录。
:
这个草稿尚未绑定运行节点只需在这里补选一次。已绑定服务器编辑时会直接进入相关配置,不会重复要求选择目标。
} +
{kind === "create" && }
+ {kind === "create" && }
} - {step === 1 &&

选择你想让平台承担的方式。后续只显示这一方式需要的字段。

{isScum &&
SCUM 受控链路Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。
}
+ {step === modeStep &&

选择你想让平台承担的方式。后续只显示这一方式需要的字段。

{isScum &&
SCUM 受控链路Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。
}
setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} /> setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
} - {step === 2 &&
+ {step === configurationStep &&
{kind === "create" && } {kind === "create" && } + {kind === "edit" && } {form.deploymentMode === "guided-install" && } {form.deploymentMode === "existing-server" && } {form.deploymentMode === "custom-command" && } @@ -109,12 +113,12 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi ))}
{form.deploymentMode === "guided-install" && } - {form.deploymentMode === "existing-server" &&
接管只做预检与绑定不会执行安装命令,也不会把本页的默认游戏参数写进已有服务器。
} + {form.deploymentMode === "existing-server" && } {form.deploymentMode === "custom-command" &&
高级启动设置

只有自定义启动器需要这些设置。执行目录留空时,节点以服务器目录执行。

} {kind === "create" && bindingFields.length > 0 &&
运行连接设置

用于插件声明的逻辑连接,不是服务器目录或游戏配置。

{bindingFields.map((field) => )}
}
} - {step === 3 &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
目标{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
{saveAsDraft ? "本次只保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建并派发部署"}{saveAsDraft ? "后续从服务器详情选择节点并部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : isScum ? "Run 只有在 SCUM 配置映射与健康验证通过后才会报告安装成功。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}
} -
{step < workflowSteps.length - 1 ? : }
+ {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
目标{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
{saveAsDraft ? "本次只保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建、部署并打开 Run 构建"}{saveAsDraft ? "后续从服务器详情选择节点并部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : isScum ? "Run 只有在 SCUM 配置映射与健康验证通过后才会报告安装成功。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}
} +
{step < reviewStep ? : }
; } @@ -136,3 +140,21 @@ function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: return
确认后,{pluginName} 会这样安装“安装目录”就是游戏服务端、数据和配置将落地的位置;它不是命令执行目录,也不会在日志中回显。
{isScum ? "全部 4 步通过才算安装成功" : "节点按插件契约执行"}
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}

不会做:{isScum ? "不会跳过验证就标记成功;失败时不会暴露你的目录、命令或凭据。" : "不会把受保护的路径、命令或凭据回显给浏览器。"}

; } + +function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) { + const steps = isScum ? [ + { icon: FolderCog, title: "定位服务端根目录", copy: "填写包含 SCUM 服务端文件、数据与配置的目录,不是 Steam 库或 SteamCMD 目录。" }, + { icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、可执行文件、版本、Steam App 标记和所需端口。" }, + { icon: SlidersHorizontal, title: "只读扫描配置", copy: "识别 ServerSettings.ini 与现有参数;接管不会写入或覆盖它们。" }, + { icon: ServerCog, title: "绑定受控生命周期", copy: "记录这台实例由哪个 Run 管理,后续启动、停止和日志仍走受控通道。" }, + { icon: HeartPulse, title: "健康验证", copy: "确认端口、进程与配置可读后,才标记为接管成功。" } + ] : [ + { icon: FolderCog, title: "定位服务端根目录", copy: "填写已有服务端文件、数据与配置所在的主目录。" }, + { icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、插件识别和端口是否可用。" }, + { icon: SlidersHorizontal, title: "只读扫描配置", copy: "读取插件需要的现有状态,不把新建默认值写进服务器。" }, + { icon: ServerCog, title: "绑定受控生命周期", copy: "后续运行操作由绑定的 Run 通过平台通道执行。" }, + { icon: HeartPulse, title: "健康验证", copy: "验证通过后才标记为接管成功。" } + ]; + + return
确认后,{pluginName} 会这样接管目录只会交给目标 Run 在本机使用;平台、浏览器和日志都不会显示原始路径。
先扫描,后绑定
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}
{isScum ?

SCUM 与 SteamCMD:接管只需要服务端根目录,不需要填写 SteamCMD 目录。Run 可能按本机策略检查 SteamCMD 是否可用,但它不是接管输入。
升级:接管不会升级游戏;当前平台尚未提供 SCUM 服务端的受控升级任务,不能承诺自动升级。升级能力需要单独的 SteamCMD 更新任务与备份/健康验证流程。

:

不会做:不会重新安装、覆盖已有游戏配置,或把受保护路径回显给浏览器。

}
; +} diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index 56d7d76..695f0cd 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -186,6 +186,12 @@ describe("first-party console pages", () => { expect(serverDeploymentWorkflowSource).toContain("全部 4 步通过才算安装成功"); expect(serverDeploymentWorkflowSource).toContain("已有服务器目录"); expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置"); + expect(serverDeploymentWorkflowSource).toContain("接管已有服务器执行流程"); + expect(serverDeploymentWorkflowSource).toContain("不需要填写 SteamCMD 目录"); + expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的受控升级任务"); + expect(serverDeploymentWorkflowSource).toContain("已绑定服务器编辑时会直接进入相关配置"); + expect(serverDeploymentWorkflowSource).toContain("可在此调整部署方式;不会重复要求选择已绑定的运行节点"); + expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" })'); expect(serverDeploymentWorkflowSource).toContain("运行连接设置"); expect(serverDeploymentWorkflowSource).toContain('type={field.sensitive ? "password" : "text"}'); expect(serversPageSource).toContain("serverCreateRequestFromForm(nextForm)"); diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index 6a2c3e7..a84a2ca 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -75,6 +75,9 @@ describe("ServerDetailPage config write approval", () => { }); it("surfaces run distribution and client-manager workflows through platform-mediated APIs", () => { + expect(serverDetailPageSource).toContain('params.routeKey !== "run-builder"'); + expect(serverDetailPageSource).toContain('id="run-builder"'); + expect(serverDetailPageSource).toContain("scrollIntoView"); expect(serverDetailPageSource).toContain("getServerRuntimeActions"); expect(serverDetailPageSource).toContain("generateRunDistribution"); expect(serverDetailPageSource).toContain("downloadLatestRunDistribution"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 8a37b9b..4712f67 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -167,6 +167,16 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa void refresh(); }, [refresh]); + useEffect(() => { + if (params.routeKey !== "run-builder" || instance.status !== "ready" || typeof document === "undefined") return; + const frame = window.requestAnimationFrame(() => { + const target = document.getElementById("run-builder"); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + target?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(frame); + }, [instance.status, params.routeKey]); + const serverOperations = useMemo( () => operations.operations.filter((operation) => operation.targetId === serverId || operation.targetId.startsWith(`${serverId}:`)), [operations.operations, serverId] @@ -1007,7 +1017,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati } return ( -
+

运行分发 diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 89ac899..4733826 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -157,10 +157,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName }); try { const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(nextForm)); - operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `已保存草稿 ${result.instance.id};可在 Run 注册后绑定并部署。`, result.job.id ? result.job : undefined); + operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `已保存草稿 ${result.instance.id};可在 Run 注册后绑定并部署。`, result.job.id ? result.job : undefined); setForm(defaultServerCreateForm(plugins, endpoints)); setShowCreate(false); await refresh(); + if (result.job.id) onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" }); } catch (error) { operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId); } diff --git a/platform_web/routes/routes.test.ts b/platform_web/routes/routes.test.ts index 412d50e..ed0267a 100644 --- a/platform_web/routes/routes.test.ts +++ b/platform_web/routes/routes.test.ts @@ -39,6 +39,8 @@ describe("console shell routes", () => { expect(resolved.route.id).toBe("serverDetail"); expect(resolved.params.serverId).toBe("server-example-1"); expect(hashForPage("serverDetail", { serverId: "server-example-1" })).toBe("#/servers/server-example-1"); + expect(hashForPage("serverDetail", { serverId: "server-example-1", routeKey: "run-builder" })).toBe("#/servers/server-example-1?focus=run-builder"); + expect(resolveRouteHash("#/servers/server-example-1?focus=run-builder", platformAdmin).params).toEqual({ serverId: "server-example-1", routeKey: "run-builder" }); }); it("round-trips hosted plugin page hashes with server context", () => { diff --git a/platform_web/routes/routes.ts b/platform_web/routes/routes.ts index cfab68d..4103248 100644 --- a/platform_web/routes/routes.ts +++ b/platform_web/routes/routes.ts @@ -100,7 +100,10 @@ export function routeForPage(pageId: PageId): PageRoute { export function hashForPage(pageId: PageId, params: PageParams = {}): string { const route = routeForPage(pageId); if (pageId === "serverDetail" && params.serverId) { - return `#/servers/${encodeURIComponent(params.serverId)}`; + const query = new URLSearchParams(); + if (params.routeKey) query.set("focus", params.routeKey); + const suffix = query.toString(); + return `#/servers/${encodeURIComponent(params.serverId)}${suffix ? `?${suffix}` : ""}`; } if (pageId === "pluginPage" && params.pluginId && params.routeKey) { const query = new URLSearchParams(); @@ -144,7 +147,8 @@ export function resolveRouteHash(hash: string, user?: CurrentUserView): Resolved return fallback; } if (segments[0] === "servers" && segments.length > 1) { - return { route: routeForPage("serverDetail"), params: { serverId: decodeURIComponent(segments[1]) } }; + const routeKey = new URLSearchParams(rawQuery).get("focus") ?? undefined; + return { route: routeForPage("serverDetail"), params: { serverId: decodeURIComponent(segments[1]), routeKey } }; } if (segments[0] === "plugin-pages" && segments.length > 2) { const serverId = new URLSearchParams(rawQuery).get("serverInstanceId") ?? undefined;