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:
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user