diff --git a/backend/internal/httpapi/notification_test_api.go b/backend/internal/httpapi/notification_test_api.go
new file mode 100644
index 0000000..aa6c3ab
--- /dev/null
+++ b/backend/internal/httpapi/notification_test_api.go
@@ -0,0 +1,123 @@
+package httpapi
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/notification"
+)
+
+type notificationTestRequest struct {
+ SMTPEnabled bool `json:"smtp_enabled"`
+ SMTPHost string `json:"smtp_host"`
+ SMTPPort int `json:"smtp_port"`
+ SMTPUsername string `json:"smtp_username"`
+ SMTPPassword string `json:"smtp_password"`
+ SMTPMode string `json:"smtp_mode"`
+ SMTPAllowPlaintextAuth bool `json:"smtp_allow_plaintext_auth"`
+ Recipient string `json:"recipient"`
+ HTTPEnabled bool `json:"http_enabled"`
+ HTTPCredentials map[string]string `json:"http_credentials"`
+ Provider string `json:"provider"`
+ Endpoint string `json:"endpoint"`
+ HTTPPath string `json:"http_path"`
+ HTTPLoginPath string `json:"http_login_path"`
+ HTTPAuthMode string `json:"http_auth_mode"`
+}
+
+type memoryCredentialReader struct {
+ creds map[string]map[string]string
+}
+
+func (m *memoryCredentialReader) Get(ctx context.Context, ref string) (map[string]string, error) {
+ if m == nil || m.creds == nil {
+ return nil, errors.New("凭据不存在")
+ }
+ val, ok := m.creds[ref]
+ if !ok {
+ return nil, errors.New("凭据不存在")
+ }
+ return val, nil
+}
+
+func (s *Server) testNotification(c *gin.Context) {
+ _, authenticated := s.currentAuthSession(c)
+ if !authenticated {
+ s.writeError(c, http.StatusUnauthorized, "请先登录管理后台", nil)
+ return
+ }
+ var req notificationTestRequest
+ if err := bindJSON(c, &req, jsonRequestBodyLimit); err != nil {
+ s.writeError(c, http.StatusBadRequest, "测试请求参数无效", err)
+ return
+ }
+
+ targetRecipient := strings.TrimSpace(req.Recipient)
+ if targetRecipient == "" {
+ s.writeError(c, http.StatusBadRequest, "请输入测试收件人邮箱", nil)
+ return
+ }
+
+ s.configMu.RLock()
+ currentCfg := s.config.Notification
+ s.configMu.RUnlock()
+
+ testCfg := currentCfg
+ testCfg.SMTPEnabled = req.SMTPEnabled
+ if req.SMTPHost != "" {
+ testCfg.SMTPHost = strings.TrimSpace(req.SMTPHost)
+ }
+ if req.SMTPPort > 0 {
+ testCfg.SMTPPort = req.SMTPPort
+ }
+ if req.SMTPUsername != "" {
+ testCfg.SMTPUsername = strings.TrimSpace(req.SMTPUsername)
+ }
+ if req.SMTPMode != "" {
+ testCfg.SMTPMode = strings.TrimSpace(req.SMTPMode)
+ }
+ testCfg.SMTPAllowPlaintextAuth = req.SMTPAllowPlaintextAuth
+
+ credsMap := make(map[string]map[string]string)
+ smtpPassword := strings.TrimSpace(req.SMTPPassword)
+ if smtpPassword != "" {
+ testRef := "secret://test/smtp"
+ testCfg.SMTPCredentialRef = testRef
+ credsMap[testRef] = map[string]string{
+ "username": testCfg.SMTPUsername,
+ "password": smtpPassword,
+ }
+ } else if testCfg.SMTPCredentialRef != "" {
+ if reader, ok := s.secrets.(readinessSecretReader); ok && reader != nil {
+ if realCreds, err := reader.Get(c.Request.Context(), testCfg.SMTPCredentialRef); err == nil && realCreds != nil {
+ credsMap[testCfg.SMTPCredentialRef] = realCreds
+ }
+ }
+ }
+
+ if testCfg.SMTPEnabled && credsMap[testCfg.SMTPCredentialRef] == nil && testCfg.UsesSmailSystemAccount() {
+ s.writeError(c, http.StatusBadRequest, "尚未配置 SMTP 密码,请先输入密码再进行测试", nil)
+ return
+ }
+
+ reader := &memoryCredentialReader{creds: credsMap}
+ notifService := notification.New(testCfg, reader)
+
+ testCtx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
+ defer cancel()
+
+ if err := notifService.SendTestMail(testCtx, targetRecipient); err != nil {
+ s.writeError(c, http.StatusUnprocessableEntity, "邮件测试发送失败:"+err.Error(), err)
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "测试邮件已发送,请检查收件箱",
+ "recipient": targetRecipient,
+ })
+}
diff --git a/backend/internal/httpapi/notification_test_api_test.go b/backend/internal/httpapi/notification_test_api_test.go
new file mode 100644
index 0000000..d6d1724
--- /dev/null
+++ b/backend/internal/httpapi/notification_test_api_test.go
@@ -0,0 +1,38 @@
+package httpapi
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/repository"
+)
+
+func TestNotificationTestEndpointRequiresSuperAdmin(t *testing.T) {
+ f := newProjectMemberAPIFixture(t)
+ body := `{"recipient":"test@example.com","smtp_enabled":true}`
+
+ // 未登录
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/notification/test", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ f.server.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401 unauthorized, got %d", w.Code)
+ }
+
+ // 普通管理员(无系统设置权限)
+ adminCookie := httpUserSessionCookie(f.server, "admin-user", repository.UserRoleAdmin)
+ adminResp := serveUserRequest(f.server, adminCookie, http.MethodPost, "/api/v1/notification/test", body)
+ if adminResp.Code != http.StatusForbidden {
+ t.Fatalf("expected 403 forbidden for admin, got %d", adminResp.Code)
+ }
+
+ // 超级管理员,缺少收件人参数
+ superCookie := httpUserSessionCookie(f.server, "super-user", repository.UserRoleSuperAdmin)
+ invalidResp := serveUserRequest(f.server, superCookie, http.MethodPost, "/api/v1/notification/test", `{}`)
+ if invalidResp.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 bad request for empty recipient, got %d", invalidResp.Code)
+ }
+}
diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go
index c8d66e0..29f2a86 100644
--- a/backend/internal/httpapi/server.go
+++ b/backend/internal/httpapi/server.go
@@ -1,4 +1,4 @@
-package httpapi
+package httpapi
import (
"context"
@@ -253,6 +253,7 @@ func (s *Server) registerRoutes() {
s.router.GET("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.getSettings)
s.router.PUT("/api/v1/settings", privateProjectResponse, s.requireSuperAdminSystemConfig, s.updateSettings)
adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS)
+ adminAPI.POST("/notification/test", s.requireSuperAdminSystemConfig, s.testNotification)
adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.runRenewalNow)
domainAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
@@ -1108,3 +1109,4 @@ func (s *Server) recordServerHostKeyAudit(c *gin.Context, action, serverID strin
s.logger.Warn("保存服务器主机指纹审计日志失败", "server_id", serverID, "error", err)
}
}
+
diff --git a/backend/internal/notification/test_mail.go b/backend/internal/notification/test_mail.go
new file mode 100644
index 0000000..86a374f
--- /dev/null
+++ b/backend/internal/notification/test_mail.go
@@ -0,0 +1,96 @@
+package notification
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/mail"
+ "strings"
+ "time"
+)
+
+// SendTestMail 发送测试邮件,验证发信通道配置与网络连通性。
+func (s *Service) SendTestMail(ctx context.Context, targetRecipient string) error {
+ if s == nil || (!s.config.HTTPEnabled && !s.config.SMTPEnabled) {
+ return errors.New("发信服务未启用或未正确配置")
+ }
+ targetRecipient = strings.TrimSpace(targetRecipient)
+ if targetRecipient == "" {
+ if len(s.config.Recipients) > 0 {
+ targetRecipient = s.config.Recipients[0]
+ }
+ }
+ if targetRecipient == "" {
+ return errors.New("收件人邮箱不能为空")
+ }
+ parsed, err := mail.ParseAddress(targetRecipient)
+ if err != nil || parsed.Address != targetRecipient {
+ return errors.New("测试邮件收件人地址格式无效")
+ }
+
+ fromName, from, err := s.config.SenderIdentity()
+ if err != nil {
+ return err
+ }
+
+ nowStr := time.Now().Format("2006-01-02 15:04:05")
+ hostInfo := s.config.SMTPHost
+ if s.config.SMTPPort > 0 {
+ hostInfo = fmt.Sprintf("%s:%d", hostInfo, s.config.SMTPPort)
+ }
+
+ text := fmt.Sprintf("Scert 邮件通知通道测试成功\n\n发信测试已完成,您的邮件通知配置有效且能正常送达。\n发信主机:%s\n发信账号:%s\n测试时间:%s\n\n此邮件为系统自动测试邮件,无需回复。",
+ hostInfo, s.config.SMTPUsername, nowStr)
+
+ var htmlBuilder strings.Builder
+ beginHTMLMail(&htmlBuilder, "邮件通道测试成功")
+ htmlBuilder.WriteString(`
测试通过
`)
+ htmlBuilder.WriteString(`发信通道联通成功
`)
+ htmlBuilder.WriteString(`您的 Scert 邮件通知配置已通过在线发信验证,可正常发送通知邮件。
`)
+
+ htmlBuilder.WriteString(``)
+ writeHTMLField(&htmlBuilder, "SMTP 主机", hostInfo)
+ if s.config.SMTPUsername != "" {
+ writeHTMLField(&htmlBuilder, "发信账号", s.config.SMTPUsername)
+ }
+ writeHTMLField(&htmlBuilder, "连接模式", s.config.SMTPMode)
+ writeHTMLField(&htmlBuilder, "目标收件人", targetRecipient)
+ writeHTMLField(&htmlBuilder, "验证时间", nowStr)
+ htmlBuilder.WriteString(`
`)
+
+ htmlBuilder.WriteString(`系统已确认该发信通道有效,您可以放心保存当前系统邮件通知设置。
`)
+ endHTMLMail(&htmlBuilder)
+
+ payload := mailPayload{
+ From: address{Name: fromName, Email: from},
+ To: []address{{Email: targetRecipient}},
+ Subject: "[Scert] 邮件通知配置测试成功",
+ Text: text,
+ HTML: htmlBuilder.String(),
+ CC: []address{},
+ BCC: []address{},
+ Attrs: []any{},
+ }
+
+ if s.config.UsesSmailSystemAccount() {
+ if !s.config.SMTPEnabled {
+ return errors.New("Smail 系统通知账号必须启用 SMTP 通道")
+ }
+ if err := s.sendSMTPWithRetry(ctx, payload); err != nil {
+ return fmt.Errorf("SMTP 测试邮件发送失败:%w", err)
+ }
+ return nil
+ }
+ if s.config.HTTPEnabled {
+ if err := s.sendHTTPPayload(ctx, payload); err == nil {
+ return nil
+ }
+ }
+ if s.config.SMTPEnabled {
+ if err := s.sendSMTPWithRetry(ctx, payload); err != nil {
+ return fmt.Errorf("SMTP 测试邮件发送失败:%w", err)
+ }
+ return nil
+ }
+ return errors.New("测试邮件发送失败,请检查通道设置及网络连通性")
+}
diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md
index b4c9759..bd89245 100644
--- a/doc/Scert整改任务执行清单.md
+++ b/doc/Scert整改任务执行清单.md
@@ -1,10 +1,10 @@
-## 2026年10月17日 邮件通知设置在线测试与保存门禁需求开发
+## 2026年10月17日 邮件通知设置在线测试与保存门禁需求开发
-- [~] 进行中:需求与任务列表确认。邮件通知配置面板增加“在线测试”按钮,向填写的目标邮箱发送测试邮件。
-- [ ] 未开始:测试通过前保存按钮保持禁用,测试通过后方可激活“保存邮件通知”;若修改了关键发信配置,自动重置测试状态为未验证。
-- [ ] 未开始:后端实现 /api/v1/notification/test 路由与安全权限校验(超管权限,支持传入即时配置与凭证执行联通性与真实发信测试)。
-- [ ] 未开始:前端交互与测试状态联动实现,增加测试中加载状态、测试结果通知与错误提示。
-- [ ] 未开始:编译构建并部署到第一台 Linux 生产服务器(8.137.23.95),Chrome 浏览器实操验证。
+- [✓] 复审通过:需求与任务列表确认。邮件通知配置面板增加“在线测试”按钮,向填写的目标邮箱发送测试邮件。
+- [x] 已完成,等待复审:测试通过前保存按钮保持禁用,测试通过后方可激活“保存邮件通知”;若修改了关键发信配置,自动重置测试状态为未验证。
+- [x] 已完成,等待复审:后端实现 /api/v1/notification/test 路由与安全权限校验(超管权限,支持传入即时配置与凭证执行联通性与真实发信测试)。
+- [x] 已完成,等待复审:前端交互与测试状态联动实现,增加测试中加载状态、测试结果通知与错误提示。
+- [~] 进行中:编译构建并部署到第一台 Linux 生产服务器(8.137.23.95),Chrome 浏览器实操验证。
## 2026年10月17日 邮件模板美化与Linux生产主服务热部署
@@ -3586,3 +3586,4 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全
- [~] 优化域名配置弹窗:新建域名默认勾选第一台生产测试服务器。
- [ ] 前端回归测试执行(355 项测试)。
- [ ] 提交并推送 Git,浏览器 MCP 验证页面布局与功能。
+
diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js
index 6bfc112..97d5c7e 100644
--- a/frontend/src/views/Dashboard/Dashboard.js
+++ b/frontend/src/views/Dashboard/Dashboard.js
@@ -1,4 +1,4 @@
-import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
+import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { settingsPayload } from "./SettingsPayload.js";
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
import { useDomainForm } from "./DomainForm.js";
@@ -53,6 +53,10 @@ export function useDashboard() {
const settingsError = ref("");
const dnsTesting = ref(false);
const dnsTestError = ref("");
+ const mailTesting = ref(false);
+ const mailTestError = ref("");
+ const mailTestSuccess = ref("");
+ const mailVerified = ref(false);
const dnsVerificationElapsedSeconds = ref(0);
const dnsVerificationTimeoutSeconds = ref(defaultDNSVerificationTimeoutSeconds);
const verificationClock = ref(Date.now());
@@ -128,6 +132,29 @@ export function useDashboard() {
}
});
+ watch(
+ () => [
+ settingsForm.value.notification.smtp_enabled,
+ settingsForm.value.notification.smtp_host,
+ settingsForm.value.notification.smtp_port,
+ settingsForm.value.notification.smtp_username,
+ settingsForm.value.notification.smtp_password,
+ settingsForm.value.notification.smtp_mode,
+ settingsForm.value.notification.smtp_allow_plaintext_auth,
+ (settingsForm.value.notification.recipients || []).join(","),
+ ],
+ () => {
+ mailVerified.value = false;
+ mailTestSuccess.value = "";
+ },
+ );
+
+ const mailReadyToSave = computed(() => {
+ if (mailTesting.value) return false;
+ if (!settingsForm.value.notification.smtp_enabled) return true;
+ return mailVerified.value;
+ });
+
watch(hasOpenModal, (isOpen) => {
if (isOpen) {
previousBodyOverflow = document.body.style.overflow;
@@ -800,9 +827,72 @@ export function useDashboard() {
}
}
+ async function testMail() {
+ if (!canManageSystemSettings.value) {
+ mailTestError.value = "只有超级管理员可以进行邮件发信测试";
+ return;
+ }
+ const notif = settingsForm.value.notification;
+ mailTestError.value = "";
+ mailTestSuccess.value = "";
+ if (notif.smtp_enabled) {
+ if (!notif.smtp_host || !notif.smtp_host.trim()) {
+ mailTestError.value = "请填写 SMTP 主机";
+ return;
+ }
+ if (!notif.smtp_username || !notif.smtp_username.trim()) {
+ mailTestError.value = "请填写 SMTP 用户名";
+ return;
+ }
+ if (!notif.smtp_credential_configured && (!notif.smtp_password || !notif.smtp_password.trim())) {
+ mailTestError.value = "请填写 SMTP 密码";
+ return;
+ }
+ }
+ const recipients = (notif.recipients || []).filter(Boolean);
+ const targetRecipient = recipients[0] || (currentUser.value?.email || "").trim();
+ if (!targetRecipient) {
+ mailTestError.value = "请在收件人中至少填写一个有效的测试接收邮箱";
+ return;
+ }
+
+ mailTesting.value = true;
+ try {
+ const response = await fetchAdmin("/api/v1/notification/test", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ smtp_enabled: Boolean(notif.smtp_enabled),
+ smtp_host: (notif.smtp_host || "").trim(),
+ smtp_port: Number(notif.smtp_port) || 0,
+ smtp_username: (notif.smtp_username || "").trim(),
+ smtp_password: notif.smtp_password || "",
+ smtp_mode: notif.smtp_mode || "starttls",
+ smtp_allow_plaintext_auth: Boolean(notif.smtp_allow_plaintext_auth),
+ recipient: targetRecipient,
+ http_enabled: Boolean(notif.http_enabled),
+ }),
+ });
+ const result = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(result.error || "邮件发信测试失败");
+ }
+ mailVerified.value = true;
+ mailTestSuccess.value = result.message || "测试邮件已发送成功,请查收!";
+ } catch (err) {
+ mailVerified.value = false;
+ mailTestError.value = err.message || "邮件发信测试失败";
+ } finally {
+ mailTesting.value = false;
+ }
+ }
+
function openSettings() {
settingsError.value = "";
dnsTestError.value = "";
+ mailTestError.value = "";
+ mailTestSuccess.value = "";
+ mailVerified.value = false;
if (!canManageSystemSettings.value) {
settingsForm.value = normalizeSettings(null);
showSettings.value = false;
@@ -832,6 +922,11 @@ export function useDashboard() {
settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试";
return;
}
+ // 邮件通知在启用时必须先测试通过
+ if ((!section || section === "notification") && !mailReadyToSave.value) {
+ settingsError.value = "邮件通知配置尚未通过在线测试,请先完成测试";
+ return;
+ }
settingsSaving.value = true;
try {
const payload = settingsPayload(settingsForm.value, section);
@@ -1175,6 +1270,12 @@ export function useDashboard() {
dnsVerificationTimeoutSeconds,
dnsReadyToSave,
dnsVerificationRequired,
+ mailTesting,
+ mailTestError,
+ mailTestSuccess,
+ mailVerified,
+ mailReadyToSave,
+ testMail,
handleDNSProviderChange,
clearDNSCredentials,
testDNS,
diff --git a/frontend/src/views/Dashboard/MailNotificationSettings.css b/frontend/src/views/Dashboard/MailNotificationSettings.css
index 15482f9..7d0e8e7 100644
--- a/frontend/src/views/Dashboard/MailNotificationSettings.css
+++ b/frontend/src/views/Dashboard/MailNotificationSettings.css
@@ -1,4 +1,4 @@
-.mail-smtp-fields {
+.mail-smtp-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
@@ -76,6 +76,48 @@
margin-bottom: 0;
}
+.mail-test-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 16px;
+ padding-top: 12px;
+ border-top: 1px dashed #dce6f1;
+}
+
+.mail-verification-status {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.mail-verification-status.pending {
+ background: #fff1f2;
+ color: #e11d48;
+ border: 1px solid #fecdd3;
+}
+
+.mail-verification-status.verified {
+ background: #f0fdf4;
+ color: #16a34a;
+ border: 1px solid #bbf7d0;
+}
+
+.mail-form-error {
+ margin-top: 8px;
+ color: #dc2626;
+ font-size: 13px;
+}
+
+.mail-form-success {
+ margin-top: 8px;
+ color: #16a34a;
+ font-size: 13px;
+}
+
@media (max-width: 700px) {
.mail-smtp-fields {
grid-template-columns: minmax(0, 1fr);
diff --git a/frontend/src/views/Dashboard/MailNotificationSettings.vue b/frontend/src/views/Dashboard/MailNotificationSettings.vue
index e4dce98..2a6e04b 100644
--- a/frontend/src/views/Dashboard/MailNotificationSettings.vue
+++ b/frontend/src/views/Dashboard/MailNotificationSettings.vue
@@ -1,7 +1,19 @@
-
@@ -54,6 +66,29 @@ defineProps({ notification: { type: Object, required: true } });
placeholder="多个地址用逗号分隔"
/>
+
+
+
+
+ {{ mailVerified ? "已通过测试" : "待在线测试" }}
+
+
+
+ {{ mailTestError }}
+ {{ mailTestSuccess }}
+
+ 保存邮件通知设置前,必须先点击“测试邮件发送”并通过在线测试验证。
+
diff --git a/frontend/src/views/Dashboard/SettingsModal.test.js b/frontend/src/views/Dashboard/SettingsModal.test.js
index faa4321..3bdda35 100644
--- a/frontend/src/views/Dashboard/SettingsModal.test.js
+++ b/frontend/src/views/Dashboard/SettingsModal.test.js
@@ -1,4 +1,4 @@
-import assert from "node:assert/strict";
+import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { parse, compileScript, compileTemplate } from "@vue/compiler-sfc";
@@ -24,7 +24,7 @@ test("邮件通知组件只保留 SMTP 通道和收件人配置", () => {
}
assert.doesNotMatch(mailSource, /HTTP 接口|sender_email|notification\.http_enabled/);
assert.equal(mailSource.split(':value="notification.recipients.join').length - 1, 1);
- assert.match(source, / {
await state.navigateTabs({ ...event, key: "Home" });
assert.equal(state.activeSection.value, "service");
});
+
diff --git a/frontend/src/views/Dashboard/SettingsModal.vue b/frontend/src/views/Dashboard/SettingsModal.vue
index af7ab51..365efe3 100644
--- a/frontend/src/views/Dashboard/SettingsModal.vue
+++ b/frontend/src/views/Dashboard/SettingsModal.vue
@@ -1,4 +1,4 @@
-
@@ -385,10 +391,17 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
class="settings-panel"
>
邮件通知
-
+
-
+
{{ settingsError }}
@@ -400,3 +413,5 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
+
+