feat: redesign server deployment workflow

This commit is contained in:
npc0-hue
2026-07-24 19:22:35 +08:00
parent 220ef91a8e
commit ab943536b0
10 changed files with 362 additions and 243 deletions
@@ -0,0 +1,115 @@
import { CheckCircle2, CircleDashed, Compass, FolderCog, Rocket, ServerCog } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import type { GamePluginResponse, RunEndpointResponse, ServerDeploymentResponse } 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;
onClose: () => void;
onSubmit: (form: ServerCreateFormState, saveAsDraft: boolean) => Promise<void>;
}
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<ServerCreateFormState>(initialForm);
const [saveAsDraft, setSaveAsDraft] = useState(false);
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);
useEffect(() => {
if (!open) return;
setStep(0);
setSaveAsDraft(!initialForm.runEndpointId);
setForm(initialForm);
}, [initialForm, kind, 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, 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 === 0) return Boolean(form.pluginId && (saveAsDraft || form.runEndpointId));
if (step === 2) {
if (kind === "create" && !form.name.trim()) return false;
if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false;
return 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 < workflowSteps.length - 1) { 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 ? "保存草稿设置" : "保存部署设置");
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "按部署顺序完成设置;路径和命令始终受保护,不会在确认页或日志中回显。" : "仅停止中的服务器可以修改部署设置。受保护路径和命令留空会保持原值。"} wide onClose={() => { if (!busy) onClose(); }}>
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
<ol className="deployment-workflow-steps" 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 === 0 && <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>稿</strong><span>稿</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><select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} disabled={saveAsDraft} required={!saveAsDraft}><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div>
<label className="deployment-draft-choice"><input type="checkbox" checked={saveAsDraft} onChange={(event) => setSaveAsDraft(event.target.checked)} /><span><strong>{kind === "create" ? "仅保存为草稿" : "保持为未绑定草稿"}</strong><small></small></span></label>
</div>}
{step === 1 && <div className="deployment-workflow-body"><p className="section-copy"></p><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="用于非标准启动器或脚本;需由节点策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
</div></div>}
{step === 2 && <div className="deployment-workflow-body"><div className="form-grid">
{kind === "create" && <label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label>}
{kind === "create" && <label><select name="profileKey" value={form.profileKey} onChange={updateForm}><option value="">使</option>{profileOptions.map((profile) => <option key={profile.key} value={profile.key}>{profile.key} · {profile.mode}</option>)}</select><small className="field-help"></small></label>}
<label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help">{form.deploymentMode === "existing-server" ? "已有服务器所在目录。" : "服务器文件、数据与配置的主目录。"}</small></label>
{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 === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help"></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>}
{kind === "create" && bindingFields.length > 0 && <details className="provider-advanced-settings"><summary></summary><p className="field-help"></p><div className="form-grid">{bindingFields.map((field) => <label key={field.key}>{field.key}{field.required ? "(必填)" : ""}<input type={field.sensitive ? "password" : "text"} autoComplete="off" value={form.bindings[field.key] ?? ""} onChange={(event) => updateBinding(field.key, event.target.value)} placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"} required={field.required} /></label>)}</div></details>}
</div>}
{step === 3 && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span></span><strong>{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span></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></>}<div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div></div><div className="form-guidance"><strong>{saveAsDraft ? "本次只保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建并派发部署"}</strong><span>{saveAsDraft ? "后续从服务器详情选择节点并部署。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}</span></div></div>}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? onClose() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < workflowSteps.length - 1 ? <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>; }
+18 -14
View File
@@ -9,6 +9,7 @@ import { ServerDetailPage } from "./ServerDetailPage";
import { ServersPage } from "./ServersPage";
import { UsersPage } from "./UsersPage";
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw";
import serversPageSource from "./ServersPage.tsx?raw";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import type { PageComponentProps } from "../contracts/page";
@@ -167,21 +168,24 @@ describe("first-party console pages", () => {
expect(serversPageSource).toContain("旧 run 会话已失效");
});
it("submits declared runtime profiles and logical bindings from the create workflow", () => {
expect(serversPageSource).toContain("<ManagementDialog");
expect(serversPageSource).toContain('className="provider-form dialog-form"');
expect(serversPageSource).toContain('name="name"');
expect(serversPageSource).toContain('name="profileKey"');
expect(serversPageSource).toContain("runtimeBindingFields");
expect(serversPageSource).toContain("createProfileUnavailable");
expect(serversPageSource).toContain("当前插件没有声明运行配置");
expect(serversPageSource).toContain("updateBinding(field.key");
expect(serversPageSource).toContain('type={field.sensitive ? "password" : "text"}');
expect(serversPageSource).toContain("serverCreateRequestFromForm(form)");
expect(serversPageSource).not.toContain('name="id"');
expect(serversPageSource).not.toContain("实例 ID");
it("uses a staged deployment workflow for create and edit without exposing protected inputs", () => {
expect(serversPageSource).toContain("<ServerDeploymentWorkflow");
expect(serversPageSource).toContain("openEditDeployment");
expect(serversPageSource).toContain("编辑部署");
expect(serverDetailPageSource).toContain("<ServerDeploymentWorkflow");
expect(serverDeploymentWorkflowSource).toContain("选择目标");
expect(serverDeploymentWorkflowSource).toContain("部署方式");
expect(serverDeploymentWorkflowSource).toContain("相关配置");
expect(serverDeploymentWorkflowSource).toContain("仅保存为草稿");
expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)");
expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录");
expect(serverDeploymentWorkflowSource).toContain("运行连接设置");
expect(serverDeploymentWorkflowSource).toContain('type={field.sensitive ? "password" : "text"}');
expect(serversPageSource).toContain("serverCreateRequestFromForm(nextForm)");
expect(serverDeploymentWorkflowSource).not.toContain('name="id"');
expect(serverDeploymentWorkflowSource).not.toContain("实例 ID");
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) {
expect(serversPageSource).not.toContain(forbidden);
expect(serverDeploymentWorkflowSource).not.toContain(forbidden);
}
});
+28 -74
View File
@@ -28,6 +28,7 @@ import type {
RemoteAdapterDeclarationResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
@@ -48,7 +49,7 @@ import {
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canStartServer, canStopServer, endpointLabel, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement";
import {
serverDetailSections,
serverIsOnline,
@@ -93,6 +94,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [endpoints, setEndpoints] = useState<RunEndpointResponse[]>([]);
const [showDeploymentEditor, setShowDeploymentEditor] = useState(false);
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
@@ -199,6 +201,18 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
});
}
async function saveDeploymentWorkflow(form: ServerCreateFormState) {
if (instance.status !== "ready") return;
const current = instance.data;
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: current.id, requester: session.displayName });
try {
await platformApiClient.updateServerDeployment(current.id, { runEndpointId: form.runEndpointId || undefined, mode: form.deploymentMode, profileKey: form.profileKey || undefined, createInputs: form.createInputs, serverRoot: form.serverRoot.trim() || undefined, workingDirectory: form.workingDirectory.trim() || undefined, installCommand: form.installCommand.trim() || undefined, startCommand: form.startCommand.trim() || undefined, stopCommand: form.stopCommand.trim() || undefined, statusCommand: form.statusCommand.trim() || undefined, shell: form.shell || undefined });
operations.succeed(operationId, "部署设置已保存;路径和命令保持受保护状态。");
setShowDeploymentEditor(false);
await refresh();
} catch (error) { operations.fail(operationId, error instanceof Error ? error.message : "部署设置保存失败"); }
}
if (!serverId) {
return (
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
@@ -269,6 +283,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
<Square size={15} />
<span></span>
</button>
<button type="button" className="icon-command" disabled={instance.data.state === "running" || instance.data.state === "installing"} title={instance.data.state === "running" || instance.data.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={() => setShowDeploymentEditor(true)}><Pencil size={15} /><span></span></button>
</div>
</div>
<div className="server-detail-stat-strip">
@@ -307,7 +322,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={() => void refresh()}
/>
)}
{section === "overview" && <ServerDeploymentSection instance={instance.data} deployment={deployment} endpoints={endpoints} session={session} operations={operations} onChanged={() => void refresh()} />}
{section === "overview" && <ServerDeploymentSection instance={instance.data} deployment={deployment} onEdit={() => setShowDeploymentEditor(true)} />}
{section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{section === "overview" && (
@@ -337,6 +352,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} onNavigate={onNavigate} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
<ServerDeploymentWorkflow open={showDeploymentEditor && deployment.status === "ready"} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={deploymentWorkflowForm(instance.data, deployment.status === "ready" ? deployment.data : undefined, plugins, endpoints)} deployment={deployment.status === "ready" ? deployment.data : undefined} busy={operations.isPending(instance.data.id, "更新部署定义")} onClose={() => setShowDeploymentEditor(false)} onSubmit={saveDeploymentWorkflow} />
</>
)}
@@ -428,88 +444,26 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser
interface ServerDeploymentSectionProps {
instance: ServerInstanceResponse;
deployment: LoadState<ServerDeploymentResponse>;
endpoints: RunEndpointResponse[];
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onChanged: () => void;
onEdit: () => void;
}
function ServerDeploymentSection({ instance, deployment, endpoints, session, operations, onChanged }: ServerDeploymentSectionProps) {
const [runEndpointId, setRunEndpointId] = useState(instance.runEndpointId);
const [mode, setMode] = useState<"guided-install" | "existing-server" | "custom-command">("guided-install");
const [serverRoot, setServerRoot] = useState("");
const [workingDirectory, setWorkingDirectory] = useState("");
const [installCommand, setInstallCommand] = useState("");
const [startCommand, setStartCommand] = useState("");
const [stopCommand, setStopCommand] = useState("");
const [statusCommand, setStatusCommand] = useState("");
const [shell, setShell] = useState<"" | "posix-sh" | "powershell" | "cmd">("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
useEffect(() => {
setRunEndpointId(instance.runEndpointId);
if (deployment.status === "ready") {
setMode(deployment.data.mode ?? "guided-install");
setShell(deployment.data.shell ?? "");
}
}, [deployment, instance.id, instance.runEndpointId]);
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setResult(null);
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
await platformApiClient.updateServerDeployment(instance.id, { runEndpointId: runEndpointId || undefined, mode, serverRoot: serverRoot.trim() || undefined, workingDirectory: workingDirectory.trim() || undefined, installCommand: installCommand.trim() || undefined, startCommand: startCommand.trim() || undefined, stopCommand: stopCommand.trim() || undefined, statusCommand: statusCommand.trim() || undefined, shell: shell || undefined });
operations.succeed(operationId, "部署定义已保存;受保护路径和命令不会回显。");
setResult({ status: "succeeded", label: "部署定义已保存" });
setServerRoot(""); setWorkingDirectory(""); setInstallCommand(""); setStartCommand(""); setStopCommand(""); setStatusCommand("");
onChanged();
} catch (error) {
const label = error instanceof Error ? error.message : "部署定义保存失败";
operations.fail(operationId, label);
setResult({ status: "failed", label });
} finally { setBusy(false); }
}
async function deploy() {
setBusy(true);
setResult(null);
const operationId = operations.begin({ intent: "部署服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
const response = await platformApiClient.deployServerInstance(instance.id, { expectedConfigVersion: instance.configVersion, idempotencyKey: `web:deploy:${instance.id}:${Date.now()}` });
operations.succeed(operationId, response.job.id ? "部署任务已进入队列,等待 Run 领取。" : "部署请求已接受。");
setResult({ status: "succeeded", label: deploymentProgressLabel(response.job.progress) });
onChanged();
} catch (error) {
const label = error instanceof Error ? error.message : "部署失败";
operations.fail(operationId, label);
setResult({ status: "failed", label });
} finally { setBusy(false); }
}
function ServerDeploymentSection({ instance, deployment, onEdit }: ServerDeploymentSectionProps) {
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
const view = deployment.data;
return <article className="console-panel" aria-label="server deployment">
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{view.mode || "未配置"} · {view.revision}</span></div>
<p className="section-copy"></p>
<div className="action-list"><span>{view.serverRootConfigured ? "已配置" : "未配置"}</span><span>{view.workingDirectoryConfigured ? "已配置" : "未配置"}</span><span> / {view.installCommandConfigured ? "已配置" : "未配置"} / {view.startCommandConfigured ? "配置" : "未配置"}</span></div>
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void save(event)}>
<div className="form-grid">
<label><select value={runEndpointId} onChange={(event) => setRunEndpointId(event.target.value)}><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label>
<label><select value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select></label>
<label><input value={serverRoot} onChange={(event) => setServerRoot(event.target.value)} placeholder="完整绝对路径" autoComplete="off" /></label>
<label><input value={workingDirectory} onChange={(event) => setWorkingDirectory(event.target.value)} placeholder="完整绝对路径" autoComplete="off" /></label>
{mode === "custom-command" && <><label><select value={shell} onChange={(event) => setShell(event.target.value as typeof shell)}><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 value={installCommand} onChange={(event) => setInstallCommand(event.target.value)} autoComplete="off" /></label><label><input value={startCommand} onChange={(event) => setStartCommand(event.target.value)} autoComplete="off" /></label><label><input value={stopCommand} onChange={(event) => setStopCommand(event.target.value)} autoComplete="off" /></label><label><input value={statusCommand} onChange={(event) => setStatusCommand(event.target.value)} autoComplete="off" /></label></>}
</div>
<div className="action-strip"><button type="submit" className="primary-command" disabled={busy}><Pencil size={14} /><span>{busy ? "保存中…" : "保存部署定义"}</span></button>{instance.state === "draft" || instance.state === "failed" ? <button type="button" className="icon-command" disabled={busy || !runEndpointId} onClick={() => void deploy()}><Sparkles size={14} /><span> Run</span></button> : null}</div>
</form>
{result && <div style={{ marginTop: 10 }}><ResultBadge status={result.status} label={result.label} /></div>}
<p className="section-copy"></p>
<div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "配置" : "插件引导"}</strong></div></div>
<div className="action-strip" style={{ marginTop: 12 }}><button type="button" className="primary-command" disabled={instance.state === "running" || instance.state === "installing"} onClick={onEdit}><Pencil size={14} /><span></span></button>{(instance.state === "draft" || instance.state === "failed") && <span className="field-help"></span>}</div>
</article>;
}
function deploymentWorkflowForm(instance: ServerInstanceResponse, deployment: ServerDeploymentResponse | undefined, plugins: GamePluginResponse[], endpoints: RunEndpointResponse[]): ServerCreateFormState {
const plugin = plugins.find((item) => item.id === instance.pluginId);
return { ...defaultServerCreateForm(plugins, endpoints), name: instance.name, pluginId: instance.pluginId, runEndpointId: instance.runEndpointId, profileKey: deployment?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", createInputs: deployment?.createInputs ?? pluginCreateInputDefaults(plugin), deploymentMode: deployment?.mode ?? "guided-install", shell: deployment?.shell ?? "" };
}
function deploymentProgressLabel(progress: JobResponse["progress"]): string {
switch (progress.phase) {
case "queued": return "任务已排队,等待 Run 领取";
+39 -154
View File
@@ -1,5 +1,5 @@
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles, Trash2 } from "lucide-react";
import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { platformApiClient } from "../api/client";
@@ -17,15 +17,14 @@ import {
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
canDeleteServer,
defaultServerCreateForm,
endpointLabel,
pluginCreateInputDefaults,
pluginLabel,
runtimeBindingFields,
pluginCreateInputDefaults,
type ServerCreateFormState
} from "../contracts/serverManagement";
import { summarizeServerOperations } from "../contracts/operationsConsole";
@@ -72,6 +71,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
const [showCreate, setShowCreate] = useState(false);
const [editDeployment, setEditDeployment] = useState<{ instance: ServerInstanceResponse; deployment: import("../api/types").ServerDeploymentResponse } | null>(null);
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
@@ -102,7 +102,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
pluginId: plugin?.id ?? "",
profileKey,
bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {},
createInputs: plugin?.id === current.pluginId ? current.createInputs : pluginCreateInputDefaults(plugin),
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
? current.runEndpointId
: endpointResponse.items[0]?.id || ""
@@ -154,39 +153,10 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
const createPending = operations.isPending("platform", "创建服务器");
const canManageServers = session.capabilities.includes("servers.manage");
const selectedCreatePlugin = plugins.find((plugin) => plugin.id === form.pluginId);
const createProfileOptions = selectedCreatePlugin?.runtimeProfiles?.lifecycleProfiles ?? [];
const createProfileUnavailable = Boolean(selectedCreatePlugin && createProfileOptions.length === 0);
const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey);
const createPluginFields = selectedCreatePlugin?.createFields ?? [];
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, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {}, createInputs: pluginCreateInputDefaults(plugin) };
}
if (name === "profileKey") {
return { ...current, profileKey: value, bindings: {} };
}
return { ...current, [name]: value };
});
}
function updateBinding(key: string, value: string) {
setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } }));
}
function updateCreateInput(key: string, value: string) {
setForm((current) => ({ ...current, createInputs: { ...current.createInputs, [key]: value } }));
}
async function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
async function handleCreate(nextForm: ServerCreateFormState) {
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
try {
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(form));
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);
setForm(defaultServerCreateForm(plugins, endpoints));
setShowCreate(false);
@@ -196,6 +166,33 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
}
}
async function openEditDeployment(instance: ServerInstanceResponse) {
if (instance.state === "running" || instance.state === "installing") return;
try {
const deployment = await platformApiClient.getServerDeployment(instance.id);
const plugin = plugins.find((item) => item.id === instance.pluginId);
setForm({ ...defaultServerCreateForm(plugins, endpoints), name: instance.name, pluginId: instance.pluginId, runEndpointId: instance.runEndpointId, profileKey: deployment.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", createInputs: deployment.createInputs ?? pluginCreateInputDefaults(plugin), deploymentMode: deployment.mode ?? "guided-install", shell: deployment.shell ?? "" });
setEditDeployment({ instance, deployment });
} catch (error) {
const operationId = operations.begin({ intent: "读取部署设置", targetKind: "server", targetId: instance.id, requester: session.displayName });
operations.fail(operationId, error instanceof Error ? error.message : "部署设置加载失败");
}
}
async function handleUpdateDeployment(nextForm: ServerCreateFormState) {
if (!editDeployment) return;
const { instance } = editDeployment;
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
await platformApiClient.updateServerDeployment(instance.id, { runEndpointId: nextForm.runEndpointId || undefined, mode: nextForm.deploymentMode, profileKey: nextForm.profileKey || undefined, createInputs: nextForm.createInputs, serverRoot: nextForm.serverRoot.trim() || undefined, workingDirectory: nextForm.workingDirectory.trim() || undefined, installCommand: nextForm.installCommand.trim() || undefined, startCommand: nextForm.startCommand.trim() || undefined, stopCommand: nextForm.stopCommand.trim() || undefined, statusCommand: nextForm.statusCommand.trim() || undefined, shell: nextForm.shell || undefined });
operations.succeed(operationId, "部署设置已保存;路径和命令保持受保护状态。");
setEditDeployment(null);
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "部署设置保存失败");
}
}
async function handleDeleteServer() {
if (!deleteConfirmation) {
return;
@@ -493,123 +490,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</div>
)}
<ManagementDialog
open={showCreate && canManageServers}
title="创建服务器"
description="先保存服务器定义也可以;Run 注册后再绑定部署。根目录和命令只会写入受保护执行计划,之后不会在页面、任务或日志中显示。"
wide
onClose={() => { if (!createPending) setShowCreate(false); }}
>
<form className="provider-form dialog-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
<div className="form-grid">
<label>
<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required />
</label>
<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>
<select name="runEndpointId" value={form.runEndpointId} onChange={updateForm}>
<option value="">稿</option>
{endpoints.map((endpoint) => (
<option key={endpoint.id} value={endpoint.id}>
{endpointLabel(endpoint, endpoint.id)}
</option>
))}
</select>
</label>
<label>
<select name="profileKey" value={form.profileKey} onChange={updateForm}>
<option value="">使</option>
{createProfileOptions.map((profile) => (
<option key={profile.key} value={profile.key}>
{profile.key} · {profile.mode}
</option>
))}
</select>
</label>
{createProfileUnavailable && (
<div className="operations-inline-warning" role="status">
<AlertTriangle size={14} />
<span> manifest </span>
</div>
)}
<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>
</label>
<label>
<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder="/srv/scum-alpha 或 C:\\Games\\SCUM" autoComplete="off" />
</label>
<label>
<input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder="/srv/scum-alpha" autoComplete="off" />
</label>
{createPluginFields.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>
))}
{form.deploymentMode === "custom-command" && (
<>
<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="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="例如 SteamCMD 安装命令" /></label>
<label><input name="startCommand" value={form.startCommand} onChange={updateForm} autoComplete="off" required placeholder="例如 /srv/app/.venv/bin/python server.py" /></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>
</>
)}
{createBindingFields.map((field) => (
<label key={field.key}>
{field.key}{field.required ? "(必填)" : ""}
<input
type={field.sensitive ? "password" : "text"}
autoComplete="off"
value={form.bindings[field.key] ?? ""}
onChange={(event) => updateBinding(field.key, event.target.value)}
placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"}
required={field.required}
/>
</label>
))}
</div>
<div className="confirm-actions">
<button type="button" disabled={createPending} onClick={() => setShowCreate(false)}></button>
<button type="submit" className="confirm-primary" disabled={createPending || createProfileUnavailable} title="创建服务器">
<Sparkles size={16} />
<span>{createPending ? "保存中…" : form.runEndpointId ? "保存并部署" : "保存草稿"}</span>
</button>
</div>
</form>
</ManagementDialog>
<ServerDeploymentWorkflow open={showCreate && canManageServers} kind="create" plugins={plugins} endpoints={endpoints} initialForm={form} busy={createPending} onClose={() => setShowCreate(false)} onSubmit={handleCreate} />
<ServerDeploymentWorkflow open={editDeployment !== null} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={form} deployment={editDeployment?.deployment} busy={editDeployment ? operations.isPending(editDeployment.instance.id, "更新部署定义") : false} onClose={() => setEditDeployment(null)} onSubmit={handleUpdateDeployment} />
<ManagementDialog
open={runTargetSelection !== null}
@@ -712,6 +594,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
canManage={canManageServers}
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
onEdit={() => void openEditDeployment(card.instance)}
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
onDelete={() => {
setDeletePassword("");
@@ -789,11 +672,12 @@ interface ServerCardProps {
canManage: boolean;
deleteDisabledReason: string;
onOpen: () => void;
onEdit: () => void;
onQuickAction: (action: ServerQuickRuntimeAction) => void;
onDelete: () => void;
}
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onQuickAction, onDelete }: ServerCardProps) {
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onQuickAction, onDelete }: ServerCardProps) {
const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state);
const canDelete = deleteDisabledReason === "";
@@ -914,6 +798,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
<Sparkles size={14} />
<span></span>
</button>
<button type="button" className="icon-command" disabled={!canManage || instance.state === "running" || instance.state === "installing"} title={instance.state === "running" || instance.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={onEdit}><span></span></button>
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<span></span>
</button>
+2 -1
View File
@@ -495,6 +495,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.management-dialog-panel .management-form,.management-dialog-panel .provider-form{border:0;padding:0;background:0 0;box-shadow:none;backdrop-filter:none}
.management-dialog-panel .management-form{grid-template-columns:repeat(2,minmax(0,1fr))}
.management-dialog-panel .dialog-form .confirm-actions,.management-dialog-panel .dialog-form .role-selector{grid-column:1/-1}
.server-deployment-workflow{display:grid;gap:14px}.deployment-workflow-steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:0;padding:0;list-style:none}.deployment-workflow-steps li{display:flex;align-items:center;gap:6px;min-height:38px;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);color:var(--ink-faint);font-size:12px}.deployment-workflow-steps li>span{display:inline-flex;align-items:center;justify-content:center;color:var(--ink-soft)}.deployment-workflow-steps strong{font-size:12px}.deployment-workflow-step-active{border-color:var(--accent)!important;background:var(--primary-command-surface)!important;color:#fff!important;box-shadow:0 0 16px var(--accent-soft)}.deployment-workflow-step-complete{border-color:color-mix(in srgb,var(--teal) 68%,var(--line));color:var(--ink)!important}.deployment-workflow-body{display:grid;gap:12px;min-height:286px}.deployment-draft-choice{grid-template-columns:auto minmax(0,1fr);align-items:start;padding:10px 12px;border:1px dashed var(--line-strong);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 62%,transparent)}.deployment-draft-choice input{width:auto!important;min-height:auto!important;margin-top:3px}.deployment-draft-choice span{display:grid;gap:3px}.deployment-draft-choice small{color:var(--ink-faint);font-weight:600;line-height:1.4}.deployment-mode-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.deployment-mode-option{display:grid;align-content:start;gap:5px;min-height:130px;padding:14px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;text-align:left}.deployment-mode-option strong{color:var(--ink);font-size:14px}.deployment-mode-option span{font-size:12px;line-height:1.45}.deployment-mode-option:hover,.deployment-mode-option:focus-visible,.deployment-mode-option-active{border-color:var(--accent);outline:0;box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft);background:var(--menu-item-active-bg)}.deployment-review{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.deployment-review>div{display:grid;gap:3px;padding:10px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 64%,transparent)}.deployment-review span{color:var(--ink-faint);font-size:11.5px;font-weight:700}.deployment-review strong{color:var(--ink);font-size:13px;overflow-wrap:anywhere}
.management-dialog-panel .plugin-detail-panel,.management-dialog-panel .plugin-group{border:0;padding:0;background:0 0;box-shadow:none;clip-path:none}
.management-dialog-panel .plugin-group::after,.management-dialog-panel .plugin-group::before{content:none}
.plugin-detail-actions{align-items:stretch}
@@ -623,7 +624,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
}
@media (max-width:760px){.auth-shell{padding:14px;place-items:stretch}
.auth-card{align-self:center}
.access-list-row,.maintenance-node-item,.maintenance-triage-grid,.management-form,.profile-background-grid,.profile-palette-grid,.profile-settings-form-row,.profile-settings-grid,.profile-settings-hero,.provider-preset-grid,.runtime-task-stages,.user-management-item,.workflow-hint-grid{grid-template-columns:1fr}
.access-list-row,.deployment-mode-grid,.deployment-review,.deployment-workflow-steps,.maintenance-node-item,.maintenance-triage-grid,.management-form,.profile-background-grid,.profile-palette-grid,.profile-settings-form-row,.profile-settings-grid,.profile-settings-hero,.provider-preset-grid,.runtime-task-stages,.user-management-item,.workflow-hint-grid{grid-template-columns:1fr}
.console-row-actions,.maintenance-actions,.profile-settings-actions,.user-actions{justify-content:flex-start}
.app-shell{display:block;grid-template-columns:1fr}
.app-sidebar{position:fixed;inset:0 auto 0 0;z-index:60;width:min(82vw,300px);min-width:0;height:100dvh;display:flex;flex-direction:column;gap:12px;align-items:stretch;padding:max(14px,env(safe-area-inset-top)) 12px max(14px,env(safe-area-inset-bottom));border-right:1px solid color-mix(in srgb,var(--line-strong) 74%,transparent);border-bottom:0;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;opacity:0;visibility:hidden;pointer-events:none;transform:translateX(calc(-100% - 18px));transition:transform .2s ease,opacity 160ms ease,visibility .2s step-end}