diff --git a/backend/internal/model/admin_user.go b/backend/internal/model/admin_user.go index 733453b..b3e7502 100644 --- a/backend/internal/model/admin_user.go +++ b/backend/internal/model/admin_user.go @@ -3,14 +3,15 @@ package model import "time" type AdminUser struct { - ID uint64 `gorm:"primaryKey" json:"id"` - Username string `gorm:"size:64;not null;uniqueIndex" json:"username"` - PasswordHash string `gorm:"size:255;not null" json:"-"` - Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` - Status string `gorm:"size:32;not null;default:'active'" json:"status"` - LastLoginAt *time.Time `json:"last_login_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:64;not null;uniqueIndex" json:"username"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` + Status string `gorm:"size:32;not null;default:'active'" json:"status"` + SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (AdminUser) TableName() string { diff --git a/backend/internal/modules/adminauth/dto.go b/backend/internal/modules/adminauth/dto.go index 320b622..7ab7604 100644 --- a/backend/internal/modules/adminauth/dto.go +++ b/backend/internal/modules/adminauth/dto.go @@ -7,13 +7,14 @@ import ( ) type AdminDTO struct { - ID uint64 `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Status string `json:"status"` - Roles []RoleDTO `json:"roles"` - Permissions []string `json:"permissions"` - LastLoginAt *time.Time `json:"last_login_at"` + ID uint64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Status string `json:"status"` + SupportStatus string `json:"support_status"` + Roles []RoleDTO `json:"roles"` + Permissions []string `json:"permissions"` + LastLoginAt *time.Time `json:"last_login_at"` } type RoleDTO struct { @@ -39,3 +40,7 @@ type CaptchaDTO struct { Image string `json:"image"` ExpiresIn int64 `json:"expires_in"` } + +type UpdateSupportStatusRequest struct { + Status string `json:"status" binding:"required,oneof=online offline busy"` +} diff --git a/backend/internal/modules/adminauth/handler.go b/backend/internal/modules/adminauth/handler.go index 2c8e9eb..bcda722 100644 --- a/backend/internal/modules/adminauth/handler.go +++ b/backend/internal/modules/adminauth/handler.go @@ -65,6 +65,33 @@ func (h *Handler) Logout(c *gin.Context) { response.OK(c, gin.H{"logged_out": true}) } +func (h *Handler) UpdateSupportStatus(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req UpdateSupportStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "状态值无效,必须是 online、offline 或 busy") + return + } + if err := h.service.UpdateSupportStatus(adminID, req.Status); err != nil { + writeAdminAuthError(c, err) + return + } + response.OK(c, gin.H{"updated": true, "support_status": req.Status}) +} + +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 +} + type AdminRefreshRequest struct { RefreshToken string `json:"refresh_token" binding:"required"` } diff --git a/backend/internal/modules/adminauth/repository.go b/backend/internal/modules/adminauth/repository.go index 4526daa..87dffa8 100644 --- a/backend/internal/modules/adminauth/repository.go +++ b/backend/internal/modules/adminauth/repository.go @@ -126,6 +126,18 @@ func (r *Repository) FindByID(id uint64) (*AdminDTO, error) { return &dto, nil } +func (r *Repository) UpdateSupportStatus(adminID uint64, status string) error { + if r.db == nil { + return ErrDependencyUnavailable + } + if status != "online" && status != "offline" && status != "busy" { + return errors.New("invalid support status") + } + return r.db.Model(&model.AdminUser{}). + Where("id = ?", adminID). + Update("support_status", status).Error +} + func (r *Repository) ensureDefaultAdmin() error { var count int64 if err := r.db.Model(&model.AdminUser{}).Count(&count).Error; err != nil { @@ -162,11 +174,12 @@ func (r *Repository) ensureDefaultAdmin() error { func toDTO(admin model.AdminUser) AdminDTO { return AdminDTO{ - ID: admin.ID, - Username: admin.Username, - Nickname: admin.Nickname, - Status: admin.Status, - LastLoginAt: admin.LastLoginAt, + ID: admin.ID, + Username: admin.Username, + Nickname: admin.Nickname, + Status: admin.Status, + SupportStatus: admin.SupportStatus, + LastLoginAt: admin.LastLoginAt, } } diff --git a/backend/internal/modules/adminauth/service.go b/backend/internal/modules/adminauth/service.go index 917eccc..114e4eb 100644 --- a/backend/internal/modules/adminauth/service.go +++ b/backend/internal/modules/adminauth/service.go @@ -61,3 +61,10 @@ func (s *Service) Me(adminID uint64) (*AdminDTO, error) { } return s.repo.FindByID(adminID) } + +func (s *Service) UpdateSupportStatus(adminID uint64, status string) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.UpdateSupportStatus(adminID, status) +} diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index 9b924f6..fb2fc8f 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -65,9 +65,10 @@ type UpdateRemarkRequest struct { } type SupportAdminDTO struct { - ID uint64 `json:"id"` - Nickname string `json:"nickname"` - ChatCount int64 `json:"chat_count"` + ID uint64 `json:"id"` + Nickname string `json:"nickname"` + SupportStatus string `json:"support_status"` + ChatCount int64 `json:"chat_count"` } type QuickReplyDTO struct { diff --git a/backend/internal/modules/chat/repository.go b/backend/internal/modules/chat/repository.go index 5920cf0..6d2a1e2 100644 --- a/backend/internal/modules/chat/repository.go +++ b/backend/internal/modules/chat/repository.go @@ -777,12 +777,13 @@ func (r *Repository) TransferConversation(principal Principal, conversationID ui func (r *Repository) GetAvailableSupportAdmins() ([]SupportAdminDTO, error) { // 查询所有有 chat:view 权限且状态为 active 的管理员 type adminRow struct { - ID uint64 - Nickname string + ID uint64 + Nickname string + SupportStatus string } var admins []adminRow err := r.db.Table("admin_users AS au"). - Select("DISTINCT au.id, COALESCE(NULLIF(au.nickname, ''), au.username) AS nickname"). + Select("DISTINCT au.id, COALESCE(NULLIF(au.nickname, ''), au.username) AS nickname, au.support_status"). Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). Joins("JOIN role_permissions AS rp ON rp.role_id = aur.role_id"). Joins("JOIN permissions AS p ON p.id = rp.permission_id"). @@ -817,9 +818,10 @@ func (r *Repository) GetAvailableSupportAdmins() ([]SupportAdminDTO, error) { result := make([]SupportAdminDTO, len(admins)) for i, a := range admins { result[i] = SupportAdminDTO{ - ID: a.ID, - Nickname: a.Nickname, - ChatCount: loadMap[a.ID], + ID: a.ID, + Nickname: a.Nickname, + SupportStatus: a.SupportStatus, + ChatCount: loadMap[a.ID], } } return result, nil diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 49a8efd..2e977b0 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -317,6 +317,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes := api.Group("/admin", requireAdmin) { adminRoutes.GET("/me", adminAuthHandler.Me) + adminRoutes.PUT("/me/support-status", adminAuthHandler.UpdateSupportStatus) adminRoutes.POST("/auth/logout", adminAuthHandler.Logout) adminRoutes.POST("/files/upload", fileHandler.Upload) adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary) diff --git a/backend/migrations/000004_add_support_status.sql b/backend/migrations/000004_add_support_status.sql new file mode 100644 index 0000000..9264156 --- /dev/null +++ b/backend/migrations/000004_add_support_status.sql @@ -0,0 +1,8 @@ +-- 添加客服在线状态字段 +-- support_status: online(在线), offline(离线), busy(忙碌) + +ALTER TABLE admin_users +ADD COLUMN support_status VARCHAR(16) NOT NULL DEFAULT 'offline' COMMENT '客服状态: online在线, offline离线, busy忙碌'; + +-- 为查询客服状态添加索引 +CREATE INDEX idx_admin_users_support_status ON admin_users(support_status); diff --git a/frontend/src/features/admin/api/adminAuth.ts b/frontend/src/features/admin/api/adminAuth.ts index b71741d..29435ce 100644 --- a/frontend/src/features/admin/api/adminAuth.ts +++ b/frontend/src/features/admin/api/adminAuth.ts @@ -16,6 +16,7 @@ export interface AdminUser { username: string nickname: string status: UserStatus + support_status: 'online' | 'offline' | 'busy' roles: AdminRole[] permissions: string[] last_login_at?: string @@ -64,6 +65,13 @@ export async function logoutAdmin() { return data.data } +export async function updateSupportStatus(status: 'online' | 'offline' | 'busy') { + const { data } = await apiClient.put>('/admin/me/support-status', { + status, + }) + return data.data +} + /** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */ export async function refreshAdminSession() { const refreshToken = getRefreshToken('admin') diff --git a/frontend/src/features/admin/components/TransferDialog.vue b/frontend/src/features/admin/components/TransferDialog.vue index e50d45e..cc3547f 100644 --- a/frontend/src/features/admin/components/TransferDialog.vue +++ b/frontend/src/features/admin/components/TransferDialog.vue @@ -20,11 +20,37 @@ const submitting = ref(false) const admins = ref([]) const selectedAdminId = ref(null) -// 按会话数排序,空闲客服优先 +// 按在线状态和会话数排序:在线客服优先,会话数少的优先 const sortedAdmins = computed(() => { - return [...admins.value].sort((a, b) => a.chat_count - b.chat_count) + return [...admins.value].sort((a, b) => { + // 在线状态优先级:online > busy > offline + const statusPriority: Record = { online: 0, busy: 1, offline: 2 } + const aPriority = statusPriority[a.support_status] ?? 2 + const bPriority = statusPriority[b.support_status] ?? 2 + if (aPriority !== bPriority) return aPriority - bPriority + // 同状态下按会话数排序 + return a.chat_count - b.chat_count + }) }) +function getSupportStatusLabel(status: string) { + const labels: Record = { + online: '在线', + offline: '离线', + busy: '忙碌', + } + return labels[status] || '未知' +} + +function getSupportStatusColor(status: string) { + const colors: Record = { + online: '#10b981', + offline: '#9ca3af', + busy: '#f59e0b', + } + return colors[status] || '#9ca3af' +} + watch(() => props.modelValue, (val) => { visible.value = val if (val) { @@ -84,16 +110,19 @@ function getStatusTag(count: number) { class="admin-item" >
- +
+ + +
{{ admin.nickname }} ID: {{ admin.id }}
- - {{ getStatusTag(admin.chat_count).text }} - + + {{ getSupportStatusLabel(admin.support_status) }} + {{ admin.chat_count }} 个会话
@@ -159,6 +188,21 @@ function getStatusTag(count: number) { min-width: 0; } +.admin-avatar-wrap { + position: relative; + flex-shrink: 0; +} + +.status-indicator { + position: absolute; + right: 0; + bottom: 0; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid #fff; +} + .admin-detail { display: flex; flex-direction: column; @@ -188,6 +232,11 @@ function getStatusTag(count: number) { flex-shrink: 0; } +.support-status { + font-size: 13px; + font-weight: 600; +} + .admin-count { color: #9ca3af; font-size: 12px; diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index 076927b..779c462 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -15,6 +15,7 @@ export { logoutAdmin, refreshAdminSession, fetchAdminMe, + updateSupportStatus, type AdminUser, type AdminTokenPair, type AdminLoginData, diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index 9f6740b..403ae0b 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -174,6 +174,23 @@ async function handleImageChange(event: Event) { const files = Array.from(input.files || []) input.value = '' if (files.length === 0) return + await uploadImages(files) +} + +async function handlePaste(event: ClipboardEvent) { + const items = Array.from(event.clipboardData?.items || []) + const imageFiles = items + .filter(item => item.type.startsWith('image/')) + .map(item => item.getAsFile()) + .filter(Boolean) as File[] + + if (imageFiles.length > 0) { + event.preventDefault() + await uploadImages(imageFiles) + } +} + +async function uploadImages(files: File[]) { const slots = 9 - attachments.value.length if (slots <= 0) { ElMessage.warning('每条消息最多发送 9 张图片') @@ -426,6 +443,7 @@ function getSupportName(item: ChatConversation) { show-word-limit placeholder="输入客服回复" @keydown.enter.exact.prevent="handleSend" + @paste="handlePaste" /> 发送 diff --git a/frontend/src/features/chats/api/chats.ts b/frontend/src/features/chats/api/chats.ts index 0cb9d85..97b8352 100644 --- a/frontend/src/features/chats/api/chats.ts +++ b/frontend/src/features/chats/api/chats.ts @@ -122,6 +122,7 @@ export async function markAdminChatRead(id: number) { export interface SupportAdmin { id: number nickname: string + support_status: 'online' | 'offline' | 'busy' chat_count: number } diff --git a/frontend/src/features/chats/views/ChatView.vue b/frontend/src/features/chats/views/ChatView.vue index 63410a1..92ff32d 100644 --- a/frontend/src/features/chats/views/ChatView.vue +++ b/frontend/src/features/chats/views/ChatView.vue @@ -139,6 +139,23 @@ async function handleImageChange(event: Event) { const files = Array.from(input.files || []) input.value = '' if (files.length === 0) return + await uploadImages(files) +} + +async function handlePaste(event: ClipboardEvent) { + const items = Array.from(event.clipboardData?.items || []) + const imageFiles = items + .filter(item => item.type.startsWith('image/')) + .map(item => item.getAsFile()) + .filter(Boolean) as File[] + + if (imageFiles.length > 0) { + event.preventDefault() + await uploadImages(imageFiles) + } +} + +async function uploadImages(files: File[]) { const slots = 9 - attachments.value.length if (slots <= 0) { ElMessage.warning('每条消息最多发送 9 张图片') @@ -288,6 +305,7 @@ function handleKeydown(e: KeyboardEvent) { placeholder="发送消息..." resize="none" @keydown="handleKeydown" + @paste="handlePaste" />
Enter 发送,Shift+Enter 换行 diff --git a/frontend/src/features/chats/views/MobileChatView.vue b/frontend/src/features/chats/views/MobileChatView.vue index 6d36ba6..39c7983 100644 --- a/frontend/src/features/chats/views/MobileChatView.vue +++ b/frontend/src/features/chats/views/MobileChatView.vue @@ -137,6 +137,23 @@ async function handleImageChange(event: Event) { const files = Array.from(input.files || []) input.value = '' if (files.length === 0) return + await uploadImages(files) +} + +async function handlePaste(event: ClipboardEvent) { + const items = Array.from(event.clipboardData?.items || []) + const imageFiles = items + .filter(item => item.type.startsWith('image/')) + .map(item => item.getAsFile()) + .filter(Boolean) as File[] + + if (imageFiles.length > 0) { + event.preventDefault() + await uploadImages(imageFiles) + } +} + +async function uploadImages(files: File[]) { const slots = 9 - attachments.value.length if (slots <= 0) { showToast('每条消息最多发送 9 张图片') @@ -263,6 +280,7 @@ function senderLabel(message: ChatMessage) { rows="1" placeholder="发送消息" @keydown.enter.prevent="handleSend" + @paste="handlePaste" />
+ + + + {{ statusLabels[supportStatus] }} + + +