工单与公告问题修复:回复强制弹窗、消息左右顺序、指定用户搜索、用户回复邮件通知
ci/woodpecker/tag/build Pipeline was successful

1. 工单新回复任意页面强制弹出:通知新增 kind 字段(reply/status),
   MobileLayout 轮询检测本设备本账号新出现的未读 reply 通知并强制弹窗
   (打卡/请假/我的等所有页面均生效);首次加载只记基线不弹历史,
   基线按用户 id 分开存储避免多账号串扰;状态变更通知不弹窗。
2. 修复管理端工单对话左右顺序:此前 user/admin 位置对调(admin 在左、
   user 在右),现 manager 侧 admin 居右、user 居左,与用户端一致。
3. 公告指定用户改为搜索选择:新增 /admin/announcements/targets,
   按用户名/学号/姓名/学校(代理按用户名)模糊查询,前端改为
   搜索候选列表点选,替代手动输入用户ID。
4. 用户回复工单也邮件通知管理员:新增 NotifyTicketUserReply,
   AddTicketMessage 后异步发信。
This commit is contained in:
becomer
2026-10-16 23:15:26 +08:00
parent 27157a7b39
commit 486e7ede04
10 changed files with 382 additions and 41 deletions
@@ -6,12 +6,70 @@ import (
"cpdaily-new/internal/utils"
"errors"
"log/slog"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// AnnouncementTargetItem 公告指定对象候选项(供管理端搜索选择指定用户/代理)
type AnnouncementTargetItem struct {
ID uint `json:"id"`
Username string `json:"username"`
RealName string `json:"realName"`
StudentID string `json:"studentId,omitempty"`
SchoolName string `json:"schoolName,omitempty"`
Role string `json:"role,omitempty"`
}
// SearchAnnouncementTargets 搜索公告指定对象:按 targetType 在普通用户或代理中
// 按用户名/学号/姓名/学校模糊查询,返回轻量候选列表供前端选择(最多 20 条)。
func SearchAnnouncementTargets(c *fiber.Ctx) error {
targetType := c.Query("targetType", "user")
keyword := strings.TrimSpace(c.Query("keyword", ""))
const limit = 20
items := make([]AnnouncementTargetItem, 0, limit)
if targetType == "agent" {
q := database.DB.Model(&model.AgentUser{})
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("username LIKE ?", like)
}
var agents []model.AgentUser
q.Order("created_at DESC").Limit(limit).Find(&agents)
for _, a := range agents {
items = append(items, AnnouncementTargetItem{
ID: a.ID,
Username: a.Username,
Role: a.Role,
})
}
return utils.Success(c, items)
}
q := database.DB.Model(&model.SysUser{})
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("username LIKE ? OR real_name LIKE ? OR student_id LIKE ? OR school_name LIKE ?",
like, like, like, like)
}
var users []model.SysUser
q.Order("created_at DESC").Limit(limit).Find(&users)
for _, u := range users {
items = append(items, AnnouncementTargetItem{
ID: u.ID,
Username: u.Username,
RealName: u.RealName,
StudentID: u.StudentID,
SchoolName: u.SchoolName,
})
}
return utils.Success(c, items)
}
// CreateAnnouncementRequest 创建公告请求
type CreateAnnouncementRequest struct {
Title string `json:"title"`
+20 -3
View File
@@ -71,6 +71,7 @@ type TicketMessageRes struct {
type TicketNotificationRes struct {
ID uint `json:"id"`
TicketID uint `json:"ticketId"`
Kind string `json:"kind"` // reply(管理员回复) / status(状态变更)
Title string `json:"title"`
Content string `json:"content"`
IsRead bool `json:"isRead"`
@@ -82,10 +83,17 @@ type TicketNotificationRes struct {
// ---------- 通知辅助函数 ----------
func createTicketNotification(ticketID, userID uint, title, content string) {
// 通知类型:reply=管理员回复 / status=状态变更
const (
ticketNotifKindReply = "reply"
ticketNotifKindStatus = "status"
)
func createTicketNotification(ticketID, userID uint, kind, title, content string) {
notif := model.TicketNotification{
UserID: userID,
TicketID: ticketID,
Kind: kind,
Title: title,
Content: content,
}
@@ -247,6 +255,14 @@ func AddTicketMessage(c *fiber.Ctx) error {
database.DB.Model(&ticket).Update("status", "pending")
}
// 异步邮件提醒管理员:工单对话有新回复
go func() {
var u model.SysUser
if err := database.DB.Select("username, real_name, school_name").First(&u, userID).Error; err == nil {
service.NotifyTicketUserReply(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, msg.Content, msg.CreatedAt.Format("2006-01-02 15:04"))
}
}()
return utils.Success(c, fiber.Map{
"id": msg.ID,
})
@@ -331,6 +347,7 @@ func ListTicketNotifications(c *fiber.Ctx) error {
items = append(items, TicketNotificationRes{
ID: n.ID,
TicketID: n.TicketID,
Kind: n.Kind,
Title: n.Title,
Content: n.Content,
IsRead: n.IsRead,
@@ -518,7 +535,7 @@ func AdminReplyTicket(c *fiber.Ctx) error {
// 发送通知给用户
title := fmt.Sprintf("工单 #%d 有新回复", ticket.ID)
content := truncateString(req.Content, 100)
createTicketNotification(ticket.ID, ticket.UserID, title, content)
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindReply, title, content)
return utils.Success(c, fiber.Map{
"id": msg.ID,
@@ -567,7 +584,7 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
}
title := fmt.Sprintf("工单 #%d 状态变更", ticket.ID)
content := fmt.Sprintf("工单状态已更新为:%s", statusLabels[req.Status])
createTicketNotification(ticket.ID, ticket.UserID, title, content)
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindStatus, title, content)
return utils.Success(c, fiber.Map{
"status": req.Status,
@@ -9,6 +9,7 @@ type TicketNotification struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
TicketID uint `gorm:"not null;index" json:"ticketId"`
Kind string `gorm:"type:varchar(16);not null;default:reply;index" json:"kind"` // reply(管理员回复) / status(状态变更)
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"size:500;not null" json:"content"`
IsRead bool `gorm:"not null;default:false;index" json:"isRead"`
+37 -7
View File
@@ -18,14 +18,14 @@ import (
// 系统配置 key(与 system_configs 表对应,后台「系统设置」页可编辑)
const (
CfgSmtpHost = "smtp_host" // SMTP 服务器地址
CfgSmtpPort = "smtp_port" // SMTP 端口
CfgSmtpUser = "smtp_user" // SMTP 用户名
CfgSmtpPassword = "smtp_password" // SMTP 密码/授权码
CfgSmtpFrom = "smtp_from" // 发件人地址(默认取 smtp_user)
CfgSmtpHost = "smtp_host" // SMTP 服务器地址
CfgSmtpPort = "smtp_port" // SMTP 端口
CfgSmtpUser = "smtp_user" // SMTP 用户名
CfgSmtpPassword = "smtp_password" // SMTP 密码/授权码
CfgSmtpFrom = "smtp_from" // 发件人地址(默认取 smtp_user)
CfgSmtpFromName = "smtp_from_name" // 发件人显示名称
CfgSmtpSecure = "smtp_secure" // ssl / starttls / none,留空按端口自动判断
CfgAdminEmails = "admin_emails" // 管理员收件邮箱(多个用英文逗号分隔)
CfgSmtpSecure = "smtp_secure" // ssl / starttls / none,留空按端口自动判断
CfgAdminEmails = "admin_emails" // 管理员收件邮箱(多个用英文逗号分隔)
)
type smtpSettings struct {
@@ -341,3 +341,33 @@ func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, conte
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并回复处理。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}
// NotifyTicketUserReply 用户回复工单提醒(发给管理员)。
// 与 NotifyNewTicket 同样是"有新内容待处理",用于工单对话中用户追加回复时再次邮件提醒管理员。
func NotifyTicketUserReply(ticketID uint, username, schoolName, realName, ticketTitle, replyContent, replyAt string) {
adminEmails := GetAdminEmails()
if len(adminEmails) == 0 {
slog.Info("未配置管理员邮箱,跳过工单回复通知", "ticketId", ticketID)
return
}
escaped := html.EscapeString(replyContent)
if len([]rune(escaped)) > 500 {
escaped = string([]rune(escaped)[:500]) + "……"
}
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
displayName := realName
if displayName == "" {
displayName = username
}
subject := fmt.Sprintf("【小助手】工单 #%d 有新回复:%s", ticketID, ticketTitle)
inner := `<h2 style="margin:0 0 16px;font-size:17px">工单收到用户的新回复</h2>
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
<tr><td style="padding-right:16px;color:#888;vertical-align:top">工单</td><td><b>#` + fmt.Sprintf("%d", ticketID) + ` ` + html.EscapeString(ticketTitle) + `</b></td></tr>
<tr><td style="padding-right:16px;color:#888">用户</td><td>` + html.EscapeString(displayName) + `(` + html.EscapeString(username) + `)</td></tr>
<tr><td style="padding-right:16px;color:#888">学校</td><td>` + html.EscapeString(schoolName) + `</td></tr>
<tr><td style="padding-right:16px;color:#888;vertical-align:top">回复内容</td><td style="max-width:420px">` + escaped + `</td></tr>
<tr><td style="padding-right:16px;color:#888">回复时间</td><td>` + html.EscapeString(replyAt) + `</td></tr>
</table>
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并继续处理。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}