import { CheckCircle2, CircleDashed, Compass, Download, FolderCog, HeartPulse, Rocket, ScanSearch, ServerCog, SlidersHorizontal } from "lucide-react"; import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react"; import type { GamePluginResponse, RunEndpointResponse, ServerDeploymentResponse, ServerDeploymentRevealResponse } from "../api/types"; import { ManagementDialog } from "./OperationControls"; import { endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, type ServerCreateFormState } from "../contracts/serverManagement"; import { cx } from "../utils/classes"; type WorkflowKind = "create" | "edit"; interface ServerDeploymentWorkflowProps { open: boolean; kind: WorkflowKind; plugins: GamePluginResponse[]; endpoints: RunEndpointResponse[]; initialForm: ServerCreateFormState; deployment?: ServerDeploymentResponse; busy?: boolean; onReveal?: () => Promise; onClose: () => void; onSubmit: (form: ServerCreateFormState, saveAsDraft: boolean) => Promise; } export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initialForm, deployment, busy = false, onReveal, onClose, onSubmit }: ServerDeploymentWorkflowProps) { const [step, setStep] = useState(0); const [form, setForm] = useState(initialForm); const [saveAsDraft, setSaveAsDraft] = useState(false); const [revealBusy, setRevealBusy] = useState(false); const [revealError, setRevealError] = useState(""); const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]); const profileOptions = selectedPlugin?.runtimeProfiles?.lifecycleProfiles ?? []; const pluginFields = selectedPlugin?.createFields ?? []; 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 selectedTargetID = kind === "create" ? form.deploymentTargetId : form.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(kind === "create" && !initialForm.runEndpointId); setForm(initialForm); setRevealBusy(false); setRevealError(""); }, [initialForm, kind, open]); useEffect(() => { if (!open || kind !== "edit" || !onReveal) return; void revealSavedInputs(); }, [kind, onReveal, open]); function updateForm(event: ChangeEvent) { const { name, value } = event.target; setForm((current) => { if (name === "pluginId") { const plugin = plugins.find((item) => item.id === value); return { ...current, pluginId: value, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {}, createInputs: pluginCreateInputDefaults(plugin) }; } if (name === "profileKey") return { ...current, profileKey: value, bindings: {} }; return { ...current, [name]: value }; }); } function updateCreateInput(key: string, value: string) { setForm((current) => ({ ...current, createInputs: { ...current.createInputs, [key]: value } })); } function updateBinding(key: string, value: string) { setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } })); } function canContinue() { if (step === targetStep) return kind === "create" ? Boolean(form.pluginId && (saveAsDraft || form.deploymentTargetId)) : 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; if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false; return form.deploymentMode !== "guided-install" || pluginFields.filter((field) => field.required).every((field) => Boolean(form.createInputs[field.key]?.trim())); } return true; } async function submit(event: FormEvent) { event.preventDefault(); if (step < reviewStep) { if (canContinue()) setStep((current) => current + 1); return; } await onSubmit({ ...form, deploymentTargetId: saveAsDraft ? "" : form.deploymentTargetId, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft); } async function revealSavedInputs() { if (!onReveal || revealBusy) return; setRevealBusy(true); setRevealError(""); try { const revealed = await onReveal(); setForm((current) => ({ ...current, serverRoot: revealed.serverRoot, workingDirectory: revealed.workingDirectory, installCommand: revealed.installCommand, startCommand: revealed.startCommand, stopCommand: revealed.stopCommand, statusCommand: revealed.statusCommand })); } catch (error) { setRevealError(error instanceof Error ? error.message : "无法显示已保存配置"); } finally { setRevealBusy(false); } } function closeWorkflow() { if (busy) return; setForm(initialForm); setRevealError(""); onClose(); } const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置"; const actionLabel = kind === "create" ? "保存草稿并准备专属 Run" : "保存部署设置"; return
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 === targetStep &&
{kind === "create" ?
选择游戏插件决定新建时需要的游戏配置与启动预设。
选择部署目标受信任的构建节点会生成专属 Run;它不是这台服务器的 Run。
注册专属 Run草稿保存后生成并启动专属 Run,注册成功才可部署。
:
这个草稿尚未绑定运行节点只需在这里补选一次。已绑定服务器编辑时会直接进入相关配置,不会重复要求选择目标。
}
{kind === "create" && }
{kind === "create" && }
} {step === modeStep &&

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

{isScum &&
SCUM 受控链路Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。
}
setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} /> setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
} {step === configurationStep &&
{kind === "edit" && onReveal &&
已读取受保护配置{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}{revealError && <>{revealError}}
}
{kind === "create" && } {kind === "create" && } {kind === "edit" && } {form.deploymentMode === "guided-install" && } {form.deploymentMode === "existing-server" && } {form.deploymentMode === "custom-command" && } {form.deploymentMode === "guided-install" && pluginFields.map((field) => ( ))}
{form.deploymentMode === "guided-install" && } {form.deploymentMode === "existing-server" && } {form.deploymentMode === "custom-command" &&
高级启动设置

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

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

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

{bindingFields.map((field) => )}
}
} {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
{kind === "create" ? "部署目标" : "目标"}{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}
部署方式{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 &&
完成条件安装/扫描、映射、验证全部通过
}
{kind === "create" ? "本次保存草稿并保留专属 Run" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}{kind === "create" ? "随后生成并启动专属 Run;新建并安装模式会在它注册后自动部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}
}
{step < reviewStep ? : }
; } function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return ; } function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) { const steps = isScum ? [ { icon: ScanSearch, title: "预检目录与端口", copy: "确认安装目录可用、节点兼容且端口可绑定。" }, { icon: Download, title: "下载 SCUM Server", copy: "通过 SteamCMD 安装 App 3792580 到该目录。" }, { icon: SlidersHorizontal, title: "写入游戏配置", copy: "把本页的名称、端口与人数写入 ServerSettings.ini。" }, { icon: HeartPulse, title: "启动并健康验证", copy: "检查可执行文件、版本、配置、端口和服务进程。" } ] : [ { icon: ScanSearch, title: "预检目录与节点", copy: "确认安装目录、权限、端口与运行节点可用。" }, { icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" }, { icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给受控部署流程。" }, { icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" } ]; 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 更新任务与备份/健康验证流程。

:

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

}
; }