Merge pull request #5 from huabeitech/feature/notification-center
Feature/notification center
This commit is contained in:
+1
-1
Submodule docs updated: a9099af47e...7504ff38a3
@@ -101,6 +101,7 @@ func addRouter(app *iris.Application) {
|
|||||||
|
|
||||||
mvc.Configure(app.Party("/api/ws"), func(m *mvc.Application) {
|
mvc.Configure(app.Party("/api/ws"), func(m *mvc.Application) {
|
||||||
m.Router.Get("/dashboard", middleware.AuthMiddleware, services.WsService.HandleDashboardWS)
|
m.Router.Get("/dashboard", middleware.AuthMiddleware, services.WsService.HandleDashboardWS)
|
||||||
|
m.Router.Get("/dashboard/notification", middleware.AuthMiddleware, services.WsService.HandleDashboardNotificationWS)
|
||||||
m.Router.Get("/open", services.WsService.HandleOpenWS)
|
m.Router.Get("/open", services.WsService.HandleOpenWS)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ func addRouter(app *iris.Application) {
|
|||||||
m.Party("/tag").Handle(new(dashboard.TagController))
|
m.Party("/tag").Handle(new(dashboard.TagController))
|
||||||
m.Party("/conversation").Handle(new(dashboard.ConversationController))
|
m.Party("/conversation").Handle(new(dashboard.ConversationController))
|
||||||
m.Party("/ticket").Handle(new(dashboard.TicketController))
|
m.Party("/ticket").Handle(new(dashboard.TicketController))
|
||||||
|
m.Party("/notification").Handle(new(dashboard.NotificationController))
|
||||||
m.Party("/ticket-resolution-code").Handle(new(dashboard.TicketResolutionCodeController))
|
m.Party("/ticket-resolution-code").Handle(new(dashboard.TicketResolutionCodeController))
|
||||||
m.Party("/ticket-priority-config").Handle(new(dashboard.TicketPriorityConfigController))
|
m.Party("/ticket-priority-config").Handle(new(dashboard.TicketPriorityConfigController))
|
||||||
m.Party("/quick-reply").Handle(new(dashboard.QuickReplyController))
|
m.Party("/quick-reply").Handle(new(dashboard.QuickReplyController))
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package builders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/pkg/dto/response"
|
||||||
|
"cs-agent/internal/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildNotification(item *models.Notification) *response.NotificationResponse {
|
||||||
|
if item == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &response.NotificationResponse{
|
||||||
|
ID: item.ID,
|
||||||
|
RecipientUserID: item.RecipientUserID,
|
||||||
|
Title: item.Title,
|
||||||
|
Content: item.Content,
|
||||||
|
NotificationType: item.NotificationType,
|
||||||
|
BizType: item.BizType,
|
||||||
|
BizID: item.BizID,
|
||||||
|
ActionURL: item.ActionURL,
|
||||||
|
ReadAt: utils.FormatTimePtr(item.ReadAt),
|
||||||
|
CreatedAt: utils.FormatTime(item.CreatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildNotificationList(list []models.Notification) []response.NotificationResponse {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
results := make([]response.NotificationResponse, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
if item := BuildNotification(&list[i]); item != nil {
|
||||||
|
results = append(results, *item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cs-agent/internal/builders"
|
||||||
|
"cs-agent/internal/pkg/constants"
|
||||||
|
"cs-agent/internal/pkg/dto/request"
|
||||||
|
"cs-agent/internal/pkg/dto/response"
|
||||||
|
"cs-agent/internal/pkg/enums"
|
||||||
|
"cs-agent/internal/services"
|
||||||
|
|
||||||
|
"github.com/kataras/iris/v12"
|
||||||
|
"github.com/mlogclub/simple/web"
|
||||||
|
"github.com/mlogclub/simple/web/params"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationController struct {
|
||||||
|
Ctx iris.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NotificationController) AnyList() *web.JsonResult {
|
||||||
|
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionNotificationView)
|
||||||
|
if err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cnd := params.NewPagedSqlCnd(c.Ctx,
|
||||||
|
params.QueryFilter{ParamName: "type", ColumnName: "notification_type"},
|
||||||
|
).Eq("recipient_user_id", operator.UserID).
|
||||||
|
Eq("status", enums.StatusOk).
|
||||||
|
Desc("id")
|
||||||
|
|
||||||
|
switch strings.TrimSpace(c.Ctx.URLParam("readStatus")) {
|
||||||
|
case "unread":
|
||||||
|
cnd.Where("read_at IS NULL")
|
||||||
|
case "read":
|
||||||
|
cnd.Where("read_at IS NOT NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, paging := services.NotificationService.FindPageByCnd(cnd)
|
||||||
|
return web.JsonData(&web.PageResult{
|
||||||
|
Results: builders.BuildNotificationList(list),
|
||||||
|
Page: paging,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NotificationController) GetUnread_count() *web.JsonResult {
|
||||||
|
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionNotificationView)
|
||||||
|
if err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
return web.JsonData(&response.NotificationUnreadCountResponse{
|
||||||
|
UnreadCount: services.NotificationService.CountUnread(operator.UserID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NotificationController) PostMark_read() *web.JsonResult {
|
||||||
|
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionNotificationUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
req := request.MarkNotificationReadRequest{}
|
||||||
|
if err := params.ReadJSON(c.Ctx, &req); err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
if err := services.NotificationService.MarkRead(req.ID, operator.UserID); err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
return web.JsonSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NotificationController) PostMark_all_read() *web.JsonResult {
|
||||||
|
operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionNotificationUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
if err := services.NotificationService.MarkAllRead(operator.UserID); err != nil {
|
||||||
|
return web.JsonError(err)
|
||||||
|
}
|
||||||
|
return web.JsonSuccess()
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ var Models = []any{
|
|||||||
&TicketEventLog{},
|
&TicketEventLog{},
|
||||||
&TicketSLARecord{},
|
&TicketSLARecord{},
|
||||||
&TicketRelation{},
|
&TicketRelation{},
|
||||||
|
&Notification{},
|
||||||
&AIAgent{},
|
&AIAgent{},
|
||||||
&Channel{},
|
&Channel{},
|
||||||
&AgentProfile{},
|
&AgentProfile{},
|
||||||
@@ -112,6 +113,21 @@ type TicketView struct {
|
|||||||
AuditFields
|
AuditFields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification 站内通知。
|
||||||
|
type Notification struct {
|
||||||
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
|
RecipientUserID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||||
|
Title string `gorm:"type:varchar(255);not null;default:''"`
|
||||||
|
Content string `gorm:"type:text"`
|
||||||
|
NotificationType string `gorm:"type:varchar(50);not null;default:'';index"`
|
||||||
|
BizType string `gorm:"type:varchar(50);not null;default:'';index"`
|
||||||
|
BizID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||||
|
ActionURL string `gorm:"type:varchar(255);not null;default:''"`
|
||||||
|
ReadAt *time.Time `gorm:"type:datetime;index"`
|
||||||
|
Status enums.Status `gorm:"type:int;not null;default:0;index"`
|
||||||
|
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
||||||
|
}
|
||||||
|
|
||||||
// AuditFields 定义涉及用户操作数据的统一审计字段。
|
// AuditFields 定义涉及用户操作数据的统一审计字段。
|
||||||
// 该结构记录数据创建与更新的时间、操作者ID和操作者名称。
|
// 该结构记录数据创建与更新的时间、操作者ID和操作者名称。
|
||||||
type AuditFields struct {
|
type AuditFields struct {
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ var (
|
|||||||
PermissionTicketPriorityConfigUpdate = Permission{Name: "更新工单优先级", Code: "ticketPriorityConfig.update", Type: "api", GroupName: "ticketPriorityConfig", Method: "POST", APIPath: "/api/dashboard/ticket-priority-config/update", SortNo: 640}
|
PermissionTicketPriorityConfigUpdate = Permission{Name: "更新工单优先级", Code: "ticketPriorityConfig.update", Type: "api", GroupName: "ticketPriorityConfig", Method: "POST", APIPath: "/api/dashboard/ticket-priority-config/update", SortNo: 640}
|
||||||
PermissionTicketPriorityConfigDelete = Permission{Name: "删除工单优先级", Code: "ticketPriorityConfig.delete", Type: "api", GroupName: "ticketPriorityConfig", Method: "POST", APIPath: "/api/dashboard/ticket-priority-config/delete", SortNo: 650}
|
PermissionTicketPriorityConfigDelete = Permission{Name: "删除工单优先级", Code: "ticketPriorityConfig.delete", Type: "api", GroupName: "ticketPriorityConfig", Method: "POST", APIPath: "/api/dashboard/ticket-priority-config/delete", SortNo: 650}
|
||||||
|
|
||||||
|
// 通知相关权限
|
||||||
|
PermissionNotificationView = Permission{Name: "查看通知", Code: "notification.view", Type: "api", GroupName: "notification", Method: "ANY", APIPath: "/api/dashboard/notification/list", SortNo: 680}
|
||||||
|
PermissionNotificationUpdate = Permission{Name: "更新通知", Code: "notification.update", Type: "api", GroupName: "notification", Method: "POST", APIPath: "/api/dashboard/notification/mark_read", SortNo: 690}
|
||||||
|
|
||||||
// 快捷回复相关权限
|
// 快捷回复相关权限
|
||||||
PermissionQuickReplyView = Permission{Name: "查看快捷回复", Code: "quickReply.view", Type: "api", GroupName: "quickReply", Method: "ANY", APIPath: "/api/dashboard/quick-reply/list", SortNo: 610}
|
PermissionQuickReplyView = Permission{Name: "查看快捷回复", Code: "quickReply.view", Type: "api", GroupName: "quickReply", Method: "ANY", APIPath: "/api/dashboard/quick-reply/list", SortNo: 610}
|
||||||
PermissionQuickReplyCreate = Permission{Name: "创建快捷回复", Code: "quickReply.create", Type: "api", GroupName: "quickReply", Method: "POST", APIPath: "/api/dashboard/quick-reply/create", SortNo: 620}
|
PermissionQuickReplyCreate = Permission{Name: "创建快捷回复", Code: "quickReply.create", Type: "api", GroupName: "quickReply", Method: "POST", APIPath: "/api/dashboard/quick-reply/create", SortNo: 620}
|
||||||
@@ -227,6 +231,8 @@ var Permissions = []Permission{
|
|||||||
PermissionTicketPriorityConfigCreate,
|
PermissionTicketPriorityConfigCreate,
|
||||||
PermissionTicketPriorityConfigUpdate,
|
PermissionTicketPriorityConfigUpdate,
|
||||||
PermissionTicketPriorityConfigDelete,
|
PermissionTicketPriorityConfigDelete,
|
||||||
|
PermissionNotificationView,
|
||||||
|
PermissionNotificationUpdate,
|
||||||
PermissionQuickReplyView,
|
PermissionQuickReplyView,
|
||||||
PermissionQuickReplyCreate,
|
PermissionQuickReplyCreate,
|
||||||
PermissionQuickReplyUpdate,
|
PermissionQuickReplyUpdate,
|
||||||
@@ -327,6 +333,7 @@ var RolePermissions = map[string][]Permission{
|
|||||||
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketReply, PermissionTicketClose, PermissionTicketReopen,
|
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketReply, PermissionTicketClose, PermissionTicketReopen,
|
||||||
PermissionTicketResolutionCodeView, PermissionTicketResolutionCodeCreate, PermissionTicketResolutionCodeUpdate, PermissionTicketResolutionCodeDelete,
|
PermissionTicketResolutionCodeView, PermissionTicketResolutionCodeCreate, PermissionTicketResolutionCodeUpdate, PermissionTicketResolutionCodeDelete,
|
||||||
PermissionTicketPriorityConfigView, PermissionTicketPriorityConfigCreate, PermissionTicketPriorityConfigUpdate, PermissionTicketPriorityConfigDelete,
|
PermissionTicketPriorityConfigView, PermissionTicketPriorityConfigCreate, PermissionTicketPriorityConfigUpdate, PermissionTicketPriorityConfigDelete,
|
||||||
|
PermissionNotificationView, PermissionNotificationUpdate,
|
||||||
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
||||||
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
||||||
PermissionCompanyView, PermissionCompanyCreate, PermissionCompanyUpdate, PermissionCompanyDelete,
|
PermissionCompanyView, PermissionCompanyCreate, PermissionCompanyUpdate, PermissionCompanyDelete,
|
||||||
@@ -349,6 +356,7 @@ var RolePermissions = map[string][]Permission{
|
|||||||
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketReply, PermissionTicketClose, PermissionTicketReopen,
|
PermissionTicketView, PermissionTicketCreate, PermissionTicketUpdate, PermissionTicketAssign, PermissionTicketChangeStatus, PermissionTicketReply, PermissionTicketClose, PermissionTicketReopen,
|
||||||
PermissionTicketResolutionCodeView, PermissionTicketResolutionCodeCreate, PermissionTicketResolutionCodeUpdate, PermissionTicketResolutionCodeDelete,
|
PermissionTicketResolutionCodeView, PermissionTicketResolutionCodeCreate, PermissionTicketResolutionCodeUpdate, PermissionTicketResolutionCodeDelete,
|
||||||
PermissionTicketPriorityConfigView, PermissionTicketPriorityConfigCreate, PermissionTicketPriorityConfigUpdate, PermissionTicketPriorityConfigDelete,
|
PermissionTicketPriorityConfigView, PermissionTicketPriorityConfigCreate, PermissionTicketPriorityConfigUpdate, PermissionTicketPriorityConfigDelete,
|
||||||
|
PermissionNotificationView, PermissionNotificationUpdate,
|
||||||
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
PermissionQuickReplyView, PermissionQuickReplyCreate, PermissionQuickReplyUpdate, PermissionQuickReplyDelete,
|
||||||
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
PermissionTagView, PermissionTagCreate, PermissionTagUpdate, PermissionTagDelete,
|
||||||
PermissionCompanyView,
|
PermissionCompanyView,
|
||||||
@@ -369,6 +377,7 @@ var RolePermissions = map[string][]Permission{
|
|||||||
PermissionConversationView,
|
PermissionConversationView,
|
||||||
PermissionTicketView, PermissionTicketCreate, PermissionTicketReply,
|
PermissionTicketView, PermissionTicketCreate, PermissionTicketReply,
|
||||||
PermissionTicketResolutionCodeView, PermissionTicketPriorityConfigView,
|
PermissionTicketResolutionCodeView, PermissionTicketPriorityConfigView,
|
||||||
|
PermissionNotificationView, PermissionNotificationUpdate,
|
||||||
PermissionQuickReplyView,
|
PermissionQuickReplyView,
|
||||||
PermissionTagView,
|
PermissionTagView,
|
||||||
PermissionCompanyView,
|
PermissionCompanyView,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package request
|
||||||
|
|
||||||
|
type CreateNotificationRequest struct {
|
||||||
|
RecipientUserID int64 `json:"recipientUserId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
NotificationType string `json:"notificationType"`
|
||||||
|
BizType string `json:"bizType"`
|
||||||
|
BizID int64 `json:"bizId"`
|
||||||
|
ActionURL string `json:"actionUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarkNotificationReadRequest struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
type NotificationResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
RecipientUserID int64 `json:"recipientUserId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
NotificationType string `json:"notificationType"`
|
||||||
|
BizType string `json:"bizType"`
|
||||||
|
BizID int64 `json:"bizId"`
|
||||||
|
ActionURL string `json:"actionUrl"`
|
||||||
|
ReadAt string `json:"readAt,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationUnreadCountResponse struct {
|
||||||
|
UnreadCount int64 `json:"unreadCount"`
|
||||||
|
}
|
||||||
@@ -250,6 +250,7 @@ const (
|
|||||||
IMRealtimeEventConversationTransferred = "conversation.transferred"
|
IMRealtimeEventConversationTransferred = "conversation.transferred"
|
||||||
IMRealtimeEventConversationClosed = "conversation.closed"
|
IMRealtimeEventConversationClosed = "conversation.closed"
|
||||||
IMRealtimeEventConversationRead = "conversation.read"
|
IMRealtimeEventConversationRead = "conversation.read"
|
||||||
|
IMRealtimeEventNotificationCreated = "notification.created"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"github.com/mlogclub/simple/web/params"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var NotificationRepository = newNotificationRepository()
|
||||||
|
|
||||||
|
func newNotificationRepository() *notificationRepository {
|
||||||
|
return ¬ificationRepository{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationRepository struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationRepository) Get(db *gorm.DB, id int64) *models.Notification {
|
||||||
|
ret := &models.Notification{}
|
||||||
|
if err := db.First(ret, "id = ?", id).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.Notification) {
|
||||||
|
cnd.Find(db, &list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.Notification, paging *sqls.Paging) {
|
||||||
|
return r.FindPageByCnd(db, ¶ms.Cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.Notification, paging *sqls.Paging) {
|
||||||
|
cnd.Find(db, &list)
|
||||||
|
count := cnd.Count(db, &models.Notification{})
|
||||||
|
|
||||||
|
paging = &sqls.Paging{
|
||||||
|
Page: cnd.Paging.Page,
|
||||||
|
Limit: cnd.Paging.Limit,
|
||||||
|
Total: count,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
||||||
|
return cnd.Count(db, &models.Notification{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationRepository) Create(db *gorm.DB, item *models.Notification) error {
|
||||||
|
return db.Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error {
|
||||||
|
return db.Model(&models.Notification{}).Where("id = ?", id).Updates(columns).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *notificationRepository) MarkAllRead(db *gorm.DB, userID int64, readAt time.Time) error {
|
||||||
|
return db.Model(&models.Notification{}).
|
||||||
|
Where("recipient_user_id = ? AND read_at IS NULL", userID).
|
||||||
|
Updates(map[string]any{"read_at": readAt}).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package event_handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cs-agent/internal/events"
|
||||||
|
"cs-agent/internal/pkg/dto/request"
|
||||||
|
"cs-agent/internal/pkg/eventbus"
|
||||||
|
"cs-agent/internal/services"
|
||||||
|
|
||||||
|
"github.com/mlogclub/simple/common/strs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
eventbus.
|
||||||
|
Register[events.TicketAssignedEvent]().
|
||||||
|
Subscribe(handleTicketAssignedInAppNotification)
|
||||||
|
eventbus.
|
||||||
|
Register[events.ConversationAssignedEvent]().
|
||||||
|
Subscribe(handleConversationAssignedInAppNotification)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTicketAssignedInAppNotification(ctx context.Context, event events.TicketAssignedEvent) error {
|
||||||
|
if event.TicketID <= 0 || event.ToUserID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ticket := services.TicketService.Get(event.TicketID)
|
||||||
|
if ticket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content := fmt.Sprintf("工单 %s 已指派给你", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID)))
|
||||||
|
if title := strings.TrimSpace(ticket.Title); title != "" {
|
||||||
|
content = content + "\n" + title
|
||||||
|
}
|
||||||
|
if reason := strings.TrimSpace(event.Reason); reason != "" {
|
||||||
|
content = content + "\n指派原因: " + reason
|
||||||
|
}
|
||||||
|
_, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{
|
||||||
|
RecipientUserID: event.ToUserID,
|
||||||
|
Title: "工单指派提醒",
|
||||||
|
Content: content,
|
||||||
|
NotificationType: "ticket_assigned",
|
||||||
|
BizType: "ticket",
|
||||||
|
BizID: ticket.ID,
|
||||||
|
ActionURL: fmt.Sprintf("/dashboard/tickets/%d", ticket.ID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create ticket assigned in-app notification failed", "error", err, "ticketId", event.TicketID, "toUserId", event.ToUserID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConversationAssignedInAppNotification(ctx context.Context, event events.ConversationAssignedEvent) error {
|
||||||
|
if event.ConversationID <= 0 || event.ToUserID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conversation := services.ConversationService.Get(event.ConversationID)
|
||||||
|
if conversation == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content := fmt.Sprintf("会话 #%d 已分配给你", conversation.ID)
|
||||||
|
if summary := strings.TrimSpace(services.ConversationService.BuildConversationSummary(conversation)); summary != "" {
|
||||||
|
content = content + "\n" + summary
|
||||||
|
}
|
||||||
|
if reason := strings.TrimSpace(event.Reason); reason != "" {
|
||||||
|
content = content + "\n分配原因: " + reason
|
||||||
|
}
|
||||||
|
_, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{
|
||||||
|
RecipientUserID: event.ToUserID,
|
||||||
|
Title: conversationAssignedNotifyTitle(event.AssignType),
|
||||||
|
Content: content,
|
||||||
|
NotificationType: "conversation_assigned",
|
||||||
|
BizType: "conversation",
|
||||||
|
BizID: conversation.ID,
|
||||||
|
ActionURL: fmt.Sprintf("/dashboard/conversations?conversationId=%d", conversation.ID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create conversation assigned in-app notification failed", "error", err, "conversationId", event.ConversationID, "toUserId", event.ToUserID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package event_handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cs-agent/internal/events"
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/pkg/enums"
|
||||||
|
"cs-agent/internal/repositories"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTicketAssignedInAppNotification(t *testing.T) {
|
||||||
|
setupNotificationEventHandlerTestDB(t)
|
||||||
|
|
||||||
|
ticket := &models.Ticket{
|
||||||
|
TicketNo: "TK202604280001",
|
||||||
|
Title: "退款处理",
|
||||||
|
Source: enums.TicketSourceManual,
|
||||||
|
Status: enums.TicketStatusOpen,
|
||||||
|
CurrentAssigneeID: 11,
|
||||||
|
AuditFields: models.AuditFields{
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := repositories.TicketRepository.Create(sqls.DB(), ticket); err != nil {
|
||||||
|
t.Fatalf("create ticket error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handleTicketAssignedInAppNotification(context.Background(), events.TicketAssignedEvent{
|
||||||
|
TicketID: ticket.ID,
|
||||||
|
FromUserID: 0,
|
||||||
|
ToUserID: 11,
|
||||||
|
OperatorID: 1,
|
||||||
|
Reason: "需要人工跟进",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handler error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := repositories.NotificationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("recipient_user_id", 11))
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("expected 1 notification, got %d", len(list))
|
||||||
|
}
|
||||||
|
got := list[0]
|
||||||
|
if got.NotificationType != "ticket_assigned" || got.BizType != "ticket" || got.BizID != ticket.ID {
|
||||||
|
t.Fatalf("unexpected notification: %+v", got)
|
||||||
|
}
|
||||||
|
if got.ActionURL != "/dashboard/tickets/1" {
|
||||||
|
t.Fatalf("unexpected action url: %q", got.ActionURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConversationAssignedInAppNotification(t *testing.T) {
|
||||||
|
setupNotificationEventHandlerTestDB(t)
|
||||||
|
|
||||||
|
conversation := &models.Conversation{
|
||||||
|
CustomerName: "张三",
|
||||||
|
Status: enums.IMConversationStatusActive,
|
||||||
|
CurrentAssigneeID: 22,
|
||||||
|
AuditFields: models.AuditFields{
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := repositories.ConversationRepository.Create(sqls.DB(), conversation); err != nil {
|
||||||
|
t.Fatalf("create conversation error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handleConversationAssignedInAppNotification(context.Background(), events.ConversationAssignedEvent{
|
||||||
|
ConversationID: conversation.ID,
|
||||||
|
FromUserID: 0,
|
||||||
|
ToUserID: 22,
|
||||||
|
OperatorID: 1,
|
||||||
|
Reason: "自动分配",
|
||||||
|
AssignType: events.ConversationAssignTypeAutoAssign,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handler error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := repositories.NotificationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("recipient_user_id", 22))
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("expected 1 notification, got %d", len(list))
|
||||||
|
}
|
||||||
|
got := list[0]
|
||||||
|
if got.NotificationType != "conversation_assigned" || got.BizType != "conversation" || got.BizID != conversation.ID {
|
||||||
|
t.Fatalf("unexpected notification: %+v", got)
|
||||||
|
}
|
||||||
|
if got.ActionURL != "/dashboard/conversations?conversationId=1" {
|
||||||
|
t.Fatalf("unexpected action url: %q", got.ActionURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupNotificationEventHandlerTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
|
||||||
|
NamingStrategy: schema.NamingStrategy{
|
||||||
|
TablePrefix: "t_",
|
||||||
|
SingularTable: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := db.AutoMigrate(&models.Notification{}, &models.Ticket{}, &models.Conversation{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate error = %v", err)
|
||||||
|
}
|
||||||
|
sqls.SetDB(db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/pkg/dto/request"
|
||||||
|
"cs-agent/internal/pkg/dto/response"
|
||||||
|
"cs-agent/internal/pkg/enums"
|
||||||
|
"cs-agent/internal/pkg/errorsx"
|
||||||
|
"cs-agent/internal/pkg/utils"
|
||||||
|
"cs-agent/internal/repositories"
|
||||||
|
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
)
|
||||||
|
|
||||||
|
var NotificationService = newNotificationService()
|
||||||
|
|
||||||
|
func newNotificationService() *notificationService {
|
||||||
|
return ¬ificationService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationService struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *notificationService) Create(req request.CreateNotificationRequest) (*models.Notification, error) {
|
||||||
|
if req.RecipientUserID <= 0 {
|
||||||
|
return nil, errorsx.InvalidParam("接收人不能为空")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
item := &models.Notification{
|
||||||
|
RecipientUserID: req.RecipientUserID,
|
||||||
|
Title: strings.TrimSpace(req.Title),
|
||||||
|
Content: strings.TrimSpace(req.Content),
|
||||||
|
NotificationType: strings.TrimSpace(req.NotificationType),
|
||||||
|
BizType: strings.TrimSpace(req.BizType),
|
||||||
|
BizID: req.BizID,
|
||||||
|
ActionURL: strings.TrimSpace(req.ActionURL),
|
||||||
|
Status: enums.StatusOk,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
if err := repositories.NotificationRepository.Create(sqls.DB(), item); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *notificationService) CreateAndPush(req request.CreateNotificationRequest) (*models.Notification, error) {
|
||||||
|
item, err := s.Create(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
WsService.PublishNotificationCreated(item.RecipientUserID, response.NotificationResponse{
|
||||||
|
ID: item.ID,
|
||||||
|
RecipientUserID: item.RecipientUserID,
|
||||||
|
Title: item.Title,
|
||||||
|
Content: item.Content,
|
||||||
|
NotificationType: item.NotificationType,
|
||||||
|
BizType: item.BizType,
|
||||||
|
BizID: item.BizID,
|
||||||
|
ActionURL: item.ActionURL,
|
||||||
|
ReadAt: utils.FormatTimePtr(item.ReadAt),
|
||||||
|
CreatedAt: utils.FormatTime(item.CreatedAt),
|
||||||
|
})
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *notificationService) FindPageByCnd(cnd *sqls.Cnd) ([]models.Notification, *sqls.Paging) {
|
||||||
|
return repositories.NotificationRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *notificationService) CountUnread(userID int64) int64 {
|
||||||
|
if userID <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return repositories.NotificationRepository.Count(sqls.DB(), sqls.NewCnd().
|
||||||
|
Eq("recipient_user_id", userID).
|
||||||
|
Eq("status", enums.StatusOk).
|
||||||
|
Where("read_at IS NULL"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *notificationService) MarkRead(id int64, userID int64) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return errorsx.InvalidParam("通知不存在")
|
||||||
|
}
|
||||||
|
item := repositories.NotificationRepository.Get(sqls.DB(), id)
|
||||||
|
if item == nil || item.RecipientUserID != userID {
|
||||||
|
return errorsx.InvalidParam("通知不存在")
|
||||||
|
}
|
||||||
|
if item.ReadAt != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return repositories.NotificationRepository.Updates(sqls.DB(), id, map[string]any{
|
||||||
|
"read_at": now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *notificationService) MarkAllRead(userID int64) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return errorsx.InvalidParam("接收人不能为空")
|
||||||
|
}
|
||||||
|
return repositories.NotificationRepository.MarkAllRead(sqls.DB(), userID, time.Now())
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package services_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cs-agent/internal/models"
|
||||||
|
"cs-agent/internal/pkg/dto/request"
|
||||||
|
"cs-agent/internal/services"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNotificationServiceCreateAndUnreadCount(t *testing.T) {
|
||||||
|
setupNotificationTestDB(t)
|
||||||
|
|
||||||
|
item, err := services.NotificationService.Create(request.CreateNotificationRequest{
|
||||||
|
RecipientUserID: 101,
|
||||||
|
Title: "工单指派提醒",
|
||||||
|
Content: "工单 TK-1 已指派给你",
|
||||||
|
NotificationType: "ticket_assigned",
|
||||||
|
BizType: "ticket",
|
||||||
|
BizID: 1,
|
||||||
|
ActionURL: "/dashboard/tickets/1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
if item.ID == 0 {
|
||||||
|
t.Fatalf("expected notification id to be assigned")
|
||||||
|
}
|
||||||
|
if item.RecipientUserID != 101 || item.ReadAt != nil {
|
||||||
|
t.Fatalf("unexpected notification: %+v", item)
|
||||||
|
}
|
||||||
|
if got := services.NotificationService.CountUnread(101); got != 1 {
|
||||||
|
t.Fatalf("expected unread count 1, got %d", got)
|
||||||
|
}
|
||||||
|
if got := services.NotificationService.CountUnread(102); got != 0 {
|
||||||
|
t.Fatalf("expected unread count 0 for another user, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationServiceMarkReadRequiresOwner(t *testing.T) {
|
||||||
|
setupNotificationTestDB(t)
|
||||||
|
|
||||||
|
item, err := services.NotificationService.Create(request.CreateNotificationRequest{
|
||||||
|
RecipientUserID: 201,
|
||||||
|
Title: "会话分配提醒",
|
||||||
|
Content: "会话 #9 已分配给你",
|
||||||
|
NotificationType: "conversation_assigned",
|
||||||
|
BizType: "conversation",
|
||||||
|
BizID: 9,
|
||||||
|
ActionURL: "/dashboard/conversations?conversationId=9",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.NotificationService.MarkRead(item.ID, 202); err == nil {
|
||||||
|
t.Fatalf("expected foreign user mark read to fail")
|
||||||
|
}
|
||||||
|
if got := services.NotificationService.CountUnread(201); got != 1 {
|
||||||
|
t.Fatalf("expected notification to remain unread, got %d", got)
|
||||||
|
}
|
||||||
|
if err := services.NotificationService.MarkRead(item.ID, 201); err != nil {
|
||||||
|
t.Fatalf("MarkRead() owner error = %v", err)
|
||||||
|
}
|
||||||
|
if got := services.NotificationService.CountUnread(201); got != 0 {
|
||||||
|
t.Fatalf("expected unread count 0 after mark read, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationServiceMarkAllReadOnlyCurrentUser(t *testing.T) {
|
||||||
|
setupNotificationTestDB(t)
|
||||||
|
|
||||||
|
for _, userID := range []int64{301, 301, 302} {
|
||||||
|
if _, err := services.NotificationService.Create(request.CreateNotificationRequest{
|
||||||
|
RecipientUserID: userID,
|
||||||
|
Title: "工单指派提醒",
|
||||||
|
Content: "工单已指派给你",
|
||||||
|
NotificationType: "ticket_assigned",
|
||||||
|
BizType: "ticket",
|
||||||
|
BizID: userID,
|
||||||
|
ActionURL: "/dashboard/tickets/1",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.NotificationService.MarkAllRead(301); err != nil {
|
||||||
|
t.Fatalf("MarkAllRead() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := services.NotificationService.CountUnread(301); got != 0 {
|
||||||
|
t.Fatalf("expected user 301 unread count 0, got %d", got)
|
||||||
|
}
|
||||||
|
if got := services.NotificationService.CountUnread(302); got != 1 {
|
||||||
|
t.Fatalf("expected user 302 unread count 1, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupNotificationTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
|
||||||
|
NamingStrategy: schema.NamingStrategy{
|
||||||
|
TablePrefix: "t_",
|
||||||
|
SingularTable: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := db.AutoMigrate(&models.Notification{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate error = %v", err)
|
||||||
|
}
|
||||||
|
sqls.SetDB(db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
@@ -22,8 +22,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
realtimeRoleUser = "user"
|
realtimeRoleUser = "user"
|
||||||
realtimeRoleAdmin = "admin"
|
realtimeRoleAdmin = "admin"
|
||||||
|
realtimeRoleNotification = "notification"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -31,6 +32,7 @@ const (
|
|||||||
realtimeTopicGuestPrefix = "guest:"
|
realtimeTopicGuestPrefix = "guest:"
|
||||||
realtimeTopicAdminPrefix = "admin:"
|
realtimeTopicAdminPrefix = "admin:"
|
||||||
realtimeTopicConversationPrefix = "conversation:"
|
realtimeTopicConversationPrefix = "conversation:"
|
||||||
|
realtimeTopicNotificationPrefix = "notification:"
|
||||||
realtimeTopicAdminAll = "admin:all"
|
realtimeTopicAdminAll = "admin:all"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -219,6 +221,24 @@ func (e RealtimeConversationChangedEvent) EventPayload() RealtimeEventPayload {
|
|||||||
return e.Payload
|
return e.Payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RealtimeNotificationCreatedPayload struct {
|
||||||
|
Notification response.NotificationResponse `json:"notification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RealtimeNotificationCreatedPayload) realtimeEventPayload() {}
|
||||||
|
|
||||||
|
type RealtimeNotificationCreatedEvent struct {
|
||||||
|
Payload RealtimeNotificationCreatedPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e RealtimeNotificationCreatedEvent) EventType() string {
|
||||||
|
return enums.IMRealtimeEventNotificationCreated
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e RealtimeNotificationCreatedEvent) EventPayload() RealtimeEventPayload {
|
||||||
|
return e.Payload
|
||||||
|
}
|
||||||
|
|
||||||
type realtimeClientMessage struct {
|
type realtimeClientMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Topics []string `json:"topics,omitempty"`
|
Topics []string `json:"topics,omitempty"`
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ func (s *wsService) HandleDashboardWS(ctx iris.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *wsService) HandleDashboardNotificationWS(ctx iris.Context) {
|
||||||
|
principal := AuthService.GetAuthPrincipal(ctx)
|
||||||
|
if principal == nil {
|
||||||
|
_ = ctx.StopWithJSON(iris.StatusUnauthorized, web.JsonError(errorsx.Unauthorized("未登录或登录已过期")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleNotification); err != nil {
|
||||||
|
slog.Error("upgrade dashboard notification websocket failed", "error", err, "path", ctx.Path())
|
||||||
|
ctx.StopExecution()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *wsService) HandleOpenWS(ctx iris.Context) {
|
func (s *wsService) HandleOpenWS(ctx iris.Context) {
|
||||||
channel := ChannelService.GetEnabledChannel(ctx)
|
channel := ChannelService.GetEnabledChannel(ctx)
|
||||||
if channel == nil {
|
if channel == nil {
|
||||||
@@ -420,6 +433,19 @@ func (s *wsService) PublishConversationChanged(conversation *models.Conversation
|
|||||||
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *wsService) PublishNotificationCreated(userID int64, notification response.NotificationResponse) {
|
||||||
|
if userID <= 0 || notification.ID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
topic := s.notificationTopic(userID)
|
||||||
|
event := s.newEvent(topic, RealtimeNotificationCreatedEvent{
|
||||||
|
Payload: RealtimeNotificationCreatedPayload{
|
||||||
|
Notification: notification,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
s.PublishToTopic(topic, event)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *wsService) PublishResyncRequired(topics []string, reason string) {
|
func (s *wsService) PublishResyncRequired(topics []string, reason string) {
|
||||||
reason = strings.TrimSpace(reason)
|
reason = strings.TrimSpace(reason)
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
@@ -531,6 +557,11 @@ func (s *wsService) defaultTopics(session *ClientSession) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch session.Role {
|
switch session.Role {
|
||||||
|
case realtimeRoleNotification:
|
||||||
|
if session.Principal == nil || session.Principal.UserID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{s.notificationTopic(session.Principal.UserID)}
|
||||||
case realtimeRoleAdmin:
|
case realtimeRoleAdmin:
|
||||||
if session.Principal == nil || session.Principal.UserID <= 0 {
|
if session.Principal == nil || session.Principal.UserID <= 0 {
|
||||||
return []string{realtimeTopicAdminAll}
|
return []string{realtimeTopicAdminAll}
|
||||||
@@ -554,6 +585,10 @@ func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch session.Role {
|
switch session.Role {
|
||||||
|
case realtimeRoleNotification:
|
||||||
|
if session.Principal == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
case realtimeRoleAdmin:
|
case realtimeRoleAdmin:
|
||||||
if session.Principal == nil {
|
if session.Principal == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -645,6 +680,10 @@ func (s *wsService) adminTopic(userID int64) string {
|
|||||||
return realtimeTopicAdminPrefix + strconv.FormatInt(userID, 10)
|
return realtimeTopicAdminPrefix + strconv.FormatInt(userID, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *wsService) notificationTopic(userID int64) string {
|
||||||
|
return realtimeTopicNotificationPrefix + strconv.FormatInt(userID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *wsService) conversationTopic(conversationID int64) string {
|
func (s *wsService) conversationTopic(conversationID int64) string {
|
||||||
return realtimeTopicConversationPrefix + strconv.FormatInt(conversationID, 10)
|
return realtimeTopicConversationPrefix + strconv.FormatInt(conversationID, 10)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cs-agent/internal/pkg/dto/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWsNotificationTopic(t *testing.T) {
|
||||||
|
svc := newWsService()
|
||||||
|
if got := svc.notificationTopic(123); got != "notification:123" {
|
||||||
|
t.Fatalf("expected notification:123, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWsNotificationCreatedEventType(t *testing.T) {
|
||||||
|
event := RealtimeNotificationCreatedEvent{
|
||||||
|
Payload: RealtimeNotificationCreatedPayload{
|
||||||
|
Notification: response.NotificationResponse{ID: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := event.EventType(); got != "notification.created" {
|
||||||
|
t.Fatalf("expected notification.created, got %q", got)
|
||||||
|
}
|
||||||
|
if payload := event.EventPayload(); payload == nil {
|
||||||
|
t.Fatalf("expected payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useEffect } from "react"
|
|||||||
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import { useAuth } from "@/components/auth-provider"
|
import { useAuth } from "@/components/auth-provider"
|
||||||
|
import { NotificationProvider } from "@/components/notification-provider"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
import { SiteHeader } from "@/components/site-header"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
|
|
||||||
@@ -57,15 +58,17 @@ export default function DashboardLayout({
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSidebar variant="inset" />
|
<NotificationProvider>
|
||||||
<SidebarInset>
|
<AppSidebar variant="inset" />
|
||||||
<SiteHeader />
|
<SidebarInset>
|
||||||
<div className="flex flex-1 flex-col">
|
<SiteHeader />
|
||||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col">
|
||||||
{children}
|
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SidebarInset>
|
||||||
</SidebarInset>
|
</NotificationProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { ListPagination } from "@/components/list-pagination"
|
||||||
|
import { useNotifications } from "@/components/notification-provider"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead,
|
||||||
|
type NotificationItem,
|
||||||
|
type NotificationReadStatus,
|
||||||
|
} from "@/lib/api/notification"
|
||||||
|
import type { PageResult } from "@/lib/api/admin"
|
||||||
|
import { cn, formatDateTime } from "@/lib/utils"
|
||||||
|
|
||||||
|
const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
|
||||||
|
{ value: "all", label: "全部" },
|
||||||
|
{ value: "unread", label: "未读" },
|
||||||
|
{ value: "read", label: "已读" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function DashboardNotificationsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { refreshUnreadCount } = useNotifications()
|
||||||
|
const [readStatus, setReadStatus] = useState<NotificationReadStatus>("all")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [limit, setLimit] = useState(20)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [actionLoading, setActionLoading] = useState(false)
|
||||||
|
const [result, setResult] = useState<PageResult<NotificationItem>>({
|
||||||
|
results: [],
|
||||||
|
page: { page: 1, limit: 20, total: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchNotifications({
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
readStatus,
|
||||||
|
})
|
||||||
|
setResult(data)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "加载通知失败")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [limit, page, readStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
async function openNotification(item: NotificationItem) {
|
||||||
|
try {
|
||||||
|
if (!item.readAt) {
|
||||||
|
await markNotificationRead(item.id)
|
||||||
|
await refreshUnreadCount()
|
||||||
|
}
|
||||||
|
if (item.actionUrl) {
|
||||||
|
router.push(item.actionUrl)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "打开通知失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkAllRead() {
|
||||||
|
setActionLoading(true)
|
||||||
|
try {
|
||||||
|
await markAllNotificationsRead()
|
||||||
|
await refreshUnreadCount()
|
||||||
|
await loadData()
|
||||||
|
toast.success("已全部标记为已读")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "全部已读失败")
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusChange(nextStatus: NotificationReadStatus) {
|
||||||
|
setReadStatus(nextStatus)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(nextPage: number) {
|
||||||
|
if (nextPage < 1 || nextPage === page) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPage(nextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLimitChange(nextLimit: number) {
|
||||||
|
setLimit(nextLimit)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4 md:p-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">通知中心</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
查看工单、会话等业务流转提醒
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
|
||||||
|
<RefreshCwIcon className={cn(loading && "animate-spin")} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleMarkAllRead()}
|
||||||
|
disabled={actionLoading || result.page.total === 0}
|
||||||
|
>
|
||||||
|
<CheckCheckIcon />
|
||||||
|
全部已读
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{readStatusOptions.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
variant={option.value === readStatus ? "default" : "outline"}
|
||||||
|
onClick={() => handleStatusChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-background">
|
||||||
|
{result.results.length > 0 ? (
|
||||||
|
<div className="divide-y">
|
||||||
|
{result.results.map((item) => {
|
||||||
|
const unread = !item.readAt
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openNotification(item)}
|
||||||
|
className="grid w-full gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<BellIcon className="size-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{item.title || "通知"}</span>
|
||||||
|
{unread ? <Badge>未读</Badge> : <Badge variant="outline">已读</Badge>}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(item.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-line text-sm text-muted-foreground">
|
||||||
|
{item.content || "-"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{loading ? "正在加载通知" : "暂无通知"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ListPagination
|
||||||
|
page={result.page.page}
|
||||||
|
limit={result.page.limit}
|
||||||
|
total={result.page.total}
|
||||||
|
loading={loading}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onLimitChange={handleLimitChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { SignJWT } from "jose"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
import type { KefuWidgetHostConfig } from "@/lib/kefu-widget-config"
|
import type { KefuWidgetHostConfig } from "@/lib/kefu-widget-config"
|
||||||
|
|
||||||
const STORAGE_KEY = "cs-agent-web-widget-test-config"
|
const STORAGE_KEY = "cs-agent-web-widget-test-config"
|
||||||
|
const DEFAULT_JWT_TTL_MINUTES = "30"
|
||||||
const INITIAL_CONFIG: KefuWidgetHostConfig = {
|
const INITIAL_CONFIG: KefuWidgetHostConfig = {
|
||||||
channelId: "",
|
channelId: "",
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthMode = "guest" | "jwt"
|
||||||
|
|
||||||
|
type WidgetDemoConfig = KefuWidgetHostConfig & {
|
||||||
|
authMode?: AuthMode
|
||||||
|
jwtSecret?: string
|
||||||
|
jwtUserId?: string
|
||||||
|
jwtName?: string
|
||||||
|
jwtTtlMinutes?: string
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
CSAgentWidget?: {
|
CSAgentWidget?: {
|
||||||
@@ -22,14 +34,14 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultConfig(): KefuWidgetHostConfig {
|
function getDefaultConfig(): WidgetDemoConfig {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return INITIAL_CONFIG
|
return INITIAL_CONFIG
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedText = window.localStorage.getItem(STORAGE_KEY)
|
const savedText = window.localStorage.getItem(STORAGE_KEY)
|
||||||
const savedConfig = savedText
|
const savedConfig = savedText
|
||||||
? (JSON.parse(savedText) as Partial<KefuWidgetHostConfig>)
|
? (JSON.parse(savedText) as Partial<WidgetDemoConfig>)
|
||||||
: {}
|
: {}
|
||||||
const query = new URLSearchParams(window.location.search)
|
const query = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
@@ -37,6 +49,11 @@ function getDefaultConfig(): KefuWidgetHostConfig {
|
|||||||
channelId: query.get("channelId") ?? savedConfig.channelId ?? "",
|
channelId: query.get("channelId") ?? savedConfig.channelId ?? "",
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
|
authMode: (query.get("authMode") as AuthMode | null) ?? savedConfig.authMode ?? "guest",
|
||||||
|
jwtSecret: savedConfig.jwtSecret ?? "",
|
||||||
|
jwtUserId: query.get("userId") ?? savedConfig.jwtUserId ?? "demo-user-001",
|
||||||
|
jwtName: query.get("name") ?? savedConfig.jwtName ?? "测试用户",
|
||||||
|
jwtTtlMinutes: savedConfig.jwtTtlMinutes ?? DEFAULT_JWT_TTL_MINUTES,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +86,79 @@ function injectWidget(config: KefuWidgetHostConfig) {
|
|||||||
document.body.appendChild(script)
|
document.body.appendChild(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildWidgetConfig(config: WidgetDemoConfig, userToken: string): WidgetDemoConfig {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
channelId: config.channelId.trim(),
|
||||||
|
baseUrl: "",
|
||||||
|
apiBaseUrl: "",
|
||||||
|
userToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signUserToken(config: WidgetDemoConfig) {
|
||||||
|
const userId = (config.jwtUserId || "").trim()
|
||||||
|
const name = (config.jwtName || "").trim()
|
||||||
|
const secret = (config.jwtSecret || "").trim()
|
||||||
|
const ttl = Number(config.jwtTtlMinutes || DEFAULT_JWT_TTL_MINUTES)
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("请填写 userId")
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("请填写用户名称")
|
||||||
|
}
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("请填写 JWT Secret")
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(ttl) || ttl <= 0) {
|
||||||
|
throw new Error("有效期必须大于 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SignJWT({ userId, name })
|
||||||
|
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${ttl}m`)
|
||||||
|
.sign(new TextEncoder().encode(secret))
|
||||||
|
}
|
||||||
|
|
||||||
export function KefuWidgetDemo() {
|
export function KefuWidgetDemo() {
|
||||||
const [config, setConfig] = useState<KefuWidgetHostConfig>(INITIAL_CONFIG)
|
const [config, setConfig] = useState<WidgetDemoConfig>({
|
||||||
|
...INITIAL_CONFIG,
|
||||||
|
authMode: "guest",
|
||||||
|
jwtSecret: "",
|
||||||
|
jwtUserId: "demo-user-001",
|
||||||
|
jwtName: "测试用户",
|
||||||
|
jwtTtlMinutes: DEFAULT_JWT_TTL_MINUTES,
|
||||||
|
})
|
||||||
const [status, setStatus] = useState("请填写 channelId")
|
const [status, setStatus] = useState("请填写 channelId")
|
||||||
const [origin, setOrigin] = useState("")
|
const [origin, setOrigin] = useState("")
|
||||||
|
const [generatedToken, setGeneratedToken] = useState("")
|
||||||
|
|
||||||
|
async function mountWidget(configToMount: WidgetDemoConfig) {
|
||||||
|
let userToken = ""
|
||||||
|
if (configToMount.authMode === "jwt") {
|
||||||
|
userToken = await signUserToken(configToMount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextConfig = buildWidgetConfig(configToMount, userToken)
|
||||||
|
setConfig(nextConfig)
|
||||||
|
setGeneratedToken(userToken)
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig))
|
||||||
|
|
||||||
|
if (!nextConfig.channelId) {
|
||||||
|
removeMountedWidget()
|
||||||
|
setStatus("请填写 channelId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
injectWidget(nextConfig)
|
||||||
|
setStatus(
|
||||||
|
nextConfig.authMode === "jwt"
|
||||||
|
? "Widget 已挂载:JWT 用户模式"
|
||||||
|
: "Widget 已挂载:访客模式"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
@@ -82,7 +168,11 @@ export function KefuWidgetDemo() {
|
|||||||
setStatus(initialConfig.channelId ? "Widget 已挂载" : "请填写 channelId")
|
setStatus(initialConfig.channelId ? "Widget 已挂载" : "请填写 channelId")
|
||||||
|
|
||||||
if (initialConfig.channelId) {
|
if (initialConfig.channelId) {
|
||||||
injectWidget(initialConfig)
|
void mountWidget(initialConfig).catch((error) => {
|
||||||
|
removeMountedWidget()
|
||||||
|
setGeneratedToken("")
|
||||||
|
setStatus(error instanceof Error ? error.message : "生成 userToken 失败")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
@@ -97,40 +187,34 @@ export function KefuWidgetDemo() {
|
|||||||
? `${origin}/sdk/cs-ai-agent-sdk.min.js`
|
? `${origin}/sdk/cs-ai-agent-sdk.min.js`
|
||||||
: "/sdk/cs-ai-agent-sdk.min.js"
|
: "/sdk/cs-ai-agent-sdk.min.js"
|
||||||
|
|
||||||
|
const configLines = [` channelId: "${config.channelId || ""}"`]
|
||||||
|
if (config.authMode === "jwt") {
|
||||||
|
configLines.push(` userToken: "${generatedToken || "业务系统后端签发的 JWT"}"`)
|
||||||
|
}
|
||||||
|
|
||||||
return `<script>
|
return `<script>
|
||||||
window.CSAgentConfig = {
|
window.CSAgentConfig = {
|
||||||
channelId: "${config.channelId || ""}"
|
${configLines.join(",\n")}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script async src="${scriptSrc}"></script>`
|
<script async src="${scriptSrc}"></script>`
|
||||||
}, [config, origin])
|
}, [config, generatedToken, origin])
|
||||||
|
|
||||||
function updateField<K extends keyof KefuWidgetHostConfig>(
|
function updateField<K extends keyof WidgetDemoConfig>(
|
||||||
key: K,
|
key: K,
|
||||||
value: KefuWidgetHostConfig[K]
|
value: WidgetDemoConfig[K]
|
||||||
) {
|
) {
|
||||||
setConfig((current) => ({ ...current, [key]: value }))
|
setConfig((current) => ({ ...current, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMount() {
|
async function handleMount() {
|
||||||
const nextConfig: KefuWidgetHostConfig = {
|
try {
|
||||||
...config,
|
await mountWidget(config)
|
||||||
channelId: config.channelId.trim(),
|
} catch (error) {
|
||||||
baseUrl: "",
|
|
||||||
apiBaseUrl: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfig(nextConfig)
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig))
|
|
||||||
|
|
||||||
if (!nextConfig.channelId) {
|
|
||||||
removeMountedWidget()
|
removeMountedWidget()
|
||||||
setStatus("请填写 channelId")
|
setGeneratedToken("")
|
||||||
return
|
setStatus(error instanceof Error ? error.message : "生成 userToken 失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
injectWidget(nextConfig)
|
|
||||||
setStatus("Widget 已挂载")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,12 +230,47 @@ export function KefuWidgetDemo() {
|
|||||||
value={config.channelId}
|
value={config.channelId}
|
||||||
onChange={(value) => updateField("channelId", value)}
|
onChange={(value) => updateField("channelId", value)}
|
||||||
/>
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
label="鉴权模式"
|
||||||
|
value={config.authMode || "guest"}
|
||||||
|
onChange={(value) => updateField("authMode", value)}
|
||||||
|
options={[
|
||||||
|
{ label: "访客", value: "guest" },
|
||||||
|
{ label: "JWT 用户", value: "jwt" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{config.authMode === "jwt" ? (
|
||||||
|
<div className="grid gap-3 rounded-md border border-slate-200 p-3">
|
||||||
|
<TextField
|
||||||
|
label="userId"
|
||||||
|
value={config.jwtUserId}
|
||||||
|
onChange={(value) => updateField("jwtUserId", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="name"
|
||||||
|
value={config.jwtName}
|
||||||
|
onChange={(value) => updateField("jwtName", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="JWT Secret"
|
||||||
|
value={config.jwtSecret}
|
||||||
|
onChange={(value) => updateField("jwtSecret", value)}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="有效期(分钟)"
|
||||||
|
value={config.jwtTtlMinutes}
|
||||||
|
onChange={(value) => updateField("jwtTtlMinutes", value)}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex gap-2">
|
<div className="mt-5 flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleMount}
|
onClick={() => void handleMount()}
|
||||||
className="rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white"
|
className="rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white"
|
||||||
>
|
>
|
||||||
挂载
|
挂载
|
||||||
@@ -178,9 +297,24 @@ export function KefuWidgetDemo() {
|
|||||||
|
|
||||||
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<div className="text-base font-semibold">接入代码</div>
|
<div className="text-base font-semibold">接入代码</div>
|
||||||
|
{config.authMode === "jwt" ? (
|
||||||
|
<div className="mt-2 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||||
|
当前页面仅用于本地模拟。正式接入时,userToken 应由业务系统后端签发。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<pre className="mt-4 overflow-x-auto rounded-md bg-slate-950 p-4 text-xs leading-5 text-slate-100">
|
<pre className="mt-4 overflow-x-auto rounded-md bg-slate-950 p-4 text-xs leading-5 text-slate-100">
|
||||||
<code>{snippet}</code>
|
<code>{snippet}</code>
|
||||||
</pre>
|
</pre>
|
||||||
|
{generatedToken ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-sm font-medium text-slate-700">当前 userToken</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={generatedToken}
|
||||||
|
className="mt-2 h-28 w-full resize-none rounded-md border border-slate-200 p-3 font-mono text-xs outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -191,15 +325,18 @@ function TextField({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
type = "text",
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
value?: string
|
value?: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
|
type?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="grid gap-1.5 text-sm">
|
<label className="grid gap-1.5 text-sm">
|
||||||
<span className="font-medium text-slate-700">{label}</span>
|
<span className="font-medium text-slate-700">{label}</span>
|
||||||
<input
|
<input
|
||||||
|
type={type}
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
className="h-9 rounded-md border border-slate-200 px-3 text-sm outline-none focus:border-slate-400"
|
className="h-9 rounded-md border border-slate-200 px-3 text-sm outline-none focus:border-slate-400"
|
||||||
@@ -207,3 +344,37 @@ function TextField({
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SegmentedControl<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: T
|
||||||
|
options: Array<{ label: string; value: T }>
|
||||||
|
onChange: (value: T) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5 text-sm">
|
||||||
|
<div className="font-medium text-slate-700">{label}</div>
|
||||||
|
<div className="grid grid-cols-2 rounded-md border border-slate-200 bg-slate-100 p-1">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
className={
|
||||||
|
option.value === value
|
||||||
|
? "rounded bg-white px-3 py-1.5 text-sm font-medium shadow-sm"
|
||||||
|
: "rounded px-3 py-1.5 text-sm text-slate-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useState } from "react"
|
|||||||
|
|
||||||
import { useAuth } from "@/components/auth-provider"
|
import { useAuth } from "@/components/auth-provider"
|
||||||
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
||||||
|
import { useNotifications } from "@/components/notification-provider"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
@@ -42,7 +44,9 @@ export function NavUser({
|
|||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const { signOut } = useAuth()
|
const { signOut } = useAuth()
|
||||||
|
const { unreadCount } = useNotifications()
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
const router = useRouter()
|
||||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
|
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
|
||||||
const fallback = user.name.slice(0, 1).toUpperCase() || "U"
|
const fallback = user.name.slice(0, 1).toUpperCase() || "U"
|
||||||
return (
|
return (
|
||||||
@@ -99,9 +103,19 @@ export function NavUser({
|
|||||||
<KeyRoundIcon />
|
<KeyRoundIcon />
|
||||||
修改密码
|
修改密码
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
router.push("/dashboard/notifications")
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
通知中心
|
<span className="flex-1">通知中心</span>
|
||||||
|
{unreadCount > 0 ? (
|
||||||
|
<Badge className="h-5 min-w-5 px-1.5">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createNotificationWebSocketUrl,
|
||||||
|
fetchNotificationUnreadCount,
|
||||||
|
markNotificationRead,
|
||||||
|
type NotificationItem,
|
||||||
|
} from "@/lib/api/notification"
|
||||||
|
import { readSession } from "@/lib/auth"
|
||||||
|
import {
|
||||||
|
createRealtimeConnectionManager,
|
||||||
|
type RealtimeConnectionStatus,
|
||||||
|
} from "@/lib/realtime-connection"
|
||||||
|
|
||||||
|
type NotificationRealtimeEnvelope = {
|
||||||
|
eventId?: string
|
||||||
|
type?: string
|
||||||
|
data?: {
|
||||||
|
notification?: NotificationItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationContextValue = {
|
||||||
|
unreadCount: number
|
||||||
|
realtimeStatus: RealtimeConnectionStatus
|
||||||
|
refreshUnreadCount: () => Promise<void>
|
||||||
|
markReadAndNavigate: (notification: NotificationItem) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationContext = createContext<NotificationContextValue | null>(null)
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
const [realtimeStatus, setRealtimeStatus] =
|
||||||
|
useState<RealtimeConnectionStatus>("disconnected")
|
||||||
|
const currentUserIdRef = useRef(readSession()?.user.id ?? 0)
|
||||||
|
|
||||||
|
const refreshUnreadCount = useCallback(async () => {
|
||||||
|
const result = await fetchNotificationUnreadCount()
|
||||||
|
setUnreadCount(result.unreadCount)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const markReadAndNavigate = useCallback(
|
||||||
|
async (notification: NotificationItem) => {
|
||||||
|
if (!notification.readAt) {
|
||||||
|
await markNotificationRead(notification.id)
|
||||||
|
setUnreadCount((current) => Math.max(0, current - 1))
|
||||||
|
}
|
||||||
|
if (notification.actionUrl) {
|
||||||
|
router.push(notification.actionUrl)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentUserIdRef.current = readSession()?.user.id ?? 0
|
||||||
|
void refreshUnreadCount().catch(() => {
|
||||||
|
setUnreadCount(0)
|
||||||
|
})
|
||||||
|
}, [refreshUnreadCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const realtime = createRealtimeConnectionManager({
|
||||||
|
createSocket: () => new WebSocket(createNotificationWebSocketUrl()),
|
||||||
|
canReconnect: () => Boolean(readSession()?.accessToken),
|
||||||
|
onStatusChange: setRealtimeStatus,
|
||||||
|
onOpen: () => {
|
||||||
|
void refreshUnreadCount().catch(() => undefined)
|
||||||
|
},
|
||||||
|
onMessage: (event, socket) => {
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(event.data) as NotificationRealtimeEnvelope
|
||||||
|
const eventType = envelope.type ?? ""
|
||||||
|
const eventId = envelope.eventId?.trim() ?? ""
|
||||||
|
if (
|
||||||
|
eventType === "" ||
|
||||||
|
eventType === "connected" ||
|
||||||
|
eventType === "pong" ||
|
||||||
|
eventType === "subscribed" ||
|
||||||
|
eventType === "unsubscribed"
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (eventId && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: "ack", eventId }))
|
||||||
|
}
|
||||||
|
if (eventType !== "notification.created") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const notification = envelope.data?.notification
|
||||||
|
if (!notification || notification.recipientUserId !== currentUserIdRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUnreadCount((current) => current + 1)
|
||||||
|
toast(notification.title || "新通知", {
|
||||||
|
description: notification.content,
|
||||||
|
action: {
|
||||||
|
label: "查看",
|
||||||
|
onClick: () => {
|
||||||
|
void markReadAndNavigate(notification).catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "打开通知失败")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore invalid realtime payload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConnectError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "连接通知服务失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
realtime.connect()
|
||||||
|
return () => {
|
||||||
|
realtime.disconnect()
|
||||||
|
}
|
||||||
|
}, [markReadAndNavigate, refreshUnreadCount])
|
||||||
|
|
||||||
|
const value = useMemo<NotificationContextValue>(
|
||||||
|
() => ({
|
||||||
|
unreadCount,
|
||||||
|
realtimeStatus,
|
||||||
|
refreshUnreadCount,
|
||||||
|
markReadAndNavigate,
|
||||||
|
}),
|
||||||
|
[markReadAndNavigate, realtimeStatus, refreshUnreadCount, unreadCount]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const context = useContext(NotificationContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useNotifications must be used within NotificationProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { readSession } from "@/lib/auth"
|
||||||
|
import { request } from "@/lib/api/client"
|
||||||
|
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||||
|
import type { PageResult } from "@/lib/api/admin"
|
||||||
|
|
||||||
|
export type NotificationReadStatus = "all" | "unread" | "read"
|
||||||
|
|
||||||
|
export type NotificationItem = {
|
||||||
|
id: number
|
||||||
|
recipientUserId: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
notificationType: string
|
||||||
|
bizType: string
|
||||||
|
bizId: number
|
||||||
|
actionUrl: string
|
||||||
|
readAt?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationUnreadCount = {
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationListQuery = {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
readStatus?: NotificationReadStatus
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||||
|
if (!query) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.set(key, String(value))
|
||||||
|
})
|
||||||
|
const output = params.toString()
|
||||||
|
return output ? `?${output}` : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchNotifications(query?: NotificationListQuery) {
|
||||||
|
return request<PageResult<NotificationItem>>(
|
||||||
|
`/api/dashboard/notification/list${toQueryString(query)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchNotificationUnreadCount() {
|
||||||
|
return request<NotificationUnreadCount>("/api/dashboard/notification/unread_count")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markNotificationRead(id: number) {
|
||||||
|
return request<void>("/api/dashboard/notification/mark_read", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAllNotificationsRead() {
|
||||||
|
return request<void>("/api/dashboard/notification/mark_all_read", {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNotificationWebSocketUrl() {
|
||||||
|
const session = readSession()
|
||||||
|
if (!session?.accessToken) {
|
||||||
|
throw new Error("未登录或登录已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
})
|
||||||
|
return `${createWebSocketBaseUrl()}/api/ws/dashboard/notification?${params.toString()}`
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
"md-editor-rt": "^6.4.1",
|
"md-editor-rt": "^6.4.1",
|
||||||
|
|||||||
Generated
+7
-4
@@ -62,6 +62,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
jose:
|
||||||
|
specifier: ^6.2.3
|
||||||
|
version: 6.2.3
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.577.0
|
specifier: ^0.577.0
|
||||||
version: 0.577.0(react@19.2.3)
|
version: 0.577.0(react@19.2.3)
|
||||||
@@ -2876,8 +2879,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jose@6.2.1:
|
jose@6.2.3:
|
||||||
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
@@ -5065,7 +5068,7 @@ snapshots:
|
|||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
express-rate-limit: 8.3.1(express@5.2.1)
|
express-rate-limit: 8.3.1(express@5.2.1)
|
||||||
hono: 4.12.7
|
hono: 4.12.7
|
||||||
jose: 6.2.1
|
jose: 6.2.3
|
||||||
json-schema-typed: 8.0.2
|
json-schema-typed: 8.0.2
|
||||||
pkce-challenge: 5.0.1
|
pkce-challenge: 5.0.1
|
||||||
raw-body: 3.0.2
|
raw-body: 3.0.2
|
||||||
@@ -7156,7 +7159,7 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
jose@6.2.1: {}
|
jose@6.2.3: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user