增加站内信未读提醒和后台通知中心+txt 校验
This commit is contained in:
@@ -17,3 +17,18 @@ type Notification struct {
|
|||||||
func (Notification) TableName() string {
|
func (Notification) TableName() string {
|
||||||
return "notifications"
|
return "notifications"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminNotification struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
AdminUserID uint64 `gorm:"not null;index" json:"admin_user_id"`
|
||||||
|
Type string `gorm:"size:32;not null" json:"type"`
|
||||||
|
Title string `gorm:"size:128;not null" json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsRead bool `gorm:"not null;default:false" json:"is_read"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AdminNotification) TableName() string {
|
||||||
|
return "admin_notifications"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package adminnotification
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Query struct {
|
||||||
|
AdminUserID uint64
|
||||||
|
OnlyUnread bool
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedResult struct {
|
||||||
|
Items interface{} `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
AdminUserID uint64 `json:"admin_user_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsRead bool `json:"is_read"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnreadCountDTO struct {
|
||||||
|
UnreadCount int64 `json:"unread_count"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package adminnotification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/middleware"
|
||||||
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePagination(c *gin.Context) (int, int) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, pageSize := parsePagination(c)
|
||||||
|
result, err := h.service.List(c.Request.Context(), Query{
|
||||||
|
AdminUserID: adminID,
|
||||||
|
OnlyUnread: c.Query("read") == "unread",
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeNotificationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UnreadCount(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.UnreadCount(c.Request.Context(), adminID)
|
||||||
|
if err != nil {
|
||||||
|
writeNotificationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) MarkRead(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "ID 不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.MarkRead(c.Request.Context(), adminID, id); err != nil {
|
||||||
|
writeNotificationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"read": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) MarkAllRead(c *gin.Context) {
|
||||||
|
adminID, ok := currentAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count, err := h.service.MarkAllRead(c.Request.Context(), adminID)
|
||||||
|
if err != nil {
|
||||||
|
writeNotificationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"read_count": count})
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||||
|
value, ok := c.Get(middleware.ContextAdminID)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
adminID, ok := value.(uint64)
|
||||||
|
return adminID, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeNotificationError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
case errors.Is(err, ErrNotificationNotFound):
|
||||||
|
response.NotFound(c, "通知不存在")
|
||||||
|
default:
|
||||||
|
response.Error(c, http.StatusInternalServerError, "internal_error", "通知服务暂时不可用")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package adminnotification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(db *gorm.DB) *Repository {
|
||||||
|
return &Repository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) List(ctx context.Context, query Query) (*PaginatedResult, error) {
|
||||||
|
db := r.db.WithContext(ctx).Model(&model.AdminNotification{}).
|
||||||
|
Where("admin_user_id = ?", query.AdminUserID)
|
||||||
|
countDB := r.db.WithContext(ctx).Model(&model.AdminNotification{}).
|
||||||
|
Where("admin_user_id = ?", query.AdminUserID)
|
||||||
|
if query.OnlyUnread {
|
||||||
|
db = db.Where("is_read = ?", false)
|
||||||
|
countDB = countDB.Where("is_read = ?", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := countDB.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (query.Page - 1) * query.PageSize
|
||||||
|
var rows []model.AdminNotification
|
||||||
|
if err := db.Order("id DESC").Offset(offset).Limit(query.PageSize).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]NotificationDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, toDTO(row))
|
||||||
|
}
|
||||||
|
return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UnreadCount(ctx context.Context, adminUserID uint64) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.AdminNotification{}).
|
||||||
|
Where("admin_user_id = ? AND is_read = ?", adminUserID, false).
|
||||||
|
Count(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) MarkRead(ctx context.Context, adminUserID uint64, id uint64) error {
|
||||||
|
result := r.db.WithContext(ctx).Model(&model.AdminNotification{}).
|
||||||
|
Where("id = ? AND admin_user_id = ?", id, adminUserID).
|
||||||
|
Update("is_read", true)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.WithContext(ctx).Model(&model.AdminNotification{}).
|
||||||
|
Where("id = ? AND admin_user_id = ?", id, adminUserID).
|
||||||
|
Count(&total).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return ErrNotificationNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) MarkAllRead(ctx context.Context, adminUserID uint64) (int64, error) {
|
||||||
|
result := r.db.WithContext(ctx).Model(&model.AdminNotification{}).
|
||||||
|
Where("admin_user_id = ? AND is_read = ?", adminUserID, false).
|
||||||
|
Update("is_read", true)
|
||||||
|
return result.RowsAffected, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry 描述一条待写入的管理员通知。
|
||||||
|
type Entry struct {
|
||||||
|
AdminUserID uint64
|
||||||
|
Type string
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append 在事务中批量写入管理员通知。
|
||||||
|
// 典型用法:在业务事务内调用 adminnotification.Append(tx, entries...),
|
||||||
|
// 确保通知与业务数据在同一事务中提交或回滚。
|
||||||
|
func Append(tx *gorm.DB, entries ...Entry) error {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.AdminUserID == 0 || entry.Title == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := model.AdminNotification{
|
||||||
|
AdminUserID: entry.AdminUserID,
|
||||||
|
Type: entry.Type,
|
||||||
|
Title: entry.Title,
|
||||||
|
Content: entry.Content,
|
||||||
|
}
|
||||||
|
if row.Type == "" {
|
||||||
|
row.Type = "system"
|
||||||
|
}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDTO(row model.AdminNotification) NotificationDTO {
|
||||||
|
return NotificationDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
AdminUserID: row.AdminUserID,
|
||||||
|
Type: row.Type,
|
||||||
|
Title: row.Title,
|
||||||
|
Content: row.Content,
|
||||||
|
IsRead: row.IsRead,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package adminnotification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/database"
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepositoryUnreadCountAndMarkRead(t *testing.T) {
|
||||||
|
db := database.NewTestDB()
|
||||||
|
if err := db.AutoMigrate(&model.AdminNotification{}); err != nil {
|
||||||
|
t.Fatalf("AutoMigrate() error = %v", err)
|
||||||
|
}
|
||||||
|
repo := NewRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := db.Create(&model.AdminNotification{
|
||||||
|
AdminUserID: 7,
|
||||||
|
Type: "system",
|
||||||
|
Title: "库存预警",
|
||||||
|
Content: "二维码库存不足",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := repo.UnreadCount(ctx, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnreadCount() error = %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("UnreadCount() = %d, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.MarkRead(ctx, 7, 1); err != nil {
|
||||||
|
t.Fatalf("MarkRead() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.MarkRead(ctx, 7, 1); err != nil {
|
||||||
|
t.Fatalf("MarkRead() repeat error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err = repo.UnreadCount(ctx, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnreadCount() after read error = %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("UnreadCount() after read = %d, want 0", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryMarkAllRead(t *testing.T) {
|
||||||
|
db := database.NewTestDB()
|
||||||
|
if err := db.AutoMigrate(&model.AdminNotification{}); err != nil {
|
||||||
|
t.Fatalf("AutoMigrate() error = %v", err)
|
||||||
|
}
|
||||||
|
repo := NewRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows := []model.AdminNotification{
|
||||||
|
{AdminUserID: 7, Type: "system", Title: "一", Content: "一"},
|
||||||
|
{AdminUserID: 7, Type: "system", Title: "二", Content: "二"},
|
||||||
|
{AdminUserID: 8, Type: "system", Title: "三", Content: "三"},
|
||||||
|
}
|
||||||
|
if err := db.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := repo.MarkAllRead(ctx, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarkAllRead() error = %v", err)
|
||||||
|
}
|
||||||
|
if affected != 2 {
|
||||||
|
t.Fatalf("MarkAllRead() affected = %d, want 2", affected)
|
||||||
|
}
|
||||||
|
affected, err = repo.MarkAllRead(ctx, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarkAllRead() repeat error = %v", err)
|
||||||
|
}
|
||||||
|
if affected != 0 {
|
||||||
|
t.Fatalf("MarkAllRead() repeat affected = %d, want 0", affected)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := repo.UnreadCount(ctx, 8)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnreadCount() error = %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("other admin UnreadCount() = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryMarkReadNotFound(t *testing.T) {
|
||||||
|
db := database.NewTestDB()
|
||||||
|
if err := db.AutoMigrate(&model.AdminNotification{}); err != nil {
|
||||||
|
t.Fatalf("AutoMigrate() error = %v", err)
|
||||||
|
}
|
||||||
|
repo := NewRepository(db)
|
||||||
|
|
||||||
|
err := repo.MarkRead(context.Background(), 7, 99)
|
||||||
|
if !errors.Is(err, ErrNotificationNotFound) {
|
||||||
|
t.Fatalf("MarkRead() error = %v, want ErrNotificationNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package adminnotification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
|
var ErrNotificationNotFound = errors.New("admin notification not found")
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo *Repository) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, query Query) (*PaginatedResult, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.List(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UnreadCount(ctx context.Context, adminUserID uint64) (*UnreadCountDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
count, err := s.repo.UnreadCount(ctx, adminUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &UnreadCountDTO{UnreadCount: count}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MarkRead(ctx context.Context, adminUserID uint64, id uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.MarkRead(ctx, adminUserID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MarkAllRead(ctx context.Context, adminUserID uint64) (int64, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return 0, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.MarkAllRead(ctx, adminUserID)
|
||||||
|
}
|
||||||
@@ -20,3 +20,7 @@ type PaginatedResult struct {
|
|||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnreadCountDTO struct {
|
||||||
|
UnreadCount int64 `json:"unread_count"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,34 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UnreadCount(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.UnreadCount(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeNotificationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) MarkAllRead(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count, err := h.service.MarkAllRead(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeNotificationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"read_count": count})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) MarkRead(c *gin.Context) {
|
func (h *Handler) MarkRead(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -79,6 +107,8 @@ func writeNotificationError(c *gin.Context, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
case errors.Is(err, ErrNotificationNotFound):
|
||||||
|
response.NotFound(c, "通知不存在")
|
||||||
default:
|
default:
|
||||||
response.ServiceUnavailable(c, "通知服务暂时不可用")
|
response.ServiceUnavailable(c, "通知服务暂时不可用")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,40 @@ func (r *Repository) List(ctx context.Context, userID uint64, page, pageSize int
|
|||||||
|
|
||||||
func (r *Repository) MarkRead(ctx context.Context, userID uint64, id uint64) error {
|
func (r *Repository) MarkRead(ctx context.Context, userID uint64, id uint64) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return r.db.WithContext(ctx).Model(&model.Notification{}).
|
result := r.db.WithContext(ctx).Model(&model.Notification{}).
|
||||||
Where("id = ? AND user_id = ?", id, userID).
|
Where("id = ? AND user_id = ?", id, userID).
|
||||||
Update("read_at", now).Error
|
Update("read_at", now)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.WithContext(ctx).Model(&model.Notification{}).
|
||||||
|
Where("id = ? AND user_id = ?", id, userID).
|
||||||
|
Count(&total).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return ErrNotificationNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UnreadCount(ctx context.Context, userID uint64) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.Notification{}).
|
||||||
|
Where("user_id = ? AND read_at IS NULL", userID).
|
||||||
|
Count(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) MarkAllRead(ctx context.Context, userID uint64) (int64, error) {
|
||||||
|
now := time.Now()
|
||||||
|
result := r.db.WithContext(ctx).Model(&model.Notification{}).
|
||||||
|
Where("user_id = ? AND read_at IS NULL", userID).
|
||||||
|
Update("read_at", now)
|
||||||
|
return result.RowsAffected, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func Append(tx *gorm.DB, entries ...Entry) error {
|
func Append(tx *gorm.DB, entries ...Entry) error {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/database"
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepositoryUnreadCountAndMarkRead(t *testing.T) {
|
||||||
|
db := database.NewTestDB()
|
||||||
|
if err := db.AutoMigrate(&model.Notification{}); err != nil {
|
||||||
|
t.Fatalf("AutoMigrate() error = %v", err)
|
||||||
|
}
|
||||||
|
repo := NewRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := db.Create(&model.Notification{
|
||||||
|
UserID: 10,
|
||||||
|
Type: "order",
|
||||||
|
Title: "订单通知",
|
||||||
|
Content: "请处理订单",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := repo.UnreadCount(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnreadCount() error = %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("UnreadCount() = %d, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.MarkRead(ctx, 10, 1); err != nil {
|
||||||
|
t.Fatalf("MarkRead() error = %v", err)
|
||||||
|
}
|
||||||
|
// 重复标记应保持幂等,不应误报不存在。
|
||||||
|
if err := repo.MarkRead(ctx, 10, 1); err != nil {
|
||||||
|
t.Fatalf("MarkRead() repeat error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err = repo.UnreadCount(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnreadCount() after read error = %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("UnreadCount() after read = %d, want 0", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryMarkReadNotFound(t *testing.T) {
|
||||||
|
db := database.NewTestDB()
|
||||||
|
if err := db.AutoMigrate(&model.Notification{}); err != nil {
|
||||||
|
t.Fatalf("AutoMigrate() error = %v", err)
|
||||||
|
}
|
||||||
|
repo := NewRepository(db)
|
||||||
|
|
||||||
|
err := repo.MarkRead(context.Background(), 10, 99)
|
||||||
|
if !errors.Is(err, ErrNotificationNotFound) {
|
||||||
|
t.Fatalf("MarkRead() error = %v, want ErrNotificationNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var ErrDependencyUnavailable = errors.New("dependency unavailable")
|
var ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
|
var ErrNotificationNotFound = errors.New("notification not found")
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *Repository
|
repo *Repository
|
||||||
@@ -28,3 +29,21 @@ func (s *Service) MarkRead(ctx context.Context, userID uint64, id uint64) error
|
|||||||
}
|
}
|
||||||
return s.repo.MarkRead(ctx, userID, id)
|
return s.repo.MarkRead(ctx, userID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) UnreadCount(ctx context.Context, userID uint64) (*UnreadCountDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
count, err := s.repo.UnreadCount(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &UnreadCountDTO{UnreadCount: count}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MarkAllRead(ctx context.Context, userID uint64) (int64, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return 0, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.MarkAllRead(ctx, userID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/admindashboard"
|
"hfb_sys/backend/internal/modules/admindashboard"
|
||||||
"hfb_sys/backend/internal/modules/adminfinance"
|
"hfb_sys/backend/internal/modules/adminfinance"
|
||||||
"hfb_sys/backend/internal/modules/adminmgr"
|
"hfb_sys/backend/internal/modules/adminmgr"
|
||||||
|
"hfb_sys/backend/internal/modules/adminnotification"
|
||||||
"hfb_sys/backend/internal/modules/adminrole"
|
"hfb_sys/backend/internal/modules/adminrole"
|
||||||
"hfb_sys/backend/internal/modules/adminuser"
|
"hfb_sys/backend/internal/modules/adminuser"
|
||||||
"hfb_sys/backend/internal/modules/announcement"
|
"hfb_sys/backend/internal/modules/announcement"
|
||||||
@@ -145,6 +146,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
adminAuditService := adminaudit.NewService(adminAuditRepo)
|
adminAuditService := adminaudit.NewService(adminAuditRepo)
|
||||||
adminAuditHandler := adminaudit.NewHandler(adminAuditService)
|
adminAuditHandler := adminaudit.NewHandler(adminAuditService)
|
||||||
|
var adminNotificationRepo *adminnotification.Repository
|
||||||
|
if deps.DB != nil {
|
||||||
|
adminNotificationRepo = adminnotification.NewRepository(deps.DB)
|
||||||
|
}
|
||||||
|
adminNotificationService := adminnotification.NewService(adminNotificationRepo)
|
||||||
|
adminNotificationHandler := adminnotification.NewHandler(adminNotificationService)
|
||||||
userHandler := user.NewHandler(userRepo)
|
userHandler := user.NewHandler(userRepo)
|
||||||
var realnameRepo *realname.Repository
|
var realnameRepo *realname.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
@@ -447,6 +454,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
notificationRoutes := api.Group("/notifications", requireAuth)
|
notificationRoutes := api.Group("/notifications", requireAuth)
|
||||||
{
|
{
|
||||||
notificationRoutes.GET("", notificationHandler.List)
|
notificationRoutes.GET("", notificationHandler.List)
|
||||||
|
notificationRoutes.GET("/unread-count", notificationHandler.UnreadCount)
|
||||||
|
notificationRoutes.PUT("/read-all", notificationHandler.MarkAllRead)
|
||||||
notificationRoutes.POST("/:id/read", notificationHandler.MarkRead)
|
notificationRoutes.POST("/:id/read", notificationHandler.MarkRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +551,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
|
|
||||||
adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List)
|
adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List)
|
||||||
adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update)
|
adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update)
|
||||||
|
adminRoutes.GET("/notifications", requirePerm("notification:view"), adminNotificationHandler.List)
|
||||||
|
adminRoutes.GET("/notifications/unread-count", requirePerm("notification:view"), adminNotificationHandler.UnreadCount)
|
||||||
|
adminRoutes.PUT("/notifications/read-all", requirePerm("notification:view"), adminNotificationHandler.MarkAllRead)
|
||||||
|
adminRoutes.POST("/notifications/:id/read", requirePerm("notification:view"), adminNotificationHandler.MarkRead)
|
||||||
adminRoutes.GET("/audit-logs", requirePerm("audit_log:view"), adminAuditHandler.List)
|
adminRoutes.GET("/audit-logs", requirePerm("audit_log:view"), adminAuditHandler.List)
|
||||||
if chatHubHandler != nil {
|
if chatHubHandler != nil {
|
||||||
adminRoutes.GET("/chats/events", requirePerm("chat:view"), chatHubHandler.AdminEvents)
|
adminRoutes.GET("/chats/events", requirePerm("chat:view"), chatHubHandler.AdminEvents)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
INSERT INTO permissions (code, name, resource, action) VALUES
|
||||||
|
('notification:view', '查看通知', 'notification', 'view')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
resource = VALUES(resource),
|
||||||
|
action = VALUES(action);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.code = 'notification:view'
|
||||||
|
WHERE r.code IN ('super_admin', 'cs', 'ops');
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
DELETE rp FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE p.code = 'notification:view';
|
||||||
|
|
||||||
|
DELETE FROM permissions WHERE code = 'notification:view';
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
05b41285dbac2f6dd146495e46ef3e29360cb820
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
|
||||||
|
|
||||||
|
export type AdminNotificationType = 'system' | 'chat' | (string & {})
|
||||||
|
|
||||||
|
export interface AdminNotification {
|
||||||
|
id: number
|
||||||
|
admin_user_id: number
|
||||||
|
type: AdminNotificationType
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
is_read: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminNotificationQuery {
|
||||||
|
read?: 'unread'
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminNotifications(query: AdminNotificationQuery = {}) {
|
||||||
|
const params = Object.fromEntries(
|
||||||
|
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
|
||||||
|
)
|
||||||
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminNotification>>>(
|
||||||
|
'/admin/notifications',
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminNotificationUnreadCount() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<{ unread_count: number }>>(
|
||||||
|
'/admin/notifications/unread-count',
|
||||||
|
{
|
||||||
|
silent: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data.data.unread_count
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAdminNotificationRead(id: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(
|
||||||
|
`/admin/notifications/${id}/read`
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllAdminNotificationsRead() {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<{ read_count: number }>>(
|
||||||
|
'/admin/notifications/read-all'
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
|
|
||||||
|
import { fetchAdminNotificationUnreadCount } from '@/features/admin/api/adminNotifications'
|
||||||
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
|
|
||||||
|
export const adminNotificationUnreadChangedEvent = 'admin-notification-unread-changed'
|
||||||
|
|
||||||
|
export function useAdminNotificationUnreadCount(route: RouteLocationNormalizedLoaded) {
|
||||||
|
const adminSession = useAdminSessionStore()
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
let timer: number | null = null
|
||||||
|
let requestID = 0
|
||||||
|
|
||||||
|
const unreadLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)))
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (timer) {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUnreadCount() {
|
||||||
|
if (!adminSession.hasSessionHint) {
|
||||||
|
unreadCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentID = ++requestID
|
||||||
|
try {
|
||||||
|
const count = await fetchAdminNotificationUnreadCount()
|
||||||
|
if (currentID === requestID) {
|
||||||
|
unreadCount.value = count
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 后台角标静默失败,避免干扰当前操作。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
void loadUnreadCount()
|
||||||
|
timer = window.setInterval(loadUnreadCount, 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumePollingIfVisible() {
|
||||||
|
if (document.hidden || !adminSession.hasSessionHint) return
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resumePollingIfVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnreadChanged() {
|
||||||
|
void loadUnreadCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(adminNotificationUnreadChangedEvent, handleUnreadChanged)
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
resumePollingIfVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopPolling()
|
||||||
|
window.removeEventListener(adminNotificationUnreadChangedEvent, handleUnreadChanged)
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => adminSession.hasSessionHint,
|
||||||
|
hasSession => {
|
||||||
|
if (hasSession) {
|
||||||
|
resumePollingIfVisible()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopPolling()
|
||||||
|
unreadCount.value = 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
void loadUnreadCount()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
unreadCount,
|
||||||
|
unreadLabel,
|
||||||
|
refreshUnreadCount: loadUnreadCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export * from './api/adminWallet'
|
|||||||
export * from './api/adminPayments'
|
export * from './api/adminPayments'
|
||||||
export * from './api/adminFinance'
|
export * from './api/adminFinance'
|
||||||
export * from './api/adminAudit'
|
export * from './api/adminAudit'
|
||||||
|
export * from './api/adminNotifications'
|
||||||
export * from './api/systemConfigs'
|
export * from './api/systemConfigs'
|
||||||
export * from './api/supportGroups'
|
export * from './api/supportGroups'
|
||||||
export * from './composables/useAdminTable'
|
export * from './composables/useAdminTable'
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Bell, Check, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchAdminNotifications,
|
||||||
|
markAdminNotificationRead,
|
||||||
|
markAllAdminNotificationsRead,
|
||||||
|
type AdminNotification,
|
||||||
|
} from '@/features/admin/api/adminNotifications'
|
||||||
|
import { adminNotificationUnreadChangedEvent } from '@/features/admin/composables/useAdminNotificationUnreadCount'
|
||||||
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const notifications = ref<AdminNotification[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
const readFilter = ref<'all' | 'unread'>('all')
|
||||||
|
|
||||||
|
const unreadInPage = computed(() => notifications.value.filter(item => !item.is_read).length)
|
||||||
|
|
||||||
|
onMounted(loadNotifications)
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminNotifications({
|
||||||
|
read: readFilter.value === 'unread' ? 'unread' : undefined,
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: currentPageSize.value,
|
||||||
|
})
|
||||||
|
notifications.value = result.items
|
||||||
|
total.value = result.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFilterChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
await loadNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkRead(row: AdminNotification) {
|
||||||
|
if (row.is_read) return
|
||||||
|
await markAdminNotificationRead(row.id)
|
||||||
|
row.is_read = true
|
||||||
|
window.dispatchEvent(new Event(adminNotificationUnreadChangedEvent))
|
||||||
|
ElMessage.success('已标记为已读')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkAllRead() {
|
||||||
|
if (submitting.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const result = await markAllAdminNotificationsRead()
|
||||||
|
window.dispatchEvent(new Event(adminNotificationUnreadChangedEvent))
|
||||||
|
ElMessage.success(result.read_count > 0 ? `已标记 ${result.read_count} 条通知` : '暂无未读通知')
|
||||||
|
await loadNotifications()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
system: '系统',
|
||||||
|
chat: '聊天',
|
||||||
|
}
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<div class="page-header-row">
|
||||||
|
<div class="page-header">
|
||||||
|
<p class="eyebrow">Notifications</p>
|
||||||
|
<h1>通知中心</h1>
|
||||||
|
<p>查看库存预警、客服协作等后台提醒。</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="loadNotifications">刷新</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:icon="Check"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="unreadInPage === 0 && readFilter === 'unread'"
|
||||||
|
@click="handleMarkAllRead"
|
||||||
|
>
|
||||||
|
全部已读
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-segmented
|
||||||
|
v-model="readFilter"
|
||||||
|
class="notification-filter"
|
||||||
|
:options="[
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '未读', value: 'unread' },
|
||||||
|
]"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" class="table-panel" :data="notifications">
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_read ? 'info' : 'danger'" effect="plain">
|
||||||
|
{{ row.is_read ? '已读' : '未读' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag type="primary" effect="plain">{{ typeLabel(row.type) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="内容" min-width="420">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="notification-content" :class="{ unread: !row.is_read }">
|
||||||
|
<el-icon><Bell /></el-icon>
|
||||||
|
<div>
|
||||||
|
<strong>{{ row.title }}</strong>
|
||||||
|
<p>{{ row.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="!row.is_read" size="small" type="primary" @click="handleMarkRead(row)">
|
||||||
|
已读
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="muted-text">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<AdminTablePagination
|
||||||
|
v-if="total > 0"
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:loading="loading"
|
||||||
|
@page-change="loadNotifications"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-filter {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
color: #5b6575;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content.unread {
|
||||||
|
color: #17233d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content .el-icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-text {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -28,3 +28,20 @@ export async function markNotificationRead(id: number) {
|
|||||||
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/notifications/${id}/read`)
|
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/notifications/${id}/read`)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchUnreadNotificationCount() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<{ unread_count: number }>>(
|
||||||
|
'/notifications/unread-count',
|
||||||
|
{
|
||||||
|
silent: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data.data.unread_count
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead() {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<{ read_count: number }>>(
|
||||||
|
'/notifications/read-all'
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
|
|
||||||
|
import { fetchUnreadNotificationCount } from '@/features/auth/api/notifications'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
|
export const notificationUnreadChangedEvent = 'notification-unread-changed'
|
||||||
|
|
||||||
|
export function useNotificationUnreadCount(route: RouteLocationNormalizedLoaded) {
|
||||||
|
const session = useSessionStore()
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
let timer: number | null = null
|
||||||
|
let requestID = 0
|
||||||
|
|
||||||
|
const unreadLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)))
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (timer) {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUnreadCount() {
|
||||||
|
if (!session.isLoggedIn) {
|
||||||
|
unreadCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentID = ++requestID
|
||||||
|
try {
|
||||||
|
const count = await fetchUnreadNotificationCount()
|
||||||
|
if (currentID === requestID) {
|
||||||
|
unreadCount.value = count
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 角标静默失败,避免影响主流程。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
void loadUnreadCount()
|
||||||
|
timer = window.setInterval(loadUnreadCount, 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumePollingIfVisible() {
|
||||||
|
if (document.hidden || !session.isLoggedIn) return
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resumePollingIfVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnreadChanged() {
|
||||||
|
void loadUnreadCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(notificationUnreadChangedEvent, handleUnreadChanged)
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
resumePollingIfVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopPolling()
|
||||||
|
window.removeEventListener(notificationUnreadChangedEvent, handleUnreadChanged)
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => session.isLoggedIn,
|
||||||
|
loggedIn => {
|
||||||
|
if (loggedIn) {
|
||||||
|
resumePollingIfVisible()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopPolling()
|
||||||
|
unreadCount.value = 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
void loadUnreadCount()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
unreadCount,
|
||||||
|
unreadLabel,
|
||||||
|
refreshUnreadCount: loadUnreadCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
export * from './api/auth'
|
export * from './api/auth'
|
||||||
export * from './api/realname'
|
export * from './api/realname'
|
||||||
export * from './api/notifications'
|
export * from './api/notifications'
|
||||||
|
export * from './composables/useNotificationUnreadCount'
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
|
||||||
|
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
markNotificationRead,
|
||||||
|
type NotificationItem,
|
||||||
|
} from '@/features/auth/api/notifications'
|
||||||
|
import { notificationUnreadChangedEvent } from '@/features/auth/composables/useNotificationUnreadCount'
|
||||||
|
import { formatDateMinute } from '@/shared/utils/time'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const notifications = ref<NotificationItem[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const hasMore = computed(() => notifications.value.length < total.value)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadNotifications(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadNotifications(reset = false) {
|
||||||
|
if ((reset && loading.value) || (!reset && loadingMore.value)) return
|
||||||
|
if (reset) {
|
||||||
|
currentPage.value = 1
|
||||||
|
loading.value = true
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await fetchNotifications(currentPage.value, pageSize)
|
||||||
|
notifications.value = reset ? result.items : [...notifications.value, ...result.items]
|
||||||
|
total.value = result.total
|
||||||
|
if (notifications.value.length < result.total && result.items.length > 0) {
|
||||||
|
currentPage.value += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast({ message: '站内信加载失败', icon: 'cross' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(item: NotificationItem) {
|
||||||
|
if (item.read_at) return
|
||||||
|
try {
|
||||||
|
await markNotificationRead(item.id)
|
||||||
|
item.read_at = new Date().toISOString()
|
||||||
|
window.dispatchEvent(new Event(notificationUnreadChangedEvent))
|
||||||
|
showToast({ message: '已标记为已读', icon: 'passed' })
|
||||||
|
} catch {
|
||||||
|
showToast({ message: '操作失败', icon: 'cross' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBiz(item: NotificationItem) {
|
||||||
|
if (!item.biz_id) return
|
||||||
|
if (item.biz_type === 'order') {
|
||||||
|
router.push(`/m/orders/${item.biz_id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.biz_type === 'listing') {
|
||||||
|
router.push('/m/seller/listings')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
order: '订单',
|
||||||
|
handoff: '交接',
|
||||||
|
checkout: '结账',
|
||||||
|
settlement: '结算',
|
||||||
|
dispute: '申诉',
|
||||||
|
arbitration: '仲裁',
|
||||||
|
timeout: '超时',
|
||||||
|
listing_review: '审核',
|
||||||
|
listing_admin: '商品',
|
||||||
|
order_admin: '客服',
|
||||||
|
}
|
||||||
|
return map[type] || '系统'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="mobile-notifications">
|
||||||
|
<header class="mobile-header">
|
||||||
|
<button type="button" class="header-back" @click="router.back()">
|
||||||
|
<van-icon name="arrow-left" :size="20" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1>站内信</h1>
|
||||||
|
<p>{{ total > 0 ? `共 ${total} 条通知` : '订单、审核和申诉消息会出现在这里' }}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<van-pull-refresh v-model="refreshing" @refresh="loadNotifications(true)">
|
||||||
|
<section class="notification-list">
|
||||||
|
<van-empty v-if="!loading && notifications.length === 0" description="暂无站内信" />
|
||||||
|
<article
|
||||||
|
v-for="item in notifications"
|
||||||
|
:key="item.id"
|
||||||
|
class="notification-item"
|
||||||
|
:class="{ unread: !item.read_at }"
|
||||||
|
>
|
||||||
|
<div class="item-head">
|
||||||
|
<span class="type-chip">{{ typeLabel(item.type) }}</span>
|
||||||
|
<small>{{ formatDateMinute(item.created_at) }}</small>
|
||||||
|
</div>
|
||||||
|
<h2>{{ item.title }}</h2>
|
||||||
|
<p>{{ item.content }}</p>
|
||||||
|
<div class="item-actions">
|
||||||
|
<van-button
|
||||||
|
v-if="item.biz_id && (item.biz_type === 'order' || item.biz_type === 'listing')"
|
||||||
|
size="small"
|
||||||
|
plain
|
||||||
|
round
|
||||||
|
type="primary"
|
||||||
|
@click="openBiz(item)"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</van-button>
|
||||||
|
<van-button v-if="!item.read_at" size="small" round type="primary" @click="markRead(item)">
|
||||||
|
已读
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<van-button
|
||||||
|
v-if="hasMore"
|
||||||
|
block
|
||||||
|
plain
|
||||||
|
round
|
||||||
|
class="load-more"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="loadNotifications(false)"
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</van-button>
|
||||||
|
</section>
|
||||||
|
</van-pull-refresh>
|
||||||
|
|
||||||
|
<MobileBottomNav />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-notifications {
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 0 0 calc(64px + env(safe-area-inset-bottom));
|
||||||
|
background: #f6f8fa;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-back {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #eef1f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread {
|
||||||
|
border-color: rgba(255, 106, 0, 0.35);
|
||||||
|
background: #fffaf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-head small {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item h2 {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { showDialog, showToast } from 'vant'
|
import { showDialog, showToast } from 'vant'
|
||||||
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
||||||
|
import { useNotificationUnreadCount } from '@/features/auth/composables/useNotificationUnreadCount'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchWalletBalance,
|
fetchWalletBalance,
|
||||||
@@ -19,6 +20,8 @@ import { formatCent } from '@/shared/utils/money'
|
|||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { unreadCount: notificationUnreadCount, unreadLabel: notificationUnreadLabel } =
|
||||||
|
useNotificationUnreadCount(route)
|
||||||
|
|
||||||
/** 数据项 */
|
/** 数据项 */
|
||||||
const availableBalanceCent = ref(0)
|
const availableBalanceCent = ref(0)
|
||||||
@@ -375,6 +378,13 @@ function resolveAvatarURL(url: string | undefined | null) {
|
|||||||
<div class="menu-card cell-card">
|
<div class="menu-card cell-card">
|
||||||
<van-cell-group :border="false">
|
<van-cell-group :border="false">
|
||||||
<van-cell title="消息中心" icon="chat-o" is-link to="/m/messages" />
|
<van-cell title="消息中心" icon="chat-o" is-link to="/m/messages" />
|
||||||
|
<van-cell title="站内信" icon="bell" is-link to="/m/notifications">
|
||||||
|
<template #value>
|
||||||
|
<span v-if="notificationUnreadCount > 0" class="unread-cell-value">
|
||||||
|
{{ notificationUnreadLabel }} 未读
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
<van-cell title="公告中心" icon="volume-o" is-link to="/m/announcements" />
|
<van-cell title="公告中心" icon="volume-o" is-link to="/m/announcements" />
|
||||||
<van-cell title="资料修改" icon="edit" is-link @click="openProfileEditor" />
|
<van-cell title="资料修改" icon="edit" is-link @click="openProfileEditor" />
|
||||||
<van-cell title="实名认证" icon="idcard" is-link to="/m/realname">
|
<van-cell title="实名认证" icon="idcard" is-link to="/m/realname">
|
||||||
@@ -874,6 +884,10 @@ function resolveAvatarURL(url: string | undefined | null) {
|
|||||||
.unverified-color {
|
.unverified-color {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
.unread-cell-value {
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* 收支明细 Popup */
|
/* 收支明细 Popup */
|
||||||
.ledgers-popup {
|
.ledgers-popup {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
type NotificationItem,
|
type NotificationItem,
|
||||||
} from '@/features/auth/api/notifications'
|
} from '@/features/auth/api/notifications'
|
||||||
|
import { notificationUnreadChangedEvent } from '@/features/auth/composables/useNotificationUnreadCount'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -35,6 +36,7 @@ function handleSizeChange() {
|
|||||||
|
|
||||||
async function markRead(id: number) {
|
async function markRead(id: number) {
|
||||||
await markNotificationRead(id)
|
await markNotificationRead(id)
|
||||||
|
window.dispatchEvent(new Event(notificationUnreadChangedEvent))
|
||||||
ElMessage.success('已标记为已读')
|
ElMessage.success('已标记为已读')
|
||||||
await loadNotifications()
|
await loadNotifications()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Refresh, Tickets } from '@element-plus/icons-vue'
|
import { Bell, ChatDotRound, Refresh, Tickets } from '@element-plus/icons-vue'
|
||||||
import { fetchChats, type ChatConversation } from '@/features/chats/api/chats'
|
import { fetchChats, type ChatConversation } from '@/features/chats/api/chats'
|
||||||
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
||||||
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
||||||
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
|
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
|
||||||
import { formatDateMinute } from '@/shared/utils/time'
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead,
|
||||||
|
type NotificationItem,
|
||||||
|
} from '@/features/auth/api/notifications'
|
||||||
|
import {
|
||||||
|
notificationUnreadChangedEvent,
|
||||||
|
useNotificationUnreadCount,
|
||||||
|
} from '@/features/auth/composables/useNotificationUnreadCount'
|
||||||
|
import { formatDateMinute, formatDateTime } from '@/shared/utils/time'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
||||||
|
|
||||||
|
// ── 聊天状态 ──
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const conversations = ref<ChatConversation[]>([])
|
const conversations = ref<ChatConversation[]>([])
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
@@ -22,6 +35,21 @@ const unreadTotal = computed(() =>
|
|||||||
conversations.value.reduce((sum, item) => sum + item.unread_count, 0)
|
conversations.value.reduce((sum, item) => sum + item.unread_count, 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── 通知状态 ──
|
||||||
|
const notiLoading = ref(false)
|
||||||
|
const notiSubmitting = ref(false)
|
||||||
|
const notifications = ref<NotificationItem[]>([])
|
||||||
|
const notiPage = ref(1)
|
||||||
|
const notiPageSize = 20
|
||||||
|
const notiTotal = ref(0)
|
||||||
|
const { unreadCount: notificationUnreadCount } = useNotificationUnreadCount(route)
|
||||||
|
|
||||||
|
// ── 合并未读 ──
|
||||||
|
const combinedUnread = computed(() => unreadTotal.value + notificationUnreadCount.value)
|
||||||
|
|
||||||
|
// ── Tab ──
|
||||||
|
const activeTab = ref('chats')
|
||||||
|
|
||||||
const desktopNotification = useDesktopNotification('user')
|
const desktopNotification = useDesktopNotification('user')
|
||||||
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
||||||
onEvent(handleSSEEvent)
|
onEvent(handleSSEEvent)
|
||||||
@@ -31,6 +59,7 @@ onMounted(() => {
|
|||||||
loadChats(true)
|
loadChats(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 聊天方法 ──
|
||||||
async function loadChats(isRefresh = false, showLoading = true) {
|
async function loadChats(isRefresh = false, showLoading = true) {
|
||||||
if (isRefresh) page.value = 1
|
if (isRefresh) page.value = 1
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
@@ -75,7 +104,6 @@ function roleLabel(role: string) {
|
|||||||
return map[role] || '成员'
|
return map[role] || '成员'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按会话类型返回图标前缀与标签:发布群 / 订单群 / 平台客服
|
|
||||||
function conversationTypeMeta(item: ChatConversation) {
|
function conversationTypeMeta(item: ChatConversation) {
|
||||||
if (item.type === 'listing_group') return { icon: '📢', label: '账号群' }
|
if (item.type === 'listing_group') return { icon: '📢', label: '账号群' }
|
||||||
if (item.type === 'order_group' || item.order_id) return { icon: '📦', label: '订单' }
|
if (item.type === 'order_group' || item.order_id) return { icon: '📦', label: '订单' }
|
||||||
@@ -88,6 +116,63 @@ function previewText(item: ChatConversation) {
|
|||||||
(item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
(item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 通知方法 ──
|
||||||
|
async function loadNotifications(reset = false) {
|
||||||
|
if (notiLoading.value) return
|
||||||
|
if (reset) notiPage.value = 1
|
||||||
|
notiLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchNotifications(notiPage.value, notiPageSize)
|
||||||
|
notifications.value = reset ? result.items : [...notifications.value, ...result.items]
|
||||||
|
notiTotal.value = result.total
|
||||||
|
if (notifications.value.length < result.total && result.items.length > 0) {
|
||||||
|
notiPage.value += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('站内信加载失败')
|
||||||
|
} finally {
|
||||||
|
notiLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markNotiRead(item: NotificationItem) {
|
||||||
|
if (item.read_at) return
|
||||||
|
try {
|
||||||
|
await markNotificationRead(item.id)
|
||||||
|
item.read_at = new Date().toISOString()
|
||||||
|
window.dispatchEvent(new Event(notificationUnreadChangedEvent))
|
||||||
|
ElMessage.success('已标记为已读')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllNotiRead() {
|
||||||
|
if (notiSubmitting.value) return
|
||||||
|
notiSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const result = await markAllNotificationsRead()
|
||||||
|
window.dispatchEvent(new Event(notificationUnreadChangedEvent))
|
||||||
|
ElMessage.success(result.read_count > 0 ? `已标记 ${result.read_count} 条通知` : '暂无未读通知')
|
||||||
|
await loadNotifications(true)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('操作失败')
|
||||||
|
} finally {
|
||||||
|
notiSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notiHasMore() {
|
||||||
|
return notifications.value.length < notiTotal.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab 切换 ──
|
||||||
|
function handleTabChange(tab: string) {
|
||||||
|
if (tab === 'notifications' && notifications.value.length === 0) {
|
||||||
|
loadNotifications(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,7 +181,13 @@ function previewText(item: ChatConversation) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Messages</p>
|
<p class="eyebrow">Messages</p>
|
||||||
<h1>消息</h1>
|
<h1>消息</h1>
|
||||||
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
|
<p>
|
||||||
|
{{
|
||||||
|
combinedUnread > 0
|
||||||
|
? `${combinedUnread} 条未读`
|
||||||
|
: '查看订单群聊、平台客服消息和系统通知。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<NotificationSettings scope="user" />
|
<NotificationSettings scope="user" />
|
||||||
@@ -107,6 +198,17 @@ function previewText(item: ChatConversation) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="messages-tabs" @tab-change="handleTabChange">
|
||||||
|
<!-- 聊天 Tab -->
|
||||||
|
<el-tab-pane name="chats">
|
||||||
|
<template #label>
|
||||||
|
<span class="tab-label">
|
||||||
|
<el-icon><ChatDotRound /></el-icon>
|
||||||
|
聊天
|
||||||
|
<em v-if="unreadTotal > 0" class="tab-badge">{{ unreadTotal > 99 ? '99+' : unreadTotal }}</em>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
|
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
|
||||||
|
|
||||||
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
|
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
|
||||||
@@ -154,6 +256,72 @@ function previewText(item: ChatConversation) {
|
|||||||
<div v-if="hasMore && conversations.length > 0" class="pagination-wrap">
|
<div v-if="hasMore && conversations.length > 0" class="pagination-wrap">
|
||||||
<el-button :loading="loading" @click="loadChats(false)">加载更多</el-button>
|
<el-button :loading="loading" @click="loadChats(false)">加载更多</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 通知 Tab -->
|
||||||
|
<el-tab-pane name="notifications">
|
||||||
|
<template #label>
|
||||||
|
<span class="tab-label">
|
||||||
|
<el-icon><Bell /></el-icon>
|
||||||
|
通知
|
||||||
|
<em v-if="notificationUnreadCount > 0" class="tab-badge">{{ notificationUnreadCount > 99 ? '99+' : notificationUnreadCount }}</em>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="noti-toolbar">
|
||||||
|
<span v-if="notificationUnreadCount > 0" class="noti-toolbar-text">
|
||||||
|
{{ notificationUnreadCount }} 条未读
|
||||||
|
</span>
|
||||||
|
<span v-else class="noti-toolbar-text">暂无未读通知</span>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="notiSubmitting"
|
||||||
|
:disabled="notificationUnreadCount === 0"
|
||||||
|
@click="markAllNotiRead"
|
||||||
|
>全部已读</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="notiLoading && notifications.length === 0" class="message-loading" v-loading="notiLoading" />
|
||||||
|
|
||||||
|
<div v-else-if="!notiLoading && notifications.length === 0" class="empty-panel">
|
||||||
|
<el-empty description="暂无站内信" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else v-loading="notiLoading" class="notification-list">
|
||||||
|
<div
|
||||||
|
v-for="item in notifications"
|
||||||
|
:key="item.id"
|
||||||
|
class="notification-item"
|
||||||
|
:class="{ unread: !item.read_at }"
|
||||||
|
>
|
||||||
|
<div class="notification-body">
|
||||||
|
<span class="noti-type-chip">{{ item.type }}</span>
|
||||||
|
<h2>{{ item.title }}</h2>
|
||||||
|
<p>{{ item.content }}</p>
|
||||||
|
<small>{{ formatDateTime(item.created_at) }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="notification-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="item.biz_type === 'order' && item.biz_id"
|
||||||
|
size="small"
|
||||||
|
@click="router.push(`/orders/${item.biz_id}`)"
|
||||||
|
>查看订单</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!item.read_at"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click="markNotiRead(item)"
|
||||||
|
>已读</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="notiHasMore()" class="pagination-wrap">
|
||||||
|
<el-button :loading="notiLoading" @click="loadNotifications(false)">加载更多</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -173,6 +341,35 @@ function previewText(item: ChatConversation) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ── */
|
||||||
|
.messages-tabs {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 通用 ── */
|
||||||
.message-loading,
|
.message-loading,
|
||||||
.empty-panel {
|
.empty-panel {
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
@@ -186,6 +383,26 @@ function previewText(item: ChatConversation) {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 通知工具栏 ── */
|
||||||
|
.noti-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noti-toolbar-text {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 聊天列表 ── */
|
||||||
.conversation-list {
|
.conversation-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -329,8 +546,77 @@ function previewText(item: ChatConversation) {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-wrap {
|
/* ── 通知列表 ── */
|
||||||
|
.notification-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #e8edf3;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:hover {
|
||||||
|
border-color: #1477ff;
|
||||||
|
box-shadow: 0 4px 16px rgba(20, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread {
|
||||||
|
border-color: rgba(255, 106, 0, 0.35);
|
||||||
|
background: #fffaf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noti-type-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef6ff;
|
||||||
|
color: #1477ff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-body h2 {
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-body p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-body small {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Fold,
|
Fold,
|
||||||
House,
|
House,
|
||||||
Lock,
|
Lock,
|
||||||
|
Message,
|
||||||
Money,
|
Money,
|
||||||
Operation,
|
Operation,
|
||||||
Picture,
|
Picture,
|
||||||
@@ -32,6 +33,7 @@ import { computed, reactive, ref } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { changeAdminPassword, logoutAdmin, updateSupportStatus } from '@/features/admin'
|
import { changeAdminPassword, logoutAdmin, updateSupportStatus } from '@/features/admin'
|
||||||
|
import { useAdminNotificationUnreadCount } from '@/features/admin/composables/useAdminNotificationUnreadCount'
|
||||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
import { adminPath, ADMIN_DASHBOARD_PATH, ADMIN_LOGIN_PATH } from '@/shared/utils/adminPath'
|
import { adminPath, ADMIN_DASHBOARD_PATH, ADMIN_LOGIN_PATH } from '@/shared/utils/adminPath'
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
@@ -155,6 +157,12 @@ const allNavGroups: NavGroup[] = [
|
|||||||
icon: Bell,
|
icon: Bell,
|
||||||
permission: 'announcement:view',
|
permission: 'announcement:view',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '通知中心',
|
||||||
|
to: adminPath('notifications'),
|
||||||
|
icon: Message,
|
||||||
|
permission: 'notification:view',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '系统配置',
|
label: '系统配置',
|
||||||
to: adminPath('system-configs'),
|
to: adminPath('system-configs'),
|
||||||
@@ -228,6 +236,13 @@ const adminRoleText = computed(() => {
|
|||||||
})
|
})
|
||||||
const supportStatus = computed(() => adminSession.supportStatus || 'offline')
|
const supportStatus = computed(() => adminSession.supportStatus || 'offline')
|
||||||
const hasChatPermission = computed(() => adminSession.hasPermission('chat:view'))
|
const hasChatPermission = computed(() => adminSession.hasPermission('chat:view'))
|
||||||
|
const hasNotificationPermission = computed(
|
||||||
|
() => adminSession.isSuperAdmin || adminSession.hasPermission('notification:view')
|
||||||
|
)
|
||||||
|
const {
|
||||||
|
unreadCount: adminNotificationUnreadCount,
|
||||||
|
unreadLabel: adminNotificationUnreadLabel,
|
||||||
|
} = useAdminNotificationUnreadCount(route)
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
online: '在线',
|
online: '在线',
|
||||||
@@ -341,6 +356,19 @@ async function handleForcedPasswordChange() {
|
|||||||
</el-menu>
|
</el-menu>
|
||||||
|
|
||||||
<div class="admin-sidebar-footer">
|
<div class="admin-sidebar-footer">
|
||||||
|
<RouterLink
|
||||||
|
v-if="hasNotificationPermission"
|
||||||
|
class="sidebar-notification"
|
||||||
|
:to="adminPath('notifications')"
|
||||||
|
>
|
||||||
|
<span class="sidebar-notification-icon">
|
||||||
|
<el-icon><Message /></el-icon>
|
||||||
|
<em v-if="adminNotificationUnreadCount > 0" class="sidebar-badge">
|
||||||
|
{{ adminNotificationUnreadLabel }}
|
||||||
|
</em>
|
||||||
|
</span>
|
||||||
|
<span v-show="!isCollapsed" class="sidebar-notification-text">通知中心</span>
|
||||||
|
</RouterLink>
|
||||||
<el-dropdown
|
<el-dropdown
|
||||||
v-if="hasChatPermission"
|
v-if="hasChatPermission"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
@@ -541,6 +569,66 @@ async function handleForcedPasswordChange() {
|
|||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-notification {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(64, 158, 255, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #d7dfec;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-notification:hover,
|
||||||
|
.sidebar-notification.router-link-active {
|
||||||
|
background-color: rgba(64, 158, 255, 0.14);
|
||||||
|
border-color: rgba(64, 158, 255, 0.8);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell--collapsed .sidebar-notification {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-notification-icon {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-notification-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -13px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 2px solid #304156;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 12px;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-status {
|
.sidebar-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { getMobilePath, getPcPath } from '../mobileHost'
|
||||||
|
|
||||||
|
describe('mobileHost path mapping', () => {
|
||||||
|
it('maps notification routes between PC and mobile views', () => {
|
||||||
|
expect(getMobilePath('/notifications')).toBe('/m/notifications')
|
||||||
|
expect(getPcPath('/m/notifications')).toBe('/notifications')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -126,6 +126,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/admin/views/AdminAuditLogsView.vue'),
|
component: () => import('@/features/admin/views/AdminAuditLogsView.vue'),
|
||||||
meta: adminMeta,
|
meta: adminMeta,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: adminPath('notifications'),
|
||||||
|
name: 'admin-notifications',
|
||||||
|
component: () => import('@/features/admin/views/AdminNotificationsView.vue'),
|
||||||
|
meta: adminMeta,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: adminPath('admin-users'),
|
path: adminPath('admin-users'),
|
||||||
name: 'admin-admin-users',
|
name: 'admin-admin-users',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function getMobilePath(path: string): string {
|
|||||||
return `/m${path}`
|
return `/m${path}`
|
||||||
}
|
}
|
||||||
if (path === '/realname') return '/m/realname'
|
if (path === '/realname') return '/m/realname'
|
||||||
|
if (path === '/notifications') return '/m/notifications'
|
||||||
if (path === '/login') return '/m/login'
|
if (path === '/login') return '/m/login'
|
||||||
if (path === '/register') return '/m/register'
|
if (path === '/register') return '/m/register'
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export function getMobilePath(path: string): string {
|
|||||||
// PC-only views that don't have mobile counterparts go to mobile profile as fallback
|
// PC-only views that don't have mobile counterparts go to mobile profile as fallback
|
||||||
if (path === '/wallet/withdrawal') return '/m/wallet/withdrawal'
|
if (path === '/wallet/withdrawal') return '/m/wallet/withdrawal'
|
||||||
|
|
||||||
if (path === '/wallet' || path === '/notifications' || path.startsWith('/seller')) {
|
if (path === '/wallet' || path.startsWith('/seller')) {
|
||||||
return '/m/profile'
|
return '/m/profile'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ export function getPcPath(path: string): string {
|
|||||||
if (subPath === '/login') return '/login'
|
if (subPath === '/login') return '/login'
|
||||||
if (subPath === '/register') return '/login' // PC uses /login for both auth actions
|
if (subPath === '/register') return '/login' // PC uses /login for both auth actions
|
||||||
if (subPath === '/realname') return '/realname'
|
if (subPath === '/realname') return '/realname'
|
||||||
|
if (subPath === '/notifications') return '/notifications'
|
||||||
if (subPath === '/wallet/withdrawal') return '/wallet/withdrawal'
|
if (subPath === '/wallet/withdrawal') return '/wallet/withdrawal'
|
||||||
if (subPath === '/seller/listings/create') return '/seller/listings/create'
|
if (subPath === '/seller/listings/create') return '/seller/listings/create'
|
||||||
if (subPath.startsWith('/seller/listings/') && subPath.endsWith('/edit')) return subPath
|
if (subPath.startsWith('/seller/listings/') && subPath.endsWith('/edit')) return subPath
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ export const mobileRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/auth/views/MobileProfileView.vue'),
|
component: () => import('@/features/auth/views/MobileProfileView.vue'),
|
||||||
meta: { layout: 'blank', requiresAuth: true },
|
meta: { layout: 'blank', requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/m/notifications',
|
||||||
|
name: 'mobile-notifications',
|
||||||
|
component: () => import('@/features/auth/views/MobileNotificationsView.vue'),
|
||||||
|
meta: { layout: 'blank', requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/m/wallet/withdrawal',
|
path: '/m/wallet/withdrawal',
|
||||||
name: 'mobile-withdrawal',
|
name: 'mobile-withdrawal',
|
||||||
|
|||||||
Reference in New Issue
Block a user