展示证书逐台部署结果并修正密码认证提示

This commit is contained in:
xuans 2026-09-25 22:55:08 +08:00
parent e817da8fc1
commit 85fcf91da0
7 changed files with 240 additions and 1 deletions

View File

@ -1,5 +1,6 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { settingsPayload } from "./SettingsPayload.js";
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
const defaultDNSVerificationTimeoutSeconds = 60;
const aliyunDNSVerificationTimeoutSeconds = 6;
@ -71,6 +72,8 @@ export function useDashboard() {
const taskAttempts = ref([]);
const taskDetailsLoading = ref(false);
const taskDetailsError = ref("");
const deploymentResult = ref(null);
const domainActionRunning = ref(false);
const hasOpenModal = computed(
() =>
showForm.value ||
@ -78,6 +81,7 @@ export function useDashboard() {
showSSHKeyForm.value ||
showSettings.value ||
showCertificateImport.value ||
deploymentResult.value !== null ||
showTaskDetails.value,
);
let previousBodyOverflow = "";
@ -719,6 +723,11 @@ export function useDashboard() {
}
async function runDomainAction(item, action, successMessage) {
if (domainActionRunning.value) return;
domainActionRunning.value = true;
if (action === "deploy") {
deploymentResult.value = { domain: item.name, running: true, report: null, error: "" };
}
error.value = "";
notice.value = "";
try {
@ -729,12 +738,26 @@ export function useDashboard() {
}
const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options);
const result = await response.json().catch(() => ({}));
if (action === "deploy") {
deploymentResult.value = {
domain: item.name,
running: false,
report: result.deployment || null,
error: response.ok && result.deployment ? "" : result.error || deploymentSummary(null),
};
}
if (!response.ok) throw new Error(result.error || "域名操作失败");
notice.value = successMessage;
if (action === "deploy") notice.value = deploymentSummary(result.deployment);
} catch (err) {
error.value = err.message || "域名操作失败";
} finally {
const actionError = error.value;
if (action === "deploy" && deploymentResult.value) {
deploymentResult.value.running = false;
if (actionError) deploymentResult.value.error = actionError;
}
domainActionRunning.value = false;
await loadData();
if (actionError) error.value = actionError;
}
@ -1262,6 +1285,7 @@ export function useDashboard() {
dnsProviders,
environmentLabels,
webServerLabels,
serverAuthLabel,
isEditing,
formTitle,
form,
@ -1319,6 +1343,7 @@ export function useDashboard() {
taskAttempts,
taskDetailsLoading,
taskDetailsError,
deploymentResult,
taskTypeLabel,
auditActionLabel,
taskStageLabel,

View File

@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import "./DeploymentResult.test.js";
import test from "node:test";
import { nextTick, ref } from "vue";
import { useDashboard } from "./Dashboard.js";
@ -108,6 +109,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
app.component(name, { render: () => null });
}
app.component("UserManagement", { render: () => Vue.h("h2", headings.users) });
app.component("DeploymentResult", { render: () => null });
const warnings = [];
app.config.warnHandler = (message) => warnings.push(message);
const html = await renderToString(app);

View File

@ -8,6 +8,7 @@ import SSHKeyModal from "./SSHKeyModal.vue";
import DashboardNavigation from "./DashboardNavigation.vue";
import { useDashboardNavigation } from "./DashboardNavigation.js";
import { useDashboard } from "./Dashboard.js";
import DeploymentResult from "./DeploymentResult.vue";
const dashboard = useDashboard();
const {
@ -128,6 +129,8 @@ const {
taskAttempts,
taskDetailsLoading,
taskDetailsError,
deploymentResult,
serverAuthLabel,
displayLabel,
formatDate,
daysRemaining,
@ -471,7 +474,8 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
</div>
<div class="domain-meta">
<span>{{ item.username }}@{{ item.address }}:{{ item.port }}</span
><span>私钥引用已配置</span
><span v-if="item.auth_method === 'private_key' && item.private_key_ref">私钥引用已配置</span
><span v-else>{{ serverAuthLabel(item) }}</span
><span>指纹:{{ item.host_key_fingerprint }}</span>
</div>
</div>
@ -550,6 +554,12 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<SSHKeyModal v-if="showSSHKeyForm" :state="dashboard" />
<DeploymentResult
v-if="deploymentResult"
:result="deploymentResult"
@close="deploymentResult = null"
/>
<div v-if="showTaskDetails" class="modal-backdrop" @click.self="closeTaskDetails">
<section class="modal-card task-details-modal" role="dialog" aria-modal="true" aria-label="任务详情">
<div class="modal-heading">

View File

@ -0,0 +1,25 @@
export function serverAuthLabel(server) {
if (server.auth_method === "password") {
return server.password_configured ? "SSH 密码已配置" : "SSH 密码未配置";
}
if (server.auth_method === "private_key") {
return server.private_key_ref ? "SSH 私钥已配置" : "SSH 私钥未配置";
}
return "认证方式未确认";
}
export function deploymentSummary(report) {
if (!report || !Array.isArray(report.results)) return "未收到有效部署结果";
if (report.total === 0 && report.results.length === 0) return "未配置部署目标,未执行推送";
if (report.status === "success" && report.total > 0 &&
report.success === report.total && report.failed === 0 &&
report.results.length === report.total &&
report.results.every((item) => item.status === "success")) {
return `证书部署完成:${report.success} 台成功`;
}
return "证书部署未全部成功,请检查各服务器结果";
}
export function deploymentStatusLabel(status) {
return { success: "成功", failed: "失败", partial: "部分失败" }[status] || "未确认";
}

View File

@ -0,0 +1,28 @@
.deployment-result {
width: min(680px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
overflow: auto;
}
.deployment-summary {
font-weight: 600;
}
.deployment-targets {
max-height: 40dvh;
overflow: auto;
}
.deployment-target {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border-color, #e2e8f0);
}
.deployment-target p {
margin: 6px 0 0;
overflow-wrap: anywhere;
}

View File

@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
import { parse, compileTemplate } from "@vue/compiler-sfc";
import { useDashboard } from "./Dashboard.js";
import { serverAuthLabel, deploymentSummary } from "./DeploymentFeedback.js";
import { mountComposable, deferred } from "../../test-support/mount.js";
const report = {
total: 1,
success: 1,
failed: 0,
status: "success",
results: [{ server_id: "server-1", server: "验收服务器", status: "success", message: "部署完成" }],
};
test("服务器认证提示区分密码、私钥及缺失配置", () => {
assert.equal(serverAuthLabel({ auth_method: "password", password_configured: true }), "SSH 密码已配置");
assert.equal(serverAuthLabel({ auth_method: "password", private_key_ref: "secret://test" }), "SSH 密码未配置");
assert.equal(serverAuthLabel({ auth_method: "private_key", private_key_ref: "secret://test" }), "SSH 私钥已配置");
assert.equal(serverAuthLabel({ auth_method: "private_key" }), "SSH 私钥未配置");
assert.equal(serverAuthLabel({}), "认证方式未确认");
});
test("部署汇总不把空目标、缺失结果或部分失败称为成功", () => {
assert.equal(deploymentSummary(report), "证书部署完成:1 台成功");
assert.equal(deploymentSummary(null), "未收到有效部署结果");
assert.equal(deploymentSummary({ total: 0, results: [] }), "未配置部署目标,未执行推送");
assert.match(deploymentSummary({ ...report, results: [] }), /未全部成功/);
assert.match(deploymentSummary({ ...report, status: "partial", failed: 1 }), /未全部成功/);
});
test("手动部署展示真实返回结果并阻止执行期间重复提交", async (t) => {
const pending = deferred();
let requests = 0;
const { state } = await mountComposable(t, useDashboard, async (path) => {
if (path.endsWith("/deploy")) {
requests++;
return pending.promise;
}
return Response.json({ items: [] });
});
const action = state.deployDomain({ id: "domain-1", name: "example.test" });
assert.equal(state.deploymentResult.value.running, true);
await state.deployDomain({ id: "domain-1", name: "example.test" });
assert.equal(requests, 1);
pending.resolve(Response.json({ deployment: report }));
await action;
assert.equal(state.deploymentResult.value.running, false);
assert.deepEqual(state.deploymentResult.value.report, report);
assert.equal(state.notice.value, "证书部署完成:1 台成功");
});
test("部署失败仍保留各服务器结果,网络失败不会残留执行中状态", async (t) => {
let failNetwork = false;
const failed = { ...report, status: "failed", success: 0, failed: 1, results: [{ ...report.results[0], status: "failed" }] };
const { state } = await mountComposable(t, useDashboard, async (path) => {
if (path.endsWith("/deploy")) {
if (failNetwork) throw new Error("network-test-failure");
return Response.json({ error: "部署失败", deployment: failed }, { status: 502 });
}
return Response.json({ items: [] });
});
await state.deployDomain({ id: "domain-1", name: "example.test" });
assert.deepEqual(state.deploymentResult.value.report, failed);
assert.equal(state.deploymentResult.value.error, "部署失败");
assert.equal(state.notice.value, "");
failNetwork = true;
await state.deployDomain({ id: "domain-1", name: "example.test" });
assert.equal(state.deploymentResult.value.running, false);
assert.equal(state.deploymentResult.value.report, null);
assert.equal(state.deploymentResult.value.error, "network-test-failure");
});
test("部署结果组件可编译且认证提示绑定真实配置", async () => {
const source = await readFile(new URL("./DeploymentResult.vue", import.meta.url), "utf8");
const { descriptor, errors } = parse(source);
assert.deepEqual(errors, []);
const compiled = compileTemplate({ source: descriptor.template.content, filename: "DeploymentResult.vue", id: "deployment-result" });
assert.deepEqual(compiled.errors, []);
assert.match(source, /:disabled="result.running"/);
assert.match(source, /role="dialog"/);
const dashboard = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
assert.match(dashboard, /serverAuthLabel\(item\)/);
assert.match(dashboard, /item.auth_method === 'private_key' && item.private_key_ref/);
});

View File

@ -0,0 +1,63 @@
<script setup>
import { deploymentStatusLabel, deploymentSummary } from "./DeploymentFeedback.js";
defineProps({ result: { type: Object, required: true } });
defineEmits(["close"]);
</script>
<template>
<div class="modal-backdrop" @click.self="!result.running && $emit('close')">
<section
class="modal-card deployment-result"
role="dialog"
aria-modal="true"
aria-labelledby="deployment-result-title"
:aria-busy="result.running"
>
<div class="modal-heading">
<div>
<h2 id="deployment-result-title">证书部署结果</h2>
<p class="muted">{{ result.domain }}</p>
</div>
<button
class="close-button"
type="button"
aria-label="关闭"
:disabled="result.running"
@click="$emit('close')"
>×</button>
</div>
<p v-if="result.running" role="status">正在连接服务器、推送证书并校验重载,请稍候……</p>
<template v-else>
<p v-if="result.error" class="form-error" role="alert">{{ result.error }}</p>
<p v-if="result.report" class="deployment-summary" role="status">
{{ deploymentSummary(result.report) }}
</p>
<div v-if="result.report" class="deployment-targets" tabindex="0" aria-label="各服务器部署结果">
<article
v-for="(item, index) in result.report.results"
:key="item.server_id || index"
class="deployment-target"
>
<div>
<strong>{{ item.server || "未命名服务器" }}</strong>
<p>{{ item.message || "无附加信息" }}</p>
</div>
<span class="tag" :class="item.status === 'success' ? 'status-success' : 'status-error'">
{{ deploymentStatusLabel(item.status) }}
</span>
</article>
</div>
<p class="muted">此处显示本次推送结果;远程站点是否使用新证书,仍需访问站点验证。</p>
</template>
<div class="modal-actions">
<button class="secondary-button" type="button" :disabled="result.running" @click="$emit('close')">
关闭
</button>
</div>
</section>
</div>
</template>
<style scoped src="./Dashboard.css"></style>
<style scoped src="./DeploymentResult.css"></style>