拆分系统设置为单屏分组并修复已保存DNS凭据的必填校验

This commit is contained in:
xuans 2026-09-24 23:58:25 +08:00
parent 3a28ae9f5b
commit 83c7e0204b
8 changed files with 760 additions and 262 deletions

View File

@ -4,7 +4,7 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "node --test src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
"test": "node --test src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"

View File

@ -1183,6 +1183,7 @@ export function useDashboard() {
dnsVerificationElapsedSeconds,
dnsVerificationTimeoutSeconds,
dnsReadyToSave,
dnsVerificationRequired,
handleDNSProviderChange,
clearDNSCredentials,
testDNS,

View File

@ -162,4 +162,17 @@ test("auditActionLabel", async (t) => {
assert.equal(state.auditActionLabel(null), state.taskTypeLabel(null));
assert.equal(state.auditActionLabel(undefined), state.taskTypeLabel(undefined));
assert.equal(state.auditActionLabel(""), state.taskTypeLabel(""));
});
});
test("settings.savedDnsCredentialsRemainOptionalUntilChanged", async (t) => {
const { state } = await setup(t, () => verifiedResponse());
assert.equal(state.dnsVerificationRequired.value, false);
state.settingsForm.value.acme.renewal_threshold_days = 91;
assert.equal(state.dnsVerificationRequired.value, false);
assert.equal(state.dnsReadyToSave.value, true);
await fill(state);
assert.equal(state.dnsVerificationRequired.value, true);
assert.equal(state.dnsReadyToSave.value, false);
await state.testDNS();
assert.equal(state.dnsReadyToSave.value, true);
});

View File

