feat: 添加客服在线状态和聊天粘贴图片功能
**客服在线状态:** - 后端:添加 admin_users.support_status 字段(online/offline/busy) - 接口:PUT /admin/me/support-status 更新状态 - 前端:管理员顶部导航显示状态切换器(仅客服权限可见) - 转接对话框显示客服在线状态和智能排序(在线优先) **聊天粘贴图片:** - 用户端、移动端、管理端聊天输入框支持 Ctrl+V/Cmd+V 直接粘贴图片 - 自动调用图片压缩和上传逻辑,与文件选择上传保持一致 - 最多支持粘贴9张图片 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,15 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
Username string `gorm:"size:64;not null;uniqueIndex" json:"username"`
|
Username string `gorm:"size:64;not null;uniqueIndex" json:"username"`
|
||||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
SupportStatus string `gorm:"size:16;not null;default:'offline';index" json:"support_status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (AdminUser) TableName() string {
|
func (AdminUser) TableName() string {
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AdminDTO struct {
|
type AdminDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Roles []RoleDTO `json:"roles"`
|
SupportStatus string `json:"support_status"`
|
||||||
Permissions []string `json:"permissions"`
|
Roles []RoleDTO `json:"roles"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
Permissions []string `json:"permissions"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoleDTO struct {
|
type RoleDTO struct {
|
||||||
@@ -39,3 +40,7 @@ type CaptchaDTO struct {
|
|||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
ExpiresIn int64 `json:"expires_in"`
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateSupportStatusRequest struct {
|
||||||
|
Status string `json:"status" binding:"required,oneof=online offline busy"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,33 @@ func (h *Handler) Logout(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"logged_out": true})
|
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 {
|
type AdminRefreshRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,18 @@ func (r *Repository) FindByID(id uint64) (*AdminDTO, error) {
|
|||||||
return &dto, nil
|
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 {
|
func (r *Repository) ensureDefaultAdmin() error {
|
||||||
var count int64
|
var count int64
|
||||||
if err := r.db.Model(&model.AdminUser{}).Count(&count).Error; err != nil {
|
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 {
|
func toDTO(admin model.AdminUser) AdminDTO {
|
||||||
return AdminDTO{
|
return AdminDTO{
|
||||||
ID: admin.ID,
|
ID: admin.ID,
|
||||||
Username: admin.Username,
|
Username: admin.Username,
|
||||||
Nickname: admin.Nickname,
|
Nickname: admin.Nickname,
|
||||||
Status: admin.Status,
|
Status: admin.Status,
|
||||||
LastLoginAt: admin.LastLoginAt,
|
SupportStatus: admin.SupportStatus,
|
||||||
|
LastLoginAt: admin.LastLoginAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,10 @@ func (s *Service) Me(adminID uint64) (*AdminDTO, error) {
|
|||||||
}
|
}
|
||||||
return s.repo.FindByID(adminID)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,9 +65,10 @@ type UpdateRemarkRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SupportAdminDTO struct {
|
type SupportAdminDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
ChatCount int64 `json:"chat_count"`
|
SupportStatus string `json:"support_status"`
|
||||||
|
ChatCount int64 `json:"chat_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuickReplyDTO struct {
|
type QuickReplyDTO struct {
|
||||||
|
|||||||
@@ -777,12 +777,13 @@ func (r *Repository) TransferConversation(principal Principal, conversationID ui
|
|||||||
func (r *Repository) GetAvailableSupportAdmins() ([]SupportAdminDTO, error) {
|
func (r *Repository) GetAvailableSupportAdmins() ([]SupportAdminDTO, error) {
|
||||||
// 查询所有有 chat:view 权限且状态为 active 的管理员
|
// 查询所有有 chat:view 权限且状态为 active 的管理员
|
||||||
type adminRow struct {
|
type adminRow struct {
|
||||||
ID uint64
|
ID uint64
|
||||||
Nickname string
|
Nickname string
|
||||||
|
SupportStatus string
|
||||||
}
|
}
|
||||||
var admins []adminRow
|
var admins []adminRow
|
||||||
err := r.db.Table("admin_users AS au").
|
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 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 role_permissions AS rp ON rp.role_id = aur.role_id").
|
||||||
Joins("JOIN permissions AS p ON p.id = rp.permission_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))
|
result := make([]SupportAdminDTO, len(admins))
|
||||||
for i, a := range admins {
|
for i, a := range admins {
|
||||||
result[i] = SupportAdminDTO{
|
result[i] = SupportAdminDTO{
|
||||||
ID: a.ID,
|
ID: a.ID,
|
||||||
Nickname: a.Nickname,
|
Nickname: a.Nickname,
|
||||||
ChatCount: loadMap[a.ID],
|
SupportStatus: a.SupportStatus,
|
||||||
|
ChatCount: loadMap[a.ID],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes := api.Group("/admin", requireAdmin)
|
adminRoutes := api.Group("/admin", requireAdmin)
|
||||||
{
|
{
|
||||||
adminRoutes.GET("/me", adminAuthHandler.Me)
|
adminRoutes.GET("/me", adminAuthHandler.Me)
|
||||||
|
adminRoutes.PUT("/me/support-status", adminAuthHandler.UpdateSupportStatus)
|
||||||
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
|
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
|
||||||
adminRoutes.POST("/files/upload", fileHandler.Upload)
|
adminRoutes.POST("/files/upload", fileHandler.Upload)
|
||||||
adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary)
|
adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary)
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -16,6 +16,7 @@ export interface AdminUser {
|
|||||||
username: string
|
username: string
|
||||||
nickname: string
|
nickname: string
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
|
support_status: 'online' | 'offline' | 'busy'
|
||||||
roles: AdminRole[]
|
roles: AdminRole[]
|
||||||
permissions: string[]
|
permissions: string[]
|
||||||
last_login_at?: string
|
last_login_at?: string
|
||||||
@@ -64,6 +65,13 @@ export async function logoutAdmin() {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSupportStatus(status: 'online' | 'offline' | 'busy') {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<{ updated: boolean; support_status: string }>>('/admin/me/support-status', {
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
|
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
|
||||||
export async function refreshAdminSession() {
|
export async function refreshAdminSession() {
|
||||||
const refreshToken = getRefreshToken('admin')
|
const refreshToken = getRefreshToken('admin')
|
||||||
|
|||||||
@@ -20,11 +20,37 @@ const submitting = ref(false)
|
|||||||
const admins = ref<SupportAdmin[]>([])
|
const admins = ref<SupportAdmin[]>([])
|
||||||
const selectedAdminId = ref<number | null>(null)
|
const selectedAdminId = ref<number | null>(null)
|
||||||
|
|
||||||
// 按会话数排序,空闲客服优先
|
// 按在线状态和会话数排序:在线客服优先,会话数少的优先
|
||||||
const sortedAdmins = computed(() => {
|
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<string, number> = { 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<string, string> = {
|
||||||
|
online: '在线',
|
||||||
|
offline: '离线',
|
||||||
|
busy: '忙碌',
|
||||||
|
}
|
||||||
|
return labels[status] || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportStatusColor(status: string) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
online: '#10b981',
|
||||||
|
offline: '#9ca3af',
|
||||||
|
busy: '#f59e0b',
|
||||||
|
}
|
||||||
|
return colors[status] || '#9ca3af'
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.modelValue, (val) => {
|
watch(() => props.modelValue, (val) => {
|
||||||
visible.value = val
|
visible.value = val
|
||||||
if (val) {
|
if (val) {
|
||||||
@@ -84,16 +110,19 @@ function getStatusTag(count: number) {
|
|||||||
class="admin-item"
|
class="admin-item"
|
||||||
>
|
>
|
||||||
<div class="admin-info">
|
<div class="admin-info">
|
||||||
<el-avatar :size="36" :icon="User" />
|
<div class="admin-avatar-wrap">
|
||||||
|
<el-avatar :size="36" :icon="User" />
|
||||||
|
<span class="status-indicator" :style="{ backgroundColor: getSupportStatusColor(admin.support_status) }"></span>
|
||||||
|
</div>
|
||||||
<div class="admin-detail">
|
<div class="admin-detail">
|
||||||
<span class="admin-name">{{ admin.nickname }}</span>
|
<span class="admin-name">{{ admin.nickname }}</span>
|
||||||
<span class="admin-id">ID: {{ admin.id }}</span>
|
<span class="admin-id">ID: {{ admin.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-status">
|
<div class="admin-status">
|
||||||
<el-tag :type="getStatusTag(admin.chat_count).type" size="small">
|
<span class="support-status" :style="{ color: getSupportStatusColor(admin.support_status) }">
|
||||||
{{ getStatusTag(admin.chat_count).text }}
|
{{ getSupportStatusLabel(admin.support_status) }}
|
||||||
</el-tag>
|
</span>
|
||||||
<span class="admin-count">{{ admin.chat_count }} 个会话</span>
|
<span class="admin-count">{{ admin.chat_count }} 个会话</span>
|
||||||
</div>
|
</div>
|
||||||
</el-radio>
|
</el-radio>
|
||||||
@@ -159,6 +188,21 @@ function getStatusTag(count: number) {
|
|||||||
min-width: 0;
|
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 {
|
.admin-detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -188,6 +232,11 @@ function getStatusTag(count: number) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.support-status {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-count {
|
.admin-count {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
logoutAdmin,
|
logoutAdmin,
|
||||||
refreshAdminSession,
|
refreshAdminSession,
|
||||||
fetchAdminMe,
|
fetchAdminMe,
|
||||||
|
updateSupportStatus,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
type AdminTokenPair,
|
type AdminTokenPair,
|
||||||
type AdminLoginData,
|
type AdminLoginData,
|
||||||
|
|||||||
@@ -174,6 +174,23 @@ async function handleImageChange(event: Event) {
|
|||||||
const files = Array.from(input.files || [])
|
const files = Array.from(input.files || [])
|
||||||
input.value = ''
|
input.value = ''
|
||||||
if (files.length === 0) return
|
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
|
const slots = 9 - attachments.value.length
|
||||||
if (slots <= 0) {
|
if (slots <= 0) {
|
||||||
ElMessage.warning('每条消息最多发送 9 张图片')
|
ElMessage.warning('每条消息最多发送 9 张图片')
|
||||||
@@ -426,6 +443,7 @@ function getSupportName(item: ChatConversation) {
|
|||||||
show-word-limit
|
show-word-limit
|
||||||
placeholder="输入客服回复"
|
placeholder="输入客服回复"
|
||||||
@keydown.enter.exact.prevent="handleSend"
|
@keydown.enter.exact.prevent="handleSend"
|
||||||
|
@paste="handlePaste"
|
||||||
/>
|
/>
|
||||||
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
|
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export async function markAdminChatRead(id: number) {
|
|||||||
export interface SupportAdmin {
|
export interface SupportAdmin {
|
||||||
id: number
|
id: number
|
||||||
nickname: string
|
nickname: string
|
||||||
|
support_status: 'online' | 'offline' | 'busy'
|
||||||
chat_count: number
|
chat_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,23 @@ async function handleImageChange(event: Event) {
|
|||||||
const files = Array.from(input.files || [])
|
const files = Array.from(input.files || [])
|
||||||
input.value = ''
|
input.value = ''
|
||||||
if (files.length === 0) return
|
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
|
const slots = 9 - attachments.value.length
|
||||||
if (slots <= 0) {
|
if (slots <= 0) {
|
||||||
ElMessage.warning('每条消息最多发送 9 张图片')
|
ElMessage.warning('每条消息最多发送 9 张图片')
|
||||||
@@ -288,6 +305,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
placeholder="发送消息..."
|
placeholder="发送消息..."
|
||||||
resize="none"
|
resize="none"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
|
@paste="handlePaste"
|
||||||
/>
|
/>
|
||||||
<div class="composer-actions">
|
<div class="composer-actions">
|
||||||
<span class="composer-hint">Enter 发送,Shift+Enter 换行</span>
|
<span class="composer-hint">Enter 发送,Shift+Enter 换行</span>
|
||||||
|
|||||||
@@ -137,6 +137,23 @@ async function handleImageChange(event: Event) {
|
|||||||
const files = Array.from(input.files || [])
|
const files = Array.from(input.files || [])
|
||||||
input.value = ''
|
input.value = ''
|
||||||
if (files.length === 0) return
|
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
|
const slots = 9 - attachments.value.length
|
||||||
if (slots <= 0) {
|
if (slots <= 0) {
|
||||||
showToast('每条消息最多发送 9 张图片')
|
showToast('每条消息最多发送 9 张图片')
|
||||||
@@ -263,6 +280,7 @@ function senderLabel(message: ChatMessage) {
|
|||||||
rows="1"
|
rows="1"
|
||||||
placeholder="发送消息"
|
placeholder="发送消息"
|
||||||
@keydown.enter.prevent="handleSend"
|
@keydown.enter.prevent="handleSend"
|
||||||
|
@paste="handlePaste"
|
||||||
/>
|
/>
|
||||||
<button class="send-btn" type="button" :disabled="!canSend || sending || uploading" @click="handleSend">
|
<button class="send-btn" type="button" :disabled="!canSend || sending || uploading" @click="handleSend">
|
||||||
<van-icon name="guide-o" :size="20" />
|
<van-icon name="guide-o" :size="20" />
|
||||||
|
|||||||
@@ -20,13 +20,14 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { logoutAdmin } from '@/features/admin'
|
import { logoutAdmin, updateSupportStatus } from '@/features/admin'
|
||||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const adminSession = useAdminSessionStore()
|
const adminSession = useAdminSessionStore()
|
||||||
const isCollapsed = ref(false)
|
const isCollapsed = ref(false)
|
||||||
|
const updatingStatus = ref(false)
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -60,6 +61,34 @@ const navItems = computed(() => {
|
|||||||
|
|
||||||
const activeMenu = computed(() => route.path)
|
const activeMenu = computed(() => route.path)
|
||||||
const adminName = computed(() => adminSession.nickname || adminSession.username || '管理员')
|
const adminName = computed(() => adminSession.nickname || adminSession.username || '管理员')
|
||||||
|
const supportStatus = computed(() => adminSession.supportStatus || 'offline')
|
||||||
|
const hasChatPermission = computed(() => adminSession.hasPermission('chat:view'))
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
online: '在线',
|
||||||
|
offline: '离线',
|
||||||
|
busy: '忙碌',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
online: '#10b981',
|
||||||
|
offline: '#9ca3af',
|
||||||
|
busy: '#f59e0b',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(status: 'online' | 'offline' | 'busy') {
|
||||||
|
if (updatingStatus.value) return
|
||||||
|
updatingStatus.value = true
|
||||||
|
try {
|
||||||
|
await updateSupportStatus(status)
|
||||||
|
adminSession.setSupportStatus(status)
|
||||||
|
ElMessage.success(`已切换为${statusLabels[status]}`)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('状态更新失败')
|
||||||
|
} finally {
|
||||||
|
updatingStatus.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
@@ -110,6 +139,28 @@ async function handleLogout() {
|
|||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
|
<el-dropdown v-if="hasChatPermission" trigger="click" @command="handleStatusChange" :disabled="updatingStatus">
|
||||||
|
<span class="status-badge" :style="{ borderColor: statusColors[supportStatus] }">
|
||||||
|
<span class="status-dot" :style="{ backgroundColor: statusColors[supportStatus] }"></span>
|
||||||
|
<span class="status-text">{{ statusLabels[supportStatus] }}</span>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="online">
|
||||||
|
<span class="status-dot" style="background-color: #10b981;"></span>
|
||||||
|
在线
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="busy">
|
||||||
|
<span class="status-dot" style="background-color: #f59e0b;"></span>
|
||||||
|
忙碌
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="offline">
|
||||||
|
<span class="status-dot" style="background-color: #9ca3af;"></span>
|
||||||
|
离线
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<span class="user-info">
|
<span class="user-info">
|
||||||
<el-avatar :size="32" :icon="User" />
|
<el-avatar :size="32" :icon="User" />
|
||||||
@@ -256,6 +307,39 @@ async function handleLogout() {
|
|||||||
.topbar-right {
|
.topbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge:hover {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dropdown-menu__item .status-dot) {
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
|||||||
adminId: Number(localStorage.getItem('admin_id') || 0),
|
adminId: Number(localStorage.getItem('admin_id') || 0),
|
||||||
username: localStorage.getItem('admin_username') || '',
|
username: localStorage.getItem('admin_username') || '',
|
||||||
nickname: '',
|
nickname: '',
|
||||||
|
supportStatus: (localStorage.getItem('admin_support_status') || 'offline') as 'online' | 'offline' | 'busy',
|
||||||
roles: [] as AdminRole[],
|
roles: [] as AdminRole[],
|
||||||
permissions: [] as string[],
|
permissions: [] as string[],
|
||||||
}),
|
}),
|
||||||
@@ -41,15 +42,18 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
|||||||
this.adminId = 0
|
this.adminId = 0
|
||||||
this.username = ''
|
this.username = ''
|
||||||
this.nickname = ''
|
this.nickname = ''
|
||||||
|
this.supportStatus = 'offline'
|
||||||
this.roles = []
|
this.roles = []
|
||||||
this.permissions = []
|
this.permissions = []
|
||||||
clearAuthStorage('admin')
|
clearAuthStorage('admin')
|
||||||
|
localStorage.removeItem('admin_support_status')
|
||||||
},
|
},
|
||||||
syncFromStorage() {
|
syncFromStorage() {
|
||||||
this.token = getAccessToken('admin')
|
this.token = getAccessToken('admin')
|
||||||
this.refreshToken = getRefreshToken('admin')
|
this.refreshToken = getRefreshToken('admin')
|
||||||
this.adminId = Number(localStorage.getItem('admin_id') || 0)
|
this.adminId = Number(localStorage.getItem('admin_id') || 0)
|
||||||
this.username = localStorage.getItem('admin_username') || ''
|
this.username = localStorage.getItem('admin_username') || ''
|
||||||
|
this.supportStatus = (localStorage.getItem('admin_support_status') || 'offline') as 'online' | 'offline' | 'busy'
|
||||||
},
|
},
|
||||||
applySession(admin: AdminUser, accessToken: string, refreshToken: string) {
|
applySession(admin: AdminUser, accessToken: string, refreshToken: string) {
|
||||||
this.token = accessToken
|
this.token = accessToken
|
||||||
@@ -64,10 +68,16 @@ export const useAdminSessionStore = defineStore('adminSession', {
|
|||||||
this.adminId = admin.id
|
this.adminId = admin.id
|
||||||
this.username = admin.username
|
this.username = admin.username
|
||||||
this.nickname = admin.nickname
|
this.nickname = admin.nickname
|
||||||
|
this.supportStatus = admin.support_status || 'offline'
|
||||||
this.roles = admin.roles || []
|
this.roles = admin.roles || []
|
||||||
this.permissions = admin.permissions || []
|
this.permissions = admin.permissions || []
|
||||||
localStorage.setItem('admin_id', String(admin.id))
|
localStorage.setItem('admin_id', String(admin.id))
|
||||||
localStorage.setItem('admin_username', admin.username)
|
localStorage.setItem('admin_username', admin.username)
|
||||||
|
localStorage.setItem('admin_support_status', admin.support_status || 'offline')
|
||||||
|
},
|
||||||
|
setSupportStatus(status: 'online' | 'offline' | 'busy') {
|
||||||
|
this.supportStatus = status
|
||||||
|
localStorage.setItem('admin_support_status', status)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user