177 lines
21 KiB
TypeScript
177 lines
21 KiB
TypeScript
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, ServerDeploymentResponse, ServerDeploymentRevealResponse } from "../api/types";
|
||
import { ManagementDialog } from "./OperationControls";
|
||
import { pluginCreateInputDefaults, pluginLabel, type ServerCreateFormState } from "../contracts/serverManagement";
|
||
import { cx } from "../utils/classes";
|
||
|
||
type WorkflowKind = "create" | "edit";
|
||
|
||
interface ServerDeploymentWorkflowProps {
|
||
open: boolean;
|
||
kind: WorkflowKind;
|
||
plugins: GamePluginResponse[];
|
||
initialForm: ServerCreateFormState;
|
||
deployment?: ServerDeploymentResponse;
|
||
busy?: boolean;
|
||
onReveal?: () => Promise<ServerDeploymentRevealResponse>;
|
||
onClose: () => void;
|
||
onSubmit: (form: ServerCreateFormState) => Promise<void>;
|
||
}
|
||
|
||
export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, deployment, busy = false, onReveal, onClose, onSubmit }: ServerDeploymentWorkflowProps) {
|
||
const [step, setStep] = useState(0);
|
||
const [form, setForm] = useState<ServerCreateFormState>(initialForm);
|
||
const [revealBusy, setRevealBusy] = useState(false);
|
||
const [revealError, setRevealError] = useState("");
|
||
const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]);
|
||
const pluginFields = selectedPlugin?.createFields ?? [];
|
||
const isScum = selectedPlugin?.id === "game.scum";
|
||
const workflowSteps = kind === "create"
|
||
? [{ label: "基本信息", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]
|
||
: [{ label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }];
|
||
const pluginStep = kind === "create" ? 0 : -1;
|
||
const modeStep = kind === "create" ? 1 : -1;
|
||
const configurationStep = kind === "create" ? 2 : 0;
|
||
const reviewStep = workflowSteps.length - 1;
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setStep(0);
|
||
setForm(initialForm);
|
||
setRevealBusy(false);
|
||
setRevealError("");
|
||
}, [initialForm, kind, open]);
|
||
|
||
useEffect(() => {
|
||
if (!open || kind !== "edit" || !onReveal) return;
|
||
void revealSavedInputs();
|
||
}, [kind, onReveal, open]);
|
||
|
||
function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
|
||
const { name, value } = event.target;
|
||
setForm((current) => {
|
||
if (name === "pluginId") {
|
||
const plugin = plugins.find((item) => item.id === value);
|
||
return { ...current, pluginId: value, createInputs: pluginCreateInputDefaults(plugin) };
|
||
}
|
||
return { ...current, [name]: value };
|
||
});
|
||
}
|
||
|
||
function updateCreateInput(key: string, value: string) { setForm((current) => ({ ...current, createInputs: { ...current.createInputs, [key]: value } })); }
|
||
|
||
function canContinue() {
|
||
if (step === pluginStep) return Boolean(form.pluginId) && Boolean(form.name.trim());
|
||
if (step === modeStep) return Boolean(form.deploymentMode);
|
||
if (step === configurationStep) {
|
||
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<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (step < reviewStep) { if (canContinue()) setStep((current) => current + 1); return; }
|
||
await onSubmit(form);
|
||
}
|
||
|
||
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" ? "创建服务器" : "保存部署设置";
|
||
|
||
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "先选择插件类型和服务器名称,再按部署方式填写启动项;Run 由心跳自动识别,不提供节点或部署目标选择。" : "任何运行状态都可以修改部署设置;这里只保存定义,不会直接重启进程。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
|
||
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
|
||
<ol className="deployment-workflow-steps" style={{ gridTemplateColumns: `repeat(${workflowSteps.length}, minmax(0, 1fr))` }} aria-label="部署步骤">{workflowSteps.map((item, index) => { const Icon = item.icon; return <li key={item.label} className={cx(index === step && "deployment-workflow-step-active", index < step && "deployment-workflow-step-complete")}><span>{index < step ? <CheckCircle2 size={15} /> : <Icon size={15} />}</span><strong>{index + 1}. {item.label}</strong></li>; })}</ol>
|
||
{step === pluginStep && <div className="deployment-workflow-body">
|
||
<div className="workflow-hint-grid"><div className="workflow-hint-card"><strong>创建基础信息</strong><span>插件决定下一步显示哪些部署方式和游戏参数。</span></div><div className="workflow-hint-card"><strong>配置启动项</strong><span>新建安装、接管已有和自定义启动分别填写自己的字段。</span></div><div className="workflow-hint-card"><strong>平台构建 Run 包</strong><span>平台在自有构建器中打包,Run 启动后自动上报心跳。</span></div></div>
|
||
<div className="form-grid"><label>插件类型<select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label><label>服务器名称<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div>
|
||
</div>}
|
||
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy">选择这台服务器的创建方式;下一步只显示该方式需要的启动项。</p>{isScum && <div className="form-guidance"><strong>SCUM 受控链路</strong><span>Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。</span></div>}<div className="deployment-mode-grid">
|
||
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
|
||
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
|
||
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由 Run 策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
|
||
</div></div>}
|
||
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong>已读取受保护配置</strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}>重试读取</button></>}</div>}<div className="form-grid">
|
||
{kind === "edit" && <label>部署方式<select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}><option value="guided-install">新建并安装</option><option value="existing-server">接管已有服务器</option><option value="custom-command">自定义启动方式</option></select><small className="field-help">可在此调整部署方式;Run 会按心跳自动识别服务器。</small></label>}
|
||
{form.deploymentMode === "guided-install" && <label>安装目录{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">新建服务器的安装目标;SCUM 受控模板必须明确安装目录。</small></label>}
|
||
{form.deploymentMode === "existing-server" && <label>已有服务器目录<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run 会先预检目录、插件与端口;不会重装或覆盖现有游戏配置。</small></label>}
|
||
{form.deploymentMode === "custom-command" && <label>服务器目录<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help">服务器文件、数据与配置的主目录。</small></label>}
|
||
{form.deploymentMode === "guided-install" && pluginFields.map((field) => (
|
||
<label key={field.key}>{field.label}{field.required ? "(必填)" : ""}{field.type === "select" ? (
|
||
<select value={form.createInputs[field.key] ?? ""} onChange={(event) => updateCreateInput(field.key, event.target.value)} required={field.required}>{!field.required && <option value="">未设置</option>}{field.options?.map((option) => <option key={option} value={option}>{option}</option>)}</select>
|
||
) : (
|
||
<input type={field.type === "boolean" ? "checkbox" : field.type === "number" || field.type === "port" ? "number" : "text"} min={field.type === "port" ? 1 : undefined} max={field.type === "port" ? 65535 : undefined} checked={field.type === "boolean" ? form.createInputs[field.key] === "true" : undefined} value={field.type === "boolean" ? undefined : form.createInputs[field.key] ?? ""} onChange={(event) => updateCreateInput(field.key, field.type === "boolean" ? String(event.target.checked) : event.target.value)} required={field.required} />
|
||
)}</label>
|
||
))}
|
||
</div>
|
||
{form.deploymentMode === "guided-install" && <GuidedInstallPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />}
|
||
{form.deploymentMode === "existing-server" && <ExistingServerAdoptionPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />}
|
||
{form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary>高级启动设置</summary><p className="field-help">只有自定义启动器需要这些设置。执行目录留空时,Run 以服务器目录执行。</p><div className="form-grid"><label>启动命令<input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label>命令解释器<select name="shell" value={form.shell} onChange={updateForm}><option value="">直接 argv(推荐)</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label>执行目录(可选)<input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label>安装命令(可选)<input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label>停止命令(可选)<input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label>状态命令<input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>}
|
||
</div>}
|
||
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span>插件类型</span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div>{kind === "create" && <div><span>服务器名称</span><strong>{form.name.trim() || "未填写"}</strong></div>}<div><span>部署方式</span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span>启动命令</span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span>执行目录</span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span>游戏配置</span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span>完成条件</span><strong>安装/扫描、映射、验证全部通过</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存创建向导配置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "Run 会自动识别并上报心跳;部署执行按这里的方式和启动项进行。" : form.deploymentMode === "existing-server" ? "Run 将自动识别并预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}</span></div></div>}
|
||
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? closeWorkflow() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span>下一步</span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div>
|
||
</form>
|
||
</ManagementDialog>;
|
||
}
|
||
|
||
function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return <button type="button" className={cx("deployment-mode-option", active && "deployment-mode-option-active")} onClick={onClick}><strong>{title}</strong><span>{copy}</span></button>; }
|
||
|
||
function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) {
|
||
const steps = isScum ? [
|
||
{ icon: ScanSearch, title: "预检目录与端口", copy: "确认安装目录可用、Run 环境兼容且端口可用。" },
|
||
{ icon: Download, title: "下载 SCUM Server", copy: "通过 SteamCMD 安装 App 3792580 到该目录。" },
|
||
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "把本页的名称、端口与人数写入 ServerSettings.ini。" },
|
||
{ icon: HeartPulse, title: "启动并健康验证", copy: "检查可执行文件、版本、配置、端口和服务进程。" }
|
||
] : [
|
||
{ icon: ScanSearch, title: "预检目录与 Run", copy: "确认安装目录、权限、端口与 Run 环境可用。" },
|
||
{ icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" },
|
||
{ icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给受控部署流程。" },
|
||
{ icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" }
|
||
];
|
||
|
||
return <section className="guided-install-plan" aria-label="新建并安装执行流程"><div className="guided-install-plan-heading"><div><strong>确认后,{pluginName} 会这样安装</strong><span>“安装目录”就是游戏服务端、数据和配置将落地的位置;它不是命令执行目录,也不会在日志中回显。</span></div><small>{isScum ? "全部 4 步通过才算安装成功" : "节点按插件契约执行"}</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol><p><strong>不会做:</strong>{isScum ? "不会跳过验证就标记成功;失败时不会暴露你的目录、命令或凭据。" : "不会把受保护的路径、命令或凭据回显给浏览器。"}</p></section>;
|
||
}
|
||
|
||
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 <section className="guided-install-plan" aria-label="接管已有服务器执行流程"><div className="guided-install-plan-heading"><div><strong>确认后,{pluginName} 会这样接管</strong><span>目录只会交给 Run 在本机使用;平台、浏览器和日志都不会显示原始路径。</span></div><small>先扫描,后自动识别</small></div><ol>{steps.map(({ icon: Icon, title, copy }, index) => <li key={title}><span><Icon size={16} /></span><div><strong>{index + 1}. {title}</strong><small>{copy}</small></div></li>)}</ol>{isScum ? <p><strong>SCUM 与 SteamCMD:</strong>接管只需要服务端根目录,不需要填写 SteamCMD 目录。Run 可能按本机策略检查 SteamCMD 是否可用,但它不是接管输入。<br /><strong>升级:</strong>接管不会升级游戏;当前平台尚未提供 SCUM 服务端的受控升级任务,不能承诺自动升级。升级能力需要单独的 SteamCMD 更新任务与备份/健康验证流程。</p> : <p><strong>不会做:</strong>不会重新安装、覆盖已有游戏配置,或把受保护路径回显给浏览器。</p>}</section>;
|
||
}
|