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:
@@ -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<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) */
|
||||
export async function refreshAdminSession() {
|
||||
const refreshToken = getRefreshToken('admin')
|
||||
|
||||
@@ -20,11 +20,37 @@ const submitting = ref(false)
|
||||
const admins = ref<SupportAdmin[]>([])
|
||||
const selectedAdminId = ref<number | null>(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<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) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
@@ -84,16 +110,19 @@ function getStatusTag(count: number) {
|
||||
class="admin-item"
|
||||
>
|
||||
<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">
|
||||
<span class="admin-name">{{ admin.nickname }}</span>
|
||||
<span class="admin-id">ID: {{ admin.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-status">
|
||||
<el-tag :type="getStatusTag(admin.chat_count).type" size="small">
|
||||
{{ getStatusTag(admin.chat_count).text }}
|
||||
</el-tag>
|
||||
<span class="support-status" :style="{ color: getSupportStatusColor(admin.support_status) }">
|
||||
{{ getSupportStatusLabel(admin.support_status) }}
|
||||
</span>
|
||||
<span class="admin-count">{{ admin.chat_count }} 个会话</span>
|
||||
</div>
|
||||
</el-radio>
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
logoutAdmin,
|
||||
refreshAdminSession,
|
||||
fetchAdminMe,
|
||||
updateSupportStatus,
|
||||
type AdminUser,
|
||||
type AdminTokenPair,
|
||||
type AdminLoginData,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<div class="composer-actions">
|
||||
<span class="composer-hint">Enter 发送,Shift+Enter 换行</span>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<button class="send-btn" type="button" :disabled="!canSend || sending || uploading" @click="handleSend">
|
||||
<van-icon name="guide-o" :size="20" />
|
||||
|
||||
Reference in New Issue
Block a user