feat(notification): 增加邮件通知设置在线测试与保存门禁功能
This commit is contained in:
parent
72af7caee8
commit
b7a99cc996
123
backend/internal/httpapi/notification_test_api.go
Normal file
123
backend/internal/httpapi/notification_test_api.go
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
38
backend/internal/httpapi/notification_test_api_test.go
Normal file
38
backend/internal/httpapi/notification_test_api_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
96
backend/internal/notification/test_mail.go
Normal file
96
backend/internal/notification/test_mail.go
Normal file
@ -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(`<div style="display:inline-block;padding:4px 12px;border-radius:999px;background:#16803c;color:#ffffff;font-size:13px;font-weight:700;">测试通过</div>`)
|
||||
htmlBuilder.WriteString(`<h2 style="margin:14px 0 6px;font-size:24px;line-height:1.35;color:#18181b;">发信通道联通成功</h2>`)
|
||||
htmlBuilder.WriteString(`<p style="margin:0 0 24px;color:#71717a;font-size:14px;">您的 Scert 邮件通知配置已通过在线发信验证,可正常发送通知邮件。</p>`)
|
||||
|
||||
htmlBuilder.WriteString(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e4e4e7;border-radius:8px;overflow:hidden;font-size:14px;">`)
|
||||
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(`</table>`)
|
||||
|
||||
htmlBuilder.WriteString(`<div style="margin-top:20px;padding:14px 16px;border-left:4px solid #16803c;border-radius:8px;background:#f0fdf4;color:#166534;font-size:13px;line-height:1.5;">系统已确认该发信通道有效,您可以放心保存当前系统邮件通知设置。</div>`)
|
||||
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("测试邮件发送失败,请检查通道设置及网络连通性")
|
||||
}
|
||||
@ -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 验证页面布局与功能。
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,7 +1,19 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import HelpTip from "../components/HelpTip/HelpTip.vue";
|
||||
|
||||
defineProps({ notification: { type: Object, required: true } });
|
||||
const props = defineProps({
|
||||
notification: { type: Object, required: true },
|
||||
mailTesting: { type: Boolean, default: false },
|
||||
mailTestError: { type: String, default: "" },
|
||||
mailTestSuccess: { type: String, default: "" },
|
||||
mailVerified: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["test-mail"]);
|
||||
|
||||
function handleTest() {
|
||||
emit("test-mail");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -54,6 +66,29 @@ defineProps({ notification: { type: Object, required: true } });
|
||||
placeholder="多个地址用逗号分隔"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="notification.smtp_enabled" class="mail-test-actions">
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="mailTesting"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ mailTesting ? "正在测试发信……" : "测试邮件发送" }}
|
||||
</button>
|
||||
<span
|
||||
class="mail-verification-status"
|
||||
:class="mailVerified ? 'verified' : 'pending'"
|
||||
>
|
||||
{{ mailVerified ? "已通过测试" : "待在线测试" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="mailTestError" class="form-error mail-form-error">{{ mailTestError }}</p>
|
||||
<p v-if="mailTestSuccess" class="form-success mail-form-success">{{ mailTestSuccess }}</p>
|
||||
<HelpTip v-if="notification.smtp_enabled && !mailVerified">
|
||||
保存邮件通知设置前,必须先点击“测试邮件发送”并通过在线测试验证。
|
||||
</HelpTip>
|
||||
</fieldset>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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, /<MailNotificationSettings :notification="settingsForm.notification"/);
|
||||
assert.match(source, /<MailNotificationSettings[\s\S]*:notification="settingsForm.notification"/);
|
||||
assert.doesNotMatch(mailSource, /role="tab"/);
|
||||
});
|
||||
|
||||
@ -128,3 +128,4 @@ test("settings.keyboardNavigationWrapsAndFocusesTabs", async () => {
|
||||
await state.navigateTabs({ ...event, key: "Home" });
|
||||
assert.equal(state.activeSection.value, "service");
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import HelpTip from "../components/HelpTip/HelpTip.vue";
|
||||
import { useSettingsModal } from "./SettingsModal.js";
|
||||
import MailNotificationSettings from "./MailNotificationSettings.vue";
|
||||
@ -23,6 +23,12 @@ const {
|
||||
closeSettings,
|
||||
saveSettings,
|
||||
dnsVerificationRequired,
|
||||
mailTesting,
|
||||
mailTestError,
|
||||
mailTestSuccess,
|
||||
mailVerified,
|
||||
mailReadyToSave,
|
||||
testMail,
|
||||
} = props.state;
|
||||
const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSettings);
|
||||
</script>
|
||||
@ -385,10 +391,17 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
|
||||
class="settings-panel"
|
||||
>
|
||||
<h3>邮件通知</h3>
|
||||
<MailNotificationSettings :notification="settingsForm.notification" />
|
||||
<MailNotificationSettings
|
||||
:notification="settingsForm.notification"
|
||||
:mail-testing="mailTesting"
|
||||
:mail-test-error="mailTestError"
|
||||
:mail-test-success="mailTestSuccess"
|
||||
:mail-verified="mailVerified"
|
||||
@test-mail="testMail"
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-button" type="button" :disabled="settingsSaving" @click="closeSettings">取消</button>
|
||||
<button class="primary-button" type="button" :disabled="settingsSaving" @click="submitSettings">{{ settingsSaving ? "保存中……" : "保存邮件通知" }}</button>
|
||||
<button class="primary-button" type="button" :disabled="settingsSaving || !mailReadyToSave" @click="submitSettings">{{ settingsSaving ? "保存中……" : "保存邮件通知" }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="settingsError" class="form-error">{{ settingsError }}</p>
|
||||
@ -400,3 +413,5 @@ const { activeSection, submitSettings, navigateTabs } = useSettingsModal(saveSet
|
||||
|
||||
<style scoped src="./Dashboard.css"></style>
|
||||
<style scoped src="./SettingsModal.css"></style>
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user