合并邮件通知设置并统一公共配置

This commit is contained in:
xuans 2026-09-25 20:54:13 +08:00
parent 94cc1ce0e7
commit 355c80dbc5
5 changed files with 256 additions and 113 deletions

View File

@ -0,0 +1,91 @@
.mail-common-fields,
.mail-channels,
.mail-smtp-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
.mail-common-fields {
margin-bottom: 14px;
}
.mail-channels {
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
}
.mail-channel {
min-width: 0;
margin: 0;
padding: 12px 14px;
border: 1px solid #dce6f1;
border-radius: 10px;
}
.mail-channel legend {
padding: 0 6px;
color: #32405a;
font-size: 13px;
font-weight: 700;
}
.mail-field {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
color: #526983;
font-size: 12px;
}
.mail-channel > .mail-field + .mail-field {
margin-top: 12px;
}
.mail-field > span {
display: flex;
align-items: center;
min-height: 22px;
}
.mail-field input,
.mail-field select {
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 36px;
padding: 8px 10px;
border: 1px solid #cbd8e7;
border-radius: 8px;
background: #fff;
color: #263650;
font: inherit;
font-size: 13px;
}
.mail-switch {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
color: #32405a;
font-size: 12px;
}
.mail-switch input {
margin: 0;
}
.mail-auth-switch {
align-self: end;
min-height: 36px;
margin-bottom: 0;
}
@media (max-width: 700px) {
.mail-common-fields,
.mail-channels,
.mail-smtp-fields {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@ -0,0 +1,85 @@
<script setup>
import HelpTip from "../components/HelpTip/HelpTip.vue";
defineProps({ notification: { type: Object, required: true } });
</script>
<template>
<div class="mail-notification-settings">
<div class="mail-common-fields">
<label class="mail-field">
<span>发件人邮箱</span>
<input v-model="notification.sender_email" type="email" />
</label>
<label class="mail-field">
<span>收件人 <HelpTip>多个地址用逗号分隔,两种发送方式共用此配置。</HelpTip></span>
<input
:value="notification.recipients.join(', ')"
@input="notification.recipients = $event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean)"
placeholder="多个地址用逗号分隔"
/>
</label>
</div>
<div class="mail-channels">
<fieldset class="mail-channel">
<legend>HTTP 接口</legend>
<label class="mail-switch">
<input v-model="notification.http_enabled" type="checkbox" />
<span>启用接口发送</span>
<HelpTip>通过邮件服务接口发送通知,保留现有接口配置。</HelpTip>
</label>
<label class="mail-field">
<span>通知服务商</span>
<input v-model="notification.provider" placeholder="例如 temporary-mail" />
</label>
<label class="mail-field">
<span>通知接口地址</span>
<input v-model="notification.endpoint" placeholder="https://..." />
</label>
</fieldset>
<fieldset class="mail-channel">
<legend>SMTP 服务</legend>
<label class="mail-switch">
<input v-model="notification.smtp_enabled" type="checkbox" />
<span>启用 SMTP 发送</span>
<HelpTip>通过 SMTP 服务发送邮件,保留现有通道启用状态。</HelpTip>
</label>
<div class="mail-smtp-fields">
<label class="mail-field">
<span>SMTP 主机</span>
<input v-model="notification.smtp_host" placeholder="例如 smtp.example.com" />
</label>
<label class="mail-field">
<span>SMTP 端口</span>
<input v-model.number="notification.smtp_port" type="number" min="1" max="65535" />
</label>
<label class="mail-field">
<span>SMTP 用户名</span>
<input v-model="notification.smtp_username" autocomplete="username" />
</label>
<label class="mail-field">
<span>SMTP 密码
<HelpTip>{{ notification.smtp_credential_configured ? "凭据已配置;密码不会回显,留空表示不更新。" : "凭据尚未配置,请按 SMTP 服务要求填写。" }}</HelpTip>
</span>
<input v-model="notification.smtp_password" type="password" autocomplete="new-password" placeholder="留空保留已保存密码" />
</label>
<label class="mail-field">
<span>连接模式</span>
<select v-model="notification.smtp_mode">
<option value="plain">明文连接</option>
<option value="starttls">STARTTLS</option>
<option value="implicit_tls">隐式 TLS</option>
</select>
</label>
<label class="mail-switch mail-auth-switch">
<input v-model="notification.smtp_allow_plaintext_auth" type="checkbox" />
<span>允许明文认证</span>
<HelpTip>仅在明确确认 SMTP 网络受保护时开启。</HelpTip>
</label>
</div>
</fieldset>
</div>
</div>
</template>
<style scoped src="./MailNotificationSettings.css"></style>

View File

@ -1,6 +1,6 @@
import { nextTick, ref } from "vue";
const sections = ["service", "acme", "dns", "http", "smtp"];
const sections = ["service", "acme", "dns", "notification"];
export function useSettingsModal(saveSettings) {
const activeSection = ref(sections[0]);

View File

@ -3,10 +3,69 @@ import test from "node:test";
import { readFileSync } from "node:fs";
import { parse, compileScript, compileTemplate } from "@vue/compiler-sfc";
import { useSettingsModal } from "./SettingsModal.js";
import { settingsPayload } from "./SettingsPayload.js";
const source = readFileSync(new URL("./SettingsModal.vue", import.meta.url), "utf8");
test("settings.componentCompilesWithFiveAccessiblePanels", () => {
test("邮件通知组件保留两种通道且公共字段只出现一次", () => {
const mailSource = readFileSync(new URL("./MailNotificationSettings.vue", import.meta.url), "utf8");
const { descriptor, errors } = parse(mailSource);
assert.deepEqual(errors, []);
const script = compileScript(descriptor, { id: "mail-settings-test" });
const compiled = compileTemplate({
source: descriptor.template.content,
filename: "MailNotificationSettings.vue",
id: "mail-settings-test",
compilerOptions: { bindingMetadata: script.bindings },
});
assert.deepEqual(compiled.errors, []);
for (const field of ["http_enabled", "smtp_enabled", "sender_email", "smtp_password"]) {
assert.equal(mailSource.split(`v-model="notification.${field}"`).length - 1, 1);
}
assert.equal(mailSource.split(':value="notification.recipients.join').length - 1, 1);
assert.match(source, /<MailNotificationSettings :notification="settingsForm.notification"/);
assert.doesNotMatch(mailSource, /role="tab"/);
});
test("合并通知页面不改变通道开关和已保存配置", () => {
for (const http of [false, true]) {
for (const smtp of [false, true]) {
const notification = {
http_enabled: http,
smtp_enabled: smtp,
endpoint: "https://mail.example.com",
sender_email: "sender@example.com",
recipients: ["recipient@example.com"],
smtp_host: "smtp.example.com",
smtp_port: 587,
smtp_username: "sender",
smtp_password: "",
smtp_mode: "starttls",
smtp_credential_configured: true,
};
const payload = settingsPayload({ notification }).notification;
const { smtp_credential_configured, ...expected } = notification;
assert.deepEqual(payload, expected);
assert.equal(notification.smtp_credential_configured, true);
}
}
});
test("通知字段校验失败时切换到合并后的邮件通知页", async () => {
let saved = false;
const state = useSettingsModal(() => { saved = true; });
const sections = [];
const invalid = {
closest: () => ({ dataset: { settingsSection: "notification" } }),
focus: () => sections.push(state.activeSection.value),
reportValidity: () => sections.push(state.activeSection.value),
};
await state.submitSettings({ currentTarget: { querySelector: () => invalid } });
assert.equal(saved, false);
assert.deepEqual(sections, ["notification", "notification"]);
});
test("settings.componentCompilesWithFourAccessiblePanels", () => {
const { descriptor, errors } = parse(source);
assert.deepEqual(errors, []);
const script = compileScript(descriptor, { id: "settings-test" });
@ -18,8 +77,10 @@ test("settings.componentCompilesWithFiveAccessiblePanels", () => {
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.equal((source.match(/role="tab"/g) || []).length, 4);
assert.equal((source.match(/role="tabpanel"/g) || []).length, 4);
assert.match(source, /id="settings-tab-notification"/);
assert.doesNotMatch(source, /settings-tab-(http|smtp)/);
assert.match(source, /:required="field.required && dnsVerificationRequired"/);
assert.match(source, /<form novalidate @submit.prevent="submitSettings">/);
assert.ok(!source.includes('<p class="field-hint"'));
@ -57,8 +118,8 @@ test("settings.keyboardNavigationWrapsAndFocusesTabs", async () => {
} },
};
await state.navigateTabs(event);
assert.equal(state.activeSection.value, "smtp");
assert.deepEqual(focused, ["#settings-tab-smtp"]);
assert.equal(state.activeSection.value, "notification");
assert.deepEqual(focused, ["#settings-tab-notification"]);
await state.navigateTabs({ ...event, key: "Home" });
assert.equal(state.activeSection.value, "service");
});

View File

@ -1,6 +1,7 @@
<script setup>
import HelpTip from "../components/HelpTip/HelpTip.vue";
import { useSettingsModal } from "./SettingsModal.js";
import MailNotificationSettings from "./MailNotificationSettings.vue";
const props = defineProps({ state: { type: Object, required: true } });
const {
@ -73,28 +74,16 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
DNS API 验证
</button>
<button
id="settings-tab-http"
id="settings-tab-notification"
type="button"
role="tab"
aria-controls="settings-panel-http"
:aria-selected="activeSection === 'http'"
:tabindex="activeSection === 'http' ? 0 : -1"
@click="activeSection = 'http'"
aria-controls="settings-panel-notification"
:aria-selected="activeSection === 'notification'"
:tabindex="activeSection === 'notification' ? 0 : -1"
@click="activeSection = 'notification'"
@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">
@ -371,98 +360,15 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
</HelpTip>
</section>
<section
v-show="activeSection === 'http'"
id="settings-panel-http"
data-settings-section="http"
v-show="activeSection === 'notification'"
id="settings-panel-notification"
data-settings-section="notification"
role="tabpanel"
aria-labelledby="settings-tab-http"
aria-labelledby="settings-tab-notification"
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>
<h3>邮件通知</h3>
<MailNotificationSettings :notification="settingsForm.notification" />
</section>
<p v-if="settingsError" class="form-error">{{ settingsError }}</p>
<div class="modal-actions">