合并服务器配置与SSH密钥引导流程

This commit is contained in:
xuans 2026-09-26 13:10:04 +08:00
parent 1b3677d95b
commit 5f4d524b98
4 changed files with 369 additions and 4 deletions

View File

@ -38,6 +38,7 @@ export function useDashboard() {
const serverSaving = ref(false);
const serverFormError = ref("");
const showSSHKeyForm = ref(false);
const showEmbeddedSSHKeyGuide = ref(false);
const sshKeySaving = ref(false);
const sshKeyFormError = ref("");
const sshKeyPlatform = ref("windows");
@ -310,6 +311,15 @@ export function useDashboard() {
const serverForm = ref(createEmptyServerForm());
const sshKeyForm = ref(createEmptySSHKeyForm());
watch(
() => [serverForm.value.username, serverForm.value.address],
([username, address]) => {
if (!showEmbeddedSSHKeyGuide.value) return;
sshKeyCommandUsername.value = String(username || "").trim() || "用户名";
sshKeyCommandHost.value = String(address || "").trim() || "服务器地址";
},
);
async function fetchAdmin(path, options = {}) {
const response = await fetch(path, {
...options,
@ -583,10 +593,13 @@ export function useDashboard() {
}
function closeServerForm() {
if (!serverSaving.value) showServerForm.value = false;
if (!serverSaving.value) {
showEmbeddedSSHKeyGuide.value = false;
showServerForm.value = false;
}
}
function openSSHKeyCreate() {
function openSSHKeyCreate(options = {}) {
sshKeyForm.value = createEmptySSHKeyForm();
sshKeyFormError.value = "";
sshKeyPlatform.value = "windows";
@ -595,7 +608,8 @@ export function useDashboard() {
sshKeyCommandHost.value = serverForm.value.address.trim() || "服务器地址";
sshKeyRandomSuffix.value = createSSHKeyRandomSuffix();
sshKeyForm.value.name = sshKeyFileName.value;
showSSHKeyForm.value = true;
showEmbeddedSSHKeyGuide.value = options.embedded === true;
showSSHKeyForm.value = options.embedded !== true;
}
function closeSSHKeyForm() {
@ -603,6 +617,7 @@ export function useDashboard() {
sshKeyForm.value = createEmptySSHKeyForm();
sshKeyFormError.value = "";
sshKeyPassphraseVisible.value = false;
showEmbeddedSSHKeyGuide.value = false;
showSSHKeyForm.value = false;
}
@ -641,7 +656,11 @@ export function useDashboard() {
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "保存 SSH 私钥失败");
if (showEmbeddedSSHKeyGuide.value && result.reference) {
serverForm.value.private_key_ref = result.reference;
}
sshKeyForm.value = createEmptySSHKeyForm();
showEmbeddedSSHKeyGuide.value = false;
showSSHKeyForm.value = false;
notice.value = "SSH 私钥已加密保存";
await loadData();
@ -1273,6 +1292,7 @@ export function useDashboard() {
serverSaving,
serverFormError,
showSSHKeyForm,
showEmbeddedSSHKeyGuide,
sshKeySaving,
sshKeyFormError,
certificateTypeLabels,

View File

@ -539,6 +539,23 @@ test("serverModal.compilesAndPreservesDeploymentFields", async () => {
},
sshKeys: [],
openSSHKeyCreate: () => {},
showEmbeddedSSHKeyGuide: false,
closeSSHKeyForm: () => {},
sshKeySaving: false,
sshKeyFormError: "",
sshKeyPlatform: "windows",
sshKeyFileName: "test-key",
sshKeyPrivatePath: "/test/private",
sshKeyPublicPath: "/test/public",
sshKeyCommandUsername: "deploy",
sshKeyCommandHost: "example.test",
sshKeyCommands: { windows: "generate-windows", linux: "generate-linux" },
sshPublicKeyCommands: { windows: "install-windows", linux: "install-linux" },
sshKeyForm: { name: "", private_key: "", passphrase: "" },
importSSHKeyFile: () => {},
sshKeyPassphraseVisible: false,
copySSHKeyCommand: () => {},
saveSSHKey: () => {},
defaultCertificatePath: "",
defaultCertificateChainPath: "",
defaultPrivateKeyPath: "",

View File

@ -74,6 +74,161 @@
border-top: 1px solid #dce6f1;
}
.server-modal .server-ssh-key-guide {
grid-column: 1 / -1;
min-width: 0;
margin: 2px 0 8px;
padding: 14px;
border: 1px solid #c7daf1;
border-radius: 14px;
background: #f5f9ff;
}
.server-modal .server-ssh-key-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
}
.server-modal .server-ssh-key-heading h3 {
margin: 2px 0 4px;
color: #214d86;
font-size: 18px;
}
.server-modal .server-ssh-key-guide .ssh-key-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.server-modal .server-ssh-key-guide .ssh-key-steps article {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 9px 10px;
border: 1px solid #d1e0f1;
border-radius: 10px;
background: #fff;
color: #294a73;
}
.server-modal .server-ssh-key-guide .ssh-key-steps b {
display: inline-grid;
place-items: center;
flex: 0 0 24px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #5b91dc;
color: #fff;
}
.server-modal .server-ssh-key-guide .ssh-key-platforms {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
margin-bottom: 8px;
padding: 4px;
border-radius: 10px;
background: #e7effa;
}
.server-modal .server-ssh-key-guide .platform-tab {
min-height: 34px;
border: 0;
border-radius: 8px;
background: transparent;
color: #5d7390;
cursor: pointer;
font: inherit;
}
.server-modal .server-ssh-key-guide .platform-tab.active {
background: #fff;
color: #24579a;
box-shadow: 0 2px 8px rgb(42 80 125 / 12%);
}
.server-modal .server-ssh-key-guide .ssh-key-path-line,
.server-modal .server-ssh-key-guide .ssh-key-path-summary {
display: grid;
gap: 8px;
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid #d1e0f1;
border-radius: 10px;
background: #fff;
}
.server-modal .server-ssh-key-guide .ssh-key-path-line {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
}
.server-modal .server-ssh-key-guide .ssh-key-path-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.server-modal .server-ssh-key-guide .ssh-key-path-summary > div {
display: grid;
gap: 4px;
min-width: 0;
}
.server-modal .server-ssh-key-guide code {
overflow-wrap: anywhere;
color: #294a73;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
.server-modal .server-ssh-key-guide .ssh-command-card {
margin-bottom: 8px;
padding: 9px 10px;
border: 1px solid #d1e0f1;
border-radius: 10px;
background: #fff;
}
.server-modal .server-ssh-key-guide .ssh-command-heading,
.server-modal .server-ssh-key-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.server-modal .server-ssh-key-guide .ssh-command-heading {
margin-bottom: 5px;
color: #294a73;
font-weight: 700;
}
.server-modal .server-ssh-key-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
padding-top: 2px;
}
.server-modal .server-ssh-key-fields .field:nth-child(3) {
grid-column: 1 / -1;
}
.server-modal .server-ssh-key-fields textarea {
min-height: 96px;
}
.server-modal .server-ssh-key-actions {
padding-top: 8px;
border-top: 1px solid #d1e0f1;
}
@media (max-width: 900px) {
.server-modal form {
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -86,6 +241,10 @@
.server-modal .server-paths {
grid-template-columns: minmax(0, 1fr);
}
.server-modal .server-ssh-key-guide .ssh-key-path-summary {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
@ -102,4 +261,23 @@
.server-modal form > .field {
grid-column: 1 / -1;
}
.server-modal .server-ssh-key-guide .ssh-key-steps,
.server-modal .server-ssh-key-fields {
grid-template-columns: minmax(0, 1fr);
}
.server-modal .server-ssh-key-fields .field:nth-child(3) {
grid-column: auto;
}
.server-modal .server-ssh-key-guide .ssh-key-path-line {
grid-template-columns: minmax(0, 1fr);
}
.server-modal .server-ssh-key-heading,
.server-modal .server-ssh-key-actions {
align-items: stretch;
flex-direction: column;
}
}

View File

@ -9,6 +9,23 @@ const {
serverForm,
sshKeys,
openSSHKeyCreate,
closeSSHKeyForm,
showEmbeddedSSHKeyGuide,
sshKeySaving,
sshKeyFormError,
sshKeyPlatform,
sshKeyFileName,
sshKeyPrivatePath,
sshKeyPublicPath,
sshKeyCommandUsername,
sshKeyCommandHost,
sshKeyCommands,
sshPublicKeyCommands,
sshKeyForm,
importSSHKeyFile,
sshKeyPassphraseVisible,
copySSHKeyCommand,
saveSSHKey,
defaultCertificatePath,
defaultCertificateChainPath,
defaultPrivateKeyPath,
@ -113,7 +130,7 @@ const {
><button
class="inline-button"
type="button"
@click="openSSHKeyCreate"
@click="openSSHKeyCreate({ embedded: true })"
>
新建或导入
</button></span
@ -131,6 +148,139 @@ const {
></label
>
</div>
<section v-if="showEmbeddedSSHKeyGuide" class="server-ssh-key-guide">
<div class="server-ssh-key-heading">
<div>
<p class="eyebrow">当前服务器</p>
<h3>在这里准备 SSH 私钥</h3>
<span class="muted">准备完成后会自动选中,不需要离开服务器配置。</span>
</div>
<button class="inline-button" type="button" @click="closeSSHKeyForm">
收起引导
</button>
</div>
<div class="ssh-key-steps">
<article>
<b>1</b>
<strong>本机生成密钥</strong>
</article>
<article>
<b>2</b>
<strong>把公钥放到当前服务器</strong>
</article>
<article>
<b>3</b>
<strong>读取私钥并保存</strong>
</article>
</div>
<div class="ssh-key-platforms" role="tablist" aria-label="密钥生成平台">
<button
class="platform-tab"
:class="{ active: sshKeyPlatform === 'windows' }"
type="button"
role="tab"
:aria-selected="sshKeyPlatform === 'windows'"
@click="sshKeyPlatform = 'windows'"
>
Windows
</button>
<button
class="platform-tab"
:class="{ active: sshKeyPlatform === 'linux' }"
type="button"
role="tab"
:aria-selected="sshKeyPlatform === 'linux'"
@click="sshKeyPlatform = 'linux'"
>
Linux / macOS
</button>
</div>
<div class="ssh-key-path-line">
<span>本次密钥文件名</span>
<code>{{ sshKeyFileName }}</code>
<span class="guide-badge">每次随机生成</span>
</div>
<div class="ssh-key-path-summary">
<div>
<span>本地私钥位置</span>
<code>{{ sshKeyPrivatePath }}</code>
</div>
<div>
<span>本地公钥位置</span>
<code>{{ sshKeyPublicPath }}</code>
</div>
<div>
<span>远程登录目标</span>
<code>{{ sshKeyCommandUsername }}@{{ sshKeyCommandHost }}</code>
</div>
</div>
<div class="ssh-command-card">
<div class="ssh-command-heading">
<span>生成命令</span>
<button
class="inline-button"
type="button"
@click="copySSHKeyCommand(sshKeyCommands[sshKeyPlatform], '密钥生成命令已复制')"
>
复制命令
</button>
</div>
<code>{{ sshKeyCommands[sshKeyPlatform] }}</code>
</div>
<div class="ssh-command-card">
<div class="ssh-command-heading">
<span>安装公钥命令</span>
<button
class="inline-button"
type="button"
@click="copySSHKeyCommand(sshPublicKeyCommands[sshKeyPlatform], '公钥安装命令已复制')"
>
复制命令
</button>
</div>
<code>{{ sshPublicKeyCommands[sshKeyPlatform] }}</code>
</div>
<div class="server-ssh-key-fields">
<label class="field">
<span>私钥名称</span>
<input v-model="sshKeyForm.name" required type="text" maxlength="128" autocomplete="off" />
</label>
<label class="field">
<span>读取本机私钥文件</span>
<input type="file" @change="importSSHKeyFile" />
</label>
<label class="field">
<span>私钥内容</span>
<textarea
v-model="sshKeyForm.private_key"
rows="4"
spellcheck="false"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
></textarea>
</label>
<label class="field">
<span class="field-label-row">
<span>私钥口令</span>
<button class="inline-button" type="button" @click="sshKeyPassphraseVisible = !sshKeyPassphraseVisible">
{{ sshKeyPassphraseVisible ? "隐藏口令" : "显示口令" }}
</button>
</span>
<input
v-model="sshKeyForm.passphrase"
:type="sshKeyPassphraseVisible ? 'text' : 'password'"
maxlength="4096"
autocomplete="new-password"
/>
</label>
</div>
<p v-if="sshKeyFormError" class="form-error">{{ sshKeyFormError }}</p>
<div class="server-ssh-key-actions">
<span class="muted">私钥只会加密保存,服务器配置仅保存安全引用。</span>
<button class="primary-button" type="button" :disabled="sshKeySaving" @click="saveSSHKey">
{{ sshKeySaving ? "加密保存中……" : "加密保存并用于当前服务器" }}
</button>
</div>
</section>
<label class="field"
><span>SSH 主机 SHA256 指纹</span
><input