@ -1,8 +1,10 @@
<script setup>
import ChangePassword from "../components/ChangePassword/ChangePassword.vue";
import UserManagement from "../UserManagement/UserManagement.vue";
import SettingsModal from "./SettingsModal.vue";
import { useDashboard } from "./Dashboard.js";
const dashboard = useDashboard();
const {
scertLogo,
status,
@ -107,7 +109,7 @@ const {
formatDate,
daysRemaining,
statusClass,
} = useDashboard();
} = dashboard;
</script>
<template>
@ -422,265 +424,7 @@ const {
</div>
</section>
<div v-if="showSettings" class="modal-backdrop" @click.self="closeSettings">
<section class="modal-card settings-modal" role="dialog" aria-modal="true" aria-label="系统设置">
<div class="modal-heading">
<div>
<p class="eyebrow">系统配置</p>
<h2>系统设置</h2>
</div>
<button class="close-button" type="button" aria-label="关闭" @click="closeSettings">×</button>
</div>
<form @submit.prevent="saveSettings">
<h3>服务监听</h3>
<div class="form-grid">
<label class="field"><span>监听地址</span><input v-model="settingsForm.server.host" required /></label>
<label class="field"><span>服务端口</span><input v-model.number="settingsForm.server.port" type="number" min="1" max="65535" required /></label>
</div>
<div class="form-grid">
<label class="field"><span>HTTPS 模式</span><select v-model="settingsForm.server.https_mode"><option value="direct">直接终止 HTTPS</option><option value="proxy">反向代理 HTTPS</option></select></label>
<label v-if="settingsForm.server.https_mode === 'proxy'" class="field">
<span>代理公开 HTTPS 端口(0 为默认 443)</span>
<input v-model.number="settingsForm.server.proxy_https_port" type="number" min="0" max="65535" required />
</label>
<label class="field"><span>证书存储路径</span><input v-model="settingsForm.paths.certificate" required /></label>
</div>
<div class="form-grid">
<label class="field"><span>管理页面域名</span><input v-model="settingsForm.web.hostname" placeholder="例如 ssl.example.com" /></label>
<label class="field"><span>管理页面公网 IP</span><input v-model="settingsForm.web.public_ip" placeholder="例如 203.0.113.10" /></label>
</div>
<div class="settings-web-controls">
<label class="switch-field settings-switch-field">
<input v-model="settingsForm.web.enabled" type="checkbox" />
<span>
<strong>开启 Web 管理页面</strong>
<small>关闭后停止网络 Web 管理入口,桌面端本地管理功能不受影响。</small>
</span>
</label>
<p class="field-hint settings-web-hint">修改此开关后需要重启 Scert 服务,新的监听状态才会生效。</p>
</div>
<h3>ACME 设置</h3>
<div class="form-grid">
<label class="field"><span>ACME 环境</span><select v-model="settingsForm.acme.environment"><option value="staging">测试环境</option><option value="production">生产环境</option></select></label>
<label class="field"><span>自动续期阈值(天)</span><input v-model.number="settingsForm.acme.renewal_threshold_days" type="number" min="1" max="365" /></label>
</div>
<div class="form-grid">
<label class="field"><span>常规检查间隔(天)</span><input v-model.number="settingsForm.acme.renewal_check_interval_days" type="number" min="1" max="30" /></label>
<label class="field"><span>临近到期检查阈值(天)</span><input v-model.number="settingsForm.acme.renewal_near_expiry_days" type="number" min="1" max="365" /></label>
</div>
<div class="form-grid">
<label class="field"><span>紧急续期阈值(天)</span><input v-model.number="settingsForm.acme.renewal_urgent_threshold_days" type="number" min="1" max="365" /></label>
<label class="field"><span>紧急续期最大尝试次数</span><input v-model.number="settingsForm.acme.renewal_max_retries" type="number" min="1" max="10" /></label>
</div>
<div class="form-grid">
<label class="field"><span>续期重试间隔(分钟)</span><input v-model.number="settingsForm.acme.renewal_retry_interval_mins" type="number" min="1" max="1440" /></label>
<div class="field-hint">剩余有效期低于自动续期阈值时触发续期;低于紧急阈值时按配置重试。</div>
</div>
<div class="form-grid">
<label class="field">
<span>DNS 传播等待(秒)</span>
<input
v-model.number="settingsForm.acme.propagation_timeout_secs"
type="number"
min="1"
/>
</label>
<label class="field">
<span>DNS 检测间隔(秒)</span>
<input
v-model.number="settingsForm.acme.propagation_polling_interval_secs"
type="number"
min="1"
/>
</label>
</div>
<p class="field-hint">
阿里云 RAM 权限验证最多等待 6 秒:系统会创建、查询并清理临时 TXT
记录。以上 300 秒和 5 秒用于实际申请证书时的公网 DNS-01
传播等待与轮询。
</p>
<h3>DNS API 验证</h3>
<div class="settings-section-note">
<span>输入域名后,系统将使用当前阿里云账号立即创建、查询并清理临时 TXT 记录,以验证该域名的实际 DNS 操作权限。</span>
<span>凭据只会在本次在线测试请求中提交,系统仅保存加密引用。</span>
</div>
<div class="form-grid">
<label class="field" for="settings-dns-provider">
<span>DNS 服务商</span>
<select
id="settings-dns-provider"
v-model="settingsForm.dns.provider"
@change="handleDNSProviderChange"
>
<option value="">暂不配置</option>
<option
v-for="provider in dnsProviderCatalog"
:key="provider.id"
:value="provider.id"
:disabled="!provider.available"
>
{{ provider.name }}{{ provider.available ? "" : "(暂不可用)" }}
</option>
</select>
</label>
<label class="field" for="settings-dns-test-domain">
<span>在线测试域名</span>
<input
id="settings-dns-test-domain"
v-model="settingsForm.dns.test_domain"
placeholder="例如 example.com"
autocomplete="off"
/>
</label>
</div>
<p v-if="selectedDNSProvider" class="field-hint dns-provider-description">
{{ selectedDNSProvider.description }}
</p>
<p
v-if="selectedDNSProvider && !selectedDNSProvider.available"
class="form-error"
>
当前服务商适配器暂未开放,无法进行在线测试。
</p>
<div v-if="selectedDNSProvider" class="dns-credentials-grid">
<label
v-for="field in selectedDNSProvider.credential_fields"
:key="field.name"
class="field"
:for="`settings-dns-${field.name}`"
>
<span>
{{ field.label }}
<small v-if="field.required">必填</small>
</span>
<input
:id="`settings-dns-${field.name}`"
v-model="settingsForm.dns.credentials[field.name]"
:type="field.secret ? 'password' : 'text'"
:placeholder="field.placeholder"
:required="field.required"
autocomplete="off"
/>
</label>
</div>
<div v-if="selectedDNSProvider" class="dns-test-actions">
<button
class="secondary-button"
type="button"
:disabled="dnsTesting || settingsSaving || !selectedDNSProvider.available"
@click="testDNS"
>
{{ dnsTesting ? "测试中……" : "在线测试 DNS API" }}
</button>
<button
class="text-button"
type="button"
:disabled="dnsTesting || settingsSaving"
@click="clearDNSCredentials"
>
清空凭据
</button>
<span
class="dns-verification-status"
:class="settingsForm.dns.verification_state"
>
{{ settingsForm.dns.verification_state === "verified" ? "已验证" : "待验证" }}
</span>
</div>
<div
v-if="dnsTesting"
class="dns-verification-progress"
role="status"
aria-live="polite"
>
<strong>正在进行 DNS 验证,请等待</strong>
<span>
已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待
{{ dnsVerificationTimeoutSeconds }} 秒。
</span>
<small v-if="selectedDNSProvider?.id === 'aliyun'">
正在通过阿里云 DNS API 创建、查询并清理临时 TXT 记录,不等待公网 DNS 传播,通常数秒即可完成;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
<small v-else>
系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
</div>
<p v-if="dnsTestError" class="form-error">{{ dnsTestError }}</p>
<div
v-if="settingsForm.dns.test_report?.stages?.length"
class="dns-test-report"
>
<div class="dns-test-report-heading">
<strong>在线测试结果</strong>
<span v-if="settingsForm.dns.test_report.record_name">
临时记录:{{ settingsForm.dns.test_report.record_name }}
</span>
</div>
<div
v-for="stage in settingsForm.dns.test_report.stages"
:key="stage.name"
class="dns-test-stage"
>
<span class="dns-stage-indicator" :class="stage.status"></span>
<div>
<strong>{{ stage.name }}</strong>
<span>{{ stage.message }}</span>
</div>
</div>
</div>
<p v-if="!dnsReadyToSave" class="security-hint">
DNS 服务商发生变化或尚未配置时,必须先完成 DNS API 在线测试。
</p>
<h3>HTTP 通知</h3>
<div class="form-grid">
<label class="switch-field">
<input v-model="settingsForm.notification.http_enabled" type="checkbox" />
<span><strong>启用 HTTP 通知</strong><small>通过外部邮件或消息服务 API 发送通知</small></span>
</label>
<label class="field"><span>通知服务商</span><input v-model="settingsForm.notification.provider" placeholder="例如 temporary-mail" /></label>
</div>
<div class="form-grid">
<label class="field"><span>通知接口地址</span><input v-model="settingsForm.notification.endpoint" placeholder="https://..." /></label>
<label class="field"><span>发件人邮箱</span><input v-model="settingsForm.notification.sender_email" type="email" /></label>
</div>
<label class="field"><span>收件人</span><input :value="settingsForm.notification.recipients.join(', ')" @input="settingsForm.notification.recipients = $event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean)" placeholder="多个地址用逗号分隔" /></label>
<h3>SMTP 通知</h3>
<label class="switch-field">
<input v-model="settingsForm.notification.smtp_enabled" type="checkbox" />
<span><strong>启用 SMTP 通知</strong><small>使用外部 SMTP 服务发送证书任务通知,不会实现邮件服务器端</small></span>
</label>
<div class="form-grid">
<label class="field"><span>SMTP 主机</span><input v-model="settingsForm.notification.smtp_host" placeholder="例如 smtp.example.com" /></label>
<label class="field"><span>SMTP 端口</span><input v-model.number="settingsForm.notification.smtp_port" type="number" min="1" max="65535" /></label>
</div>
<div class="form-grid">
<label class="field"><span>SMTP 用户名</span><input v-model="settingsForm.notification.smtp_username" autocomplete="username" /></label>
<label class="field"><span>SMTP 密码</span><input v-model="settingsForm.notification.smtp_password" type="password" autocomplete="new-password" placeholder="留空表示不更新已保存密码" /></label>
</div>
<div class="form-grid">
<label class="field"><span>连接模式</span><select v-model="settingsForm.notification.smtp_mode"><option value="plain">明文连接</option><option value="starttls">STARTTLS</option><option value="implicit_tls">隐式 TLS</option></select></label>
<label class="switch-field">
<input v-model="settingsForm.notification.smtp_allow_plaintext_auth" type="checkbox" />
<span><strong>允许明文认证</strong><small>仅在明确确认 SMTP 网络受保护时开启</small></span>
</label>
</div>
<p class="security-hint">
{{ settingsForm.notification.smtp_credential_configured ? "SMTP 凭据已配置;密码不会回显。填写新密码后保存即可更新。" : "SMTP 凭据尚未配置;启用 SMTP 时请填写用户名和密码。" }}
</p>
<p v-if="settingsError" class="form-error">{{ settingsError }}</p>
<div class="modal-actions">
<button class="secondary-button" type="button" :disabled="settingsSaving" @click="closeSettings">取消</button>
<button
class="primary-button"
type="submit"
:disabled="settingsSaving || !dnsReadyToSave"
>
{{ settingsSaving ? "保存中……" : "保存设置" }}
</button>
</div>
</form>
</section>
</div>
<SettingsModal v-if="showSettings" :state="dashboard" />
<div v-if="showCertificateImport" class="modal-backdrop" @click.self="closeCertificateImport">

View File

@ -0,0 +1,153 @@
.modal-backdrop {
padding: 16px;
}
.settings-modal {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: min(100%, 1060px);
height: min(660px, calc(100dvh - 32px));
max-height: calc(100dvh - 32px);
overflow: hidden;
padding: 22px 28px;
}
.settings-modal .modal-heading {
flex: 0 0 auto;
margin-bottom: 16px;
}
.settings-tabs {
display: flex;
flex: 0 0 auto;
gap: 6px;
padding-bottom: 12px;
border-bottom: 1px solid #dce6f1;
}
.settings-tabs button {
flex: 1;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 9px;
color: #526983;
background: #f2f6fb;
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.settings-tabs button[aria-selected="true"] {
border-color: #b7cdec;
color: #234f99;
background: #e7f0fd;
}
.settings-tabs button:focus-visible {
outline: 2px solid #315faa;
outline-offset: 2px;
}
.settings-modal form {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.settings-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 2px 8px;
scrollbar-gutter: stable;
}
.settings-panel > h3 {
margin: 0 0 14px;
color: #32405a;
font-size: 15px;
}
.settings-panel .form-grid,
.settings-panel .dns-credentials-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 18px;
}
.settings-panel .field {
margin-bottom: 12px;
gap: 6px;
}
.settings-panel .field > span {
font-size: 12px;
}
.settings-panel .field input,
.settings-panel .field select {
min-height: 36px;
padding: 8px 11px;
font-size: 13px;
}
.settings-panel .switch-field {
margin-bottom: 10px;
}
.settings-panel .switch-field > span {
display: flex;
align-items: center;
}
.settings-panel :deep(.help-tip-trigger) {
margin-bottom: 6px;
}
.settings-modal .modal-actions {
flex: 0 0 auto;
margin-top: 10px;
padding-top: 12px;
border-top: 1px solid #dce6f1;
}
.settings-modal .form-error {
flex: 0 0 auto;
margin: 6px 0;
}
.settings-panel .dns-test-actions {
margin: 8px 0;
padding: 8px 10px;
}
.settings-panel .dns-test-report {
gap: 3px;
margin: 8px 0;
padding: 8px 12px;
}
.settings-panel .dns-test-stage {
padding: 4px 0;
}
@media (max-width: 700px) {
.settings-modal {
padding: 16px;
}
.settings-tabs {
flex-wrap: wrap;
}
.settings-tabs button {
flex: 1 1 26%;
}
.settings-panel .form-grid,
.settings-panel .dns-credentials-grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,39 @@
import { nextTick, ref } from "vue";
const sections = ["service", "acme", "dns", "http", "smtp"];
export function useSettingsModal(saveSettings) {
const activeSection = ref(sections[0]);
async function submitSettings(event) {
const invalid = event.currentTarget.querySelector(
"input:invalid, select:invalid, textarea:invalid",
);
if (invalid) {
const section = invalid.closest("[data-settings-section]");
if (section) activeSection.value = section.dataset.settingsSection;
await nextTick();
invalid.focus();
invalid.reportValidity();
return;
}
await saveSettings();
}
async function navigateTabs(event) {
const current = sections.indexOf(activeSection.value);
let next;
if (event.key === "ArrowRight") next = (current + 1) % sections.length;
else if (event.key === "ArrowLeft") next = (current + sections.length - 1) % sections.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = sections.length - 1;
else return;
event.preventDefault();
const tablist = event.currentTarget.parentElement;
activeSection.value = sections[next];
await nextTick();
tablist.querySelector(`#settings-tab-${sections[next]}`)?.focus();
}
return { activeSection, submitSettings, navigateTabs };
}

View File

@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { parse, compileScript, compileTemplate } from "@vue/compiler-sfc";
import { useSettingsModal } from "./SettingsModal.js";
const source = readFileSync(new URL("./SettingsModal.vue", import.meta.url), "utf8");
test("settings.componentCompilesWithFiveAccessiblePanels", () => {
const { descriptor, errors } = parse(source);
assert.deepEqual(errors, []);
const script = compileScript(descriptor, { id: "settings-test" });
assert.ok(script.bindings.saveSettings);
const result = compileTemplate({
source: descriptor.template.content,
filename: "SettingsModal.vue",
id: "settings-test",
compilerOptions: { bindingMetadata: script.bindings },
});
assert.deepEqual(result.errors, []);
assert.equal((source.match(/role="tab"/g) || []).length, 5);
assert.equal((source.match(/role="tabpanel"/g) || []).length, 5);
assert.match(source, /:required="field.required && dnsVerificationRequired"/);
assert.match(source, /<form novalidate @submit.prevent="submitSettings">/);
assert.ok(!source.includes('<p class="field-hint"'));
});
test("settings.invalidHiddenFieldOpensItsPanelBeforeReporting", async () => {
let saved = false;
const calls = [];
const state = useSettingsModal(() => { saved = true; });
const invalid = {
closest: () => ({ dataset: { settingsSection: "acme" } }),
focus: () => calls.push(["focus", state.activeSection.value]),
reportValidity: () => calls.push(["report", state.activeSection.value]),
};
await state.submitSettings({ currentTarget: { querySelector: () => invalid } });
assert.equal(saved, false);
assert.deepEqual(calls, [["focus", "acme"], ["report", "acme"]]);
});
test("settings.validFormUsesExistingSaveFlow", async () => {
let count = 0;
const state = useSettingsModal(async () => { count += 1; });
await state.submitSettings({ currentTarget: { querySelector: () => null } });
assert.equal(count, 1);
});
test("settings.keyboardNavigationWrapsAndFocusesTabs", async () => {
const state = useSettingsModal(() => {});
const focused = [];
const event = {
key: "ArrowLeft",
preventDefault() {},
currentTarget: { parentElement: {
querySelector: (selector) => ({ focus: () => focused.push(selector) }),
} },
};
await state.navigateTabs(event);
assert.equal(state.activeSection.value, "smtp");
assert.deepEqual(focused, ["#settings-tab-smtp"]);
await state.navigateTabs({ ...event, key: "Home" });
assert.equal(state.activeSection.value, "service");
});

View File

@ -0,0 +1,484 @@
<script setup>
import HelpTip from "../components/HelpTip/HelpTip.vue";
import { useSettingsModal } from "./SettingsModal.js";
const props = defineProps({ state: { type: Object, required: true } });
const {
settingsForm,
showSettings,
settingsSaving,
settingsError,
dnsProviderCatalog,
selectedDNSProvider,
dnsTesting,
dnsTestError,
dnsVerificationElapsedSeconds,
dnsVerificationTimeoutSeconds,
dnsReadyToSave,
handleDNSProviderChange,
clearDNSCredentials,
testDNS,
closeSettings,
saveSettings,
dnsVerificationRequired,
} = props.state;
const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSettings);
</script>
<template>
<div v-if="showSettings" class="modal-backdrop" @click.self="closeSettings">
<section class="modal-card settings-modal" role="dialog" aria-modal="true" aria-label="系统设置">
<div class="modal-heading">
<div>
<p class="eyebrow">系统配置</p>
<h2 id="settings-title">系统设置</h2>
</div>
<button class="close-button" type="button" aria-label="关闭" @click="closeSettings">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-labelledby="settings-title">
<button
id="settings-tab-service"
type="button"
role="tab"
aria-controls="settings-panel-service"
:aria-selected="activeSection === 'service'"
:tabindex="activeSection === 'service' ? 0 : -1"
@click="activeSection = 'service'"
@keydown="navigateTabs"
>
服务监听
</button>
<button
id="settings-tab-acme"
type="button"
role="tab"
aria-controls="settings-panel-acme"
:aria-selected="activeSection === 'acme'"
:tabindex="activeSection === 'acme' ? 0 : -1"
@click="activeSection = 'acme'"
@keydown="navigateTabs"
>
ACME 设置
</button>
<button
id="settings-tab-dns"
type="button"
role="tab"
aria-controls="settings-panel-dns"
:aria-selected="activeSection === 'dns'"
:tabindex="activeSection === 'dns' ? 0 : -1"
@click="activeSection = 'dns'"
@keydown="navigateTabs"
>
DNS API 验证
</button>
<button
id="settings-tab-http"
type="button"
role="tab"
aria-controls="settings-panel-http"
:aria-selected="activeSection === 'http'"
:tabindex="activeSection === 'http' ? 0 : -1"
@click="activeSection = 'http'"
@keydown="navigateTabs"
>
HTTP 通知
</button>
<button
id="settings-tab-smtp"
type="button"
role="tab"
aria-controls="settings-panel-smtp"
:aria-selected="activeSection === 'smtp'"
:tabindex="activeSection === 'smtp' ? 0 : -1"
@click="activeSection = 'smtp'"
@keydown="navigateTabs"
>
SMTP 通知
</button>
</div>
<form novalidate @submit.prevent="submitSettings">
<section
v-show="activeSection === 'service'"
id="settings-panel-service"
data-settings-section="service"
role="tabpanel"
aria-labelledby="settings-tab-service"
class="settings-panel"
>
<h3>服务监听</h3>
<div class="form-grid">
<label class="field">
<span>监听地址</span>
<input v-model="settingsForm.server.host" required />
</label>
<label class="field">
<span>服务端口</span>
<input v-model.number="settingsForm.server.port" type="number" min="1" max="65535" required />
</label>
</div>
<div class="form-grid">
<label class="field">
<span>HTTPS 模式</span>
<select v-model="settingsForm.server.https_mode">
<option value="direct">直接终止 HTTPS</option>
<option value="proxy">反向代理 HTTPS</option>
</select>
</label>
<label v-if="settingsForm.server.https_mode === 'proxy'" class="field">
<span>代理公开 HTTPS 端口(0 为默认 443)</span>
<input v-model.number="settingsForm.server.proxy_https_port" type="number" min="0" max="65535" required />
</label>
<label class="field">
<span>证书存储路径</span>
<input v-model="settingsForm.paths.certificate" required />
</label>
</div>
<div class="form-grid">
<label class="field">
<span>管理页面域名</span>
<input v-model="settingsForm.web.hostname" placeholder="例如 ssl.example.com" />
</label>
<label class="field">
<span>管理页面公网 IP</span>
<input v-model="settingsForm.web.public_ip" placeholder="例如 203.0.113.10" />
</label>
</div>
<div class="settings-web-controls">
<label class="switch-field settings-switch-field">
<input v-model="settingsForm.web.enabled" type="checkbox" />
<span>
<strong>开启 Web 管理页面</strong>
<HelpTip>关闭后停止网络 Web 管理入口,桌面端本地管理功能不受影响。</HelpTip>
</span>
</label>
<HelpTip>修改此开关后需要重启 Scert 服务,新的监听状态才会生效。</HelpTip>
</div>
</section>
<section
v-show="activeSection === 'acme'"
id="settings-panel-acme"
data-settings-section="acme"
role="tabpanel"
aria-labelledby="settings-tab-acme"
class="settings-panel"
>
<h3>ACME 设置</h3>
<div class="form-grid">
<label class="field">
<span>ACME 环境</span>
<select v-model="settingsForm.acme.environment">
<option value="staging">测试环境</option>
<option value="production">生产环境</option>
</select>
</label>
<label class="field">
<span>自动续期阈值(天)</span>
<input v-model.number="settingsForm.acme.renewal_threshold_days" type="number" min="1" max="365" />
</label>
</div>
<div class="form-grid">
<label class="field">
<span>常规检查间隔(天)</span>
<input v-model.number="settingsForm.acme.renewal_check_interval_days" type="number" min="1" max="30" />
</label>
<label class="field">
<span>临近到期检查阈值(天)</span>
<input v-model.number="settingsForm.acme.renewal_near_expiry_days" type="number" min="1" max="365" />
</label>
</div>
<div class="form-grid">
<label class="field">
<span>紧急续期阈值(天)</span>
<input v-model.number="settingsForm.acme.renewal_urgent_threshold_days" type="number" min="1" max="365" />
</label>
<label class="field">
<span>紧急续期最大尝试次数</span>
<input v-model.number="settingsForm.acme.renewal_max_retries" type="number" min="1" max="10" />
</label>
</div>
<div class="form-grid">
<label class="field">
<span>续期重试间隔(分钟)</span>
<input v-model.number="settingsForm.acme.renewal_retry_interval_mins" type="number" min="1" max="1440" />
</label>
<HelpTip>剩余有效期低于自动续期阈值时触发续期;低于紧急阈值时按配置重试。</HelpTip>
</div>
<div class="form-grid">
<label class="field">
<span>DNS 传播等待(秒)</span>
<input
v-model.number="settingsForm.acme.propagation_timeout_secs"
type="number"
min="1"
/>
</label>
<label class="field">
<span>DNS 检测间隔(秒)</span>
<input
v-model.number="settingsForm.acme.propagation_polling_interval_secs"
type="number"
min="1"
/>
</label>
</div>
<HelpTip>
阿里云 RAM 权限验证最多等待 6 秒:系统会创建、查询并清理临时 TXT
记录。以上 300 秒和 5 秒用于实际申请证书时的公网 DNS-01
传播等待与轮询。
</HelpTip>
</section>
<section
v-show="activeSection === 'dns'"
id="settings-panel-dns"
data-settings-section="dns"
role="tabpanel"
aria-labelledby="settings-tab-dns"
class="settings-panel"
>
<h3>DNS API 验证</h3>
<HelpTip>
<span>输入域名后,系统将使用当前阿里云账号立即创建、查询并清理临时 TXT 记录,以验证该域名的实际 DNS 操作权限。</span>
<span>凭据只会在本次在线测试请求中提交,系统仅保存加密引用。</span>
</HelpTip>
<div class="form-grid">
<label class="field" for="settings-dns-provider">
<span>DNS 服务商</span>
<select
id="settings-dns-provider"
v-model="settingsForm.dns.provider"
@change="handleDNSProviderChange"
>
<option value="">暂不配置</option>
<option
v-for="provider in dnsProviderCatalog"
:key="provider.id"
:value="provider.id"
:disabled="!provider.available"
>
{{ provider.name }}{{ provider.available ? "" : "(暂不可用)" }}
</option>
</select>
</label>
<label class="field" for="settings-dns-test-domain">
<span>在线测试域名</span>
<input
id="settings-dns-test-domain"
v-model="settingsForm.dns.test_domain"
placeholder="例如 example.com"
autocomplete="off"
/>
</label>
</div>
<HelpTip v-if="selectedDNSProvider">
{{ selectedDNSProvider.description }}
</HelpTip>
<p
v-if="selectedDNSProvider && !selectedDNSProvider.available"
class="form-error"
>
当前服务商适配器暂未开放,无法进行在线测试。
</p>
<div v-if="selectedDNSProvider" class="dns-credentials-grid">
<label
v-for="field in selectedDNSProvider.credential_fields"
:key="field.name"
class="field"
:for="`settings-dns-${field.name}`"
>
<span>
{{ field.label }}
<small v-if="field.required && dnsVerificationRequired">必填</small>
</span>
<input
:id="`settings-dns-${field.name}`"
v-model="settingsForm.dns.credentials[field.name]"
:type="field.secret ? 'password' : 'text'"
:placeholder="field.placeholder"
:required="field.required && dnsVerificationRequired"
autocomplete="off"
/>
</label>
</div>
<div v-if="selectedDNSProvider" class="dns-test-actions">
<button
class="secondary-button"
type="button"
:disabled="dnsTesting || settingsSaving || !selectedDNSProvider.available"
@click="testDNS"
>
{{ dnsTesting ? "测试中……" : "在线测试 DNS API" }}
</button>
<button
class="text-button"
type="button"
:disabled="dnsTesting || settingsSaving"
@click="clearDNSCredentials"
>
清空凭据
</button>
<span
class="dns-verification-status"
:class="settingsForm.dns.verification_state"
>
{{ settingsForm.dns.verification_state === "verified" ? "已验证" : "待验证" }}
</span>
</div>
<div
v-if="dnsTesting"
class="dns-verification-progress"
role="status"
aria-live="polite"
>
<strong>正在进行 DNS 验证,请等待</strong>
<span>
已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待
{{ dnsVerificationTimeoutSeconds }} 秒。
</span>
<small v-if="selectedDNSProvider?.id === 'aliyun'">
正在通过阿里云 DNS API 创建、查询并清理临时 TXT 记录,不等待公网 DNS 传播,通常数秒即可完成;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
<small v-else>
系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
</div>
<p v-if="dnsTestError" class="form-error">{{ dnsTestError }}</p>
<div
v-if="settingsForm.dns.test_report?.stages?.length"
class="dns-test-report"
>
<div class="dns-test-report-heading">
<strong>在线测试结果</strong>
<span v-if="settingsForm.dns.test_report.record_name">
临时记录:{{ settingsForm.dns.test_report.record_name }}
</span>
</div>
<div
v-for="stage in settingsForm.dns.test_report.stages"
:key="stage.name"
class="dns-test-stage"
>
<span class="dns-stage-indicator" :class="stage.status">
</span>
<div>
<strong>{{ stage.name }}</strong>
<span>{{ stage.message }}</span>
</div>
</div>
</div>
<HelpTip v-if="!dnsReadyToSave">
DNS 服务商发生变化或尚未配置时,必须先完成 DNS API 在线测试。
</HelpTip>
</section>
<section
v-show="activeSection === 'http'"
id="settings-panel-http"
data-settings-section="http"
role="tabpanel"
aria-labelledby="settings-tab-http"
class="settings-panel"
>
<h3>HTTP 通知</h3>
<div class="form-grid">
<label class="switch-field">
<input v-model="settingsForm.notification.http_enabled" type="checkbox" />
<span>
<strong>启用 HTTP 通知</strong>
<HelpTip>通过外部邮件或消息服务 API 发送通知</HelpTip>
</span>
</label>
<label class="field">
<span>通知服务商</span>
<input v-model="settingsForm.notification.provider" placeholder="例如 temporary-mail" />
</label>
</div>
<div class="form-grid">
<label class="field">
<span>通知接口地址</span>
<input v-model="settingsForm.notification.endpoint" placeholder="https://..." />
</label>
<label class="field">
<span>发件人邮箱</span>
<input v-model="settingsForm.notification.sender_email" type="email" />
</label>
</div>
<label class="field">
<span>收件人</span>
<input :value="settingsForm.notification.recipients.join(', ')" @input="settingsForm.notification.recipients = $event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean)" placeholder="多个地址用逗号分隔" />
</label>
</section>
<section
v-show="activeSection === 'smtp'"
id="settings-panel-smtp"
data-settings-section="smtp"
role="tabpanel"
aria-labelledby="settings-tab-smtp"
class="settings-panel"
>
<h3>SMTP 通知</h3>
<label class="switch-field">
<input v-model="settingsForm.notification.smtp_enabled" type="checkbox" />
<span>
<strong>启用 SMTP 通知</strong>
<HelpTip>使用外部 SMTP 服务发送证书任务通知,不会实现邮件服务器端</HelpTip>
</span>
</label>
<div class="form-grid">
<label class="field">
<span>SMTP 主机</span>
<input v-model="settingsForm.notification.smtp_host" placeholder="例如 smtp.example.com" />
</label>
<label class="field">
<span>SMTP 端口</span>
<input v-model.number="settingsForm.notification.smtp_port" type="number" min="1" max="65535" />
</label>
</div>
<div class="form-grid">
<label class="field">
<span>SMTP 用户名</span>
<input v-model="settingsForm.notification.smtp_username" autocomplete="username" />
</label>
<label class="field">
<span>SMTP 密码</span>
<input v-model="settingsForm.notification.smtp_password" type="password" autocomplete="new-password" placeholder="留空表示不更新已保存密码" />
</label>
</div>
<div class="form-grid">
<label class="field">
<span>连接模式</span>
<select v-model="settingsForm.notification.smtp_mode">
<option value="plain">明文连接</option>
<option value="starttls">STARTTLS</option>
<option value="implicit_tls">隐式 TLS</option>
</select>
</label>
<label class="switch-field">
<input v-model="settingsForm.notification.smtp_allow_plaintext_auth" type="checkbox" />
<span>
<strong>允许明文认证</strong>
<HelpTip>仅在明确确认 SMTP 网络受保护时开启</HelpTip>
</span>
</label>
</div>
<HelpTip>
{{ settingsForm.notification.smtp_credential_configured ? "SMTP 凭据已配置;密码不会回显。填写新密码后保存即可更新。" : "SMTP 凭据尚未配置;启用 SMTP 时请填写用户名和密码。" }}
</HelpTip>
</section>
<p v-if="settingsError" class="form-error">{{ settingsError }}</p>
<div class="modal-actions">
<button class="secondary-button" type="button" :disabled="settingsSaving" @click="closeSettings">取消</button>
<button
class="primary-button"
type="submit"
:disabled="settingsSaving || !dnsReadyToSave"
>
{{ settingsSaving ? "保存中……" : "保存设置" }}
</button>
</div>
</form>
</section>
</div>
</template>
<style scoped src="./Dashboard.css"></style>
<style scoped src="./SettingsModal.css"></style>