拆分系统设置为单屏分组并修复已保存DNS凭据的必填校验
This commit is contained in:
parent
3a28ae9f5b
commit
83c7e0204b
@ -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"
|
||||
|
||||
@ -1183,6 +1183,7 @@ export function useDashboard() {
|
||||
dnsVerificationElapsedSeconds,
|
||||
dnsVerificationTimeoutSeconds,
|
||||
dnsReadyToSave,
|
||||
dnsVerificationRequired,
|
||||
handleDNSProviderChange,
|
||||
clearDNSCredentials,
|
||||
testDNS,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
153
frontend/src/views/Dashboard/SettingsModal.css
Normal file
153
frontend/src/views/Dashboard/SettingsModal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
39
frontend/src/views/Dashboard/SettingsModal.js
Normal file
39
frontend/src/views/Dashboard/SettingsModal.js
Normal 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 };
|
||||
}
|
||||
64
frontend/src/views/Dashboard/SettingsModal.test.js
Normal file
64
frontend/src/views/Dashboard/SettingsModal.test.js
Normal 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");
|
||||
});
|
||||
484
frontend/src/views/Dashboard/SettingsModal.vue
Normal file
484
frontend/src/views/Dashboard/SettingsModal.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user