Files
hfb_sys/frontend/src/features/admin/views/AdminChatsView.vue
T
yml2213 e010f2ce7e chore: 批量更新导入路径到 features 架构
- 更新所有 admin API 文件导入路径
- 更新所有 features 中 Vue 文件的导入路径
- 修复 ChatAttachmentImage 组件导入
- 修复 auth 模块部分导入路径

剩余 ~190 个类型错误待修复(主要是缺失的模块)
2026-06-04 09:23:15 +08:00

752 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Picture } from '@element-plus/icons-vue'
import {
fetchAdminChat,
fetchAdminChatMessages,
fetchAdminChats,
fetchQuickReplies,
markAdminChatRead,
sendAdminChatMessage,
updateChatRemark,
type ChatConversation,
type ChatMessage,
type QuickReply,
} from '@/features/chats'
import { uploadAdminFile } from '@/shared/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
import TransferDialog from './components/TransferDialog.vue'
import QuickReplyDialog from './components/QuickReplyDialog.vue'
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
const conversations = ref<ChatConversation[]>([])
const active = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const messageLoading = ref(false)
const sending = ref(false)
const uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
const transferVisible = ref(false)
const quickReplyVisible = ref(false)
const quickReplies = ref<QuickReply[]>([])
const remarkEditing = ref(false)
const remarkValue = ref('')
const activeMembers = computed(() => {
const participants = active.value?.participants || []
return participants.map(item => {
const remark = getParticipantRemark(item)
const name = remark ? `${remark}(${item.display_name})` : item.display_name
return `${roleLabel(item.role)}${name}`
}).join(' / ')
})
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
function getParticipantRemark(participant: any) {
if (!active.value) return ''
const myParticipant = active.value.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
return myParticipant?.remark || ''
}
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'conversation_updated') {
loadConversations(false)
}
if (event.type === 'new_message' && active.value && event.conversation_id === active.value.id) {
const msg = event.message
if (msg && !messages.value.some(m => m.id === msg.id)) {
messages.value = [...messages.value, {
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'admin' && msg.sender_id === currentAdminId,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
}]
nextTick(() => scrollBottom())
}
}
}
const { onEvent } = useChatSSE('admin', '/api/admin/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
await Promise.all([loadConversations(), loadQuickReplies()])
})
async function loadConversations(showLoading = true) {
if (showLoading) loading.value = true
try {
const res = await fetchAdminChats(1, 100, filter.value)
conversations.value = res.items
const first = conversations.value[0]
if (!active.value && first) {
await openConversation(first)
}
} catch {
ElMessage.error('会话加载失败')
} finally {
loading.value = false
}
}
async function loadQuickReplies() {
try {
quickReplies.value = await fetchQuickReplies()
} catch { /* ignore */ }
}
async function openConversation(item: ChatConversation) {
messageLoading.value = true
try {
active.value = await fetchAdminChat(item.id)
await loadMessages(item.id)
await markAdminChatRead(item.id)
await loadConversations(false)
remarkEditing.value = false
remarkValue.value = ''
attachments.value = []
} catch {
ElMessage.error('会话详情加载失败')
} finally {
messageLoading.value = false
}
}
async function loadMessages(id: number, scroll = true) {
const res = await fetchAdminChatMessages(id, 1, 100)
messages.value = res.items
if (scroll) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
const imageUrls = [...attachments.value]
if (!active.value || (!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
const sent = await sendAdminChatMessage(active.value.id, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
await loadConversations(false)
} catch {
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
ElMessage.warning('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
ElMessage.warning(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadAdminFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
ElMessage.warning('每条消息最多发送 9 张图片')
}
} catch {
ElMessage.error('图片上传失败')
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function handleQuickReplySelect(reply: QuickReply) {
content.value = reply.content
quickReplyVisible.value = false
}
function handleFilterChange(val: string) {
filter.value = val as typeof filter.value
active.value = null
messages.value = []
loadConversations()
}
function handleTransferSuccess() {
loadConversations(false)
if (active.value) {
loadMessages(active.value.id, false)
}
}
async function startEditRemark() {
if (!active.value) return
const myParticipant = active.value.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
remarkValue.value = myParticipant?.remark || active.value.title
remarkEditing.value = true
}
async function saveRemark() {
if (!active.value) return
try {
await updateChatRemark(active.value.id, remarkValue.value)
ElMessage.success('备注已更新')
remarkEditing.value = false
await fetchAdminChat(active.value.id).then(chat => {
active.value = chat
})
await loadConversations(false)
} catch {
ElMessage.error('更新备注失败')
}
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(item: ChatMessage) {
if (item.sender_type === 'system') return '系统'
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
}
function getConversationTitle(item: ChatConversation) {
const myParticipant = item.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
return myParticipant?.remark || item.title
}
function getSupportName(item: ChatConversation) {
const support = item.participants?.find(p => p.role === 'support')
return support?.display_name || '未分配'
}
</script>
<template>
<section class="admin-page">
<div class="page-head">
<div>
<h1>客服会话</h1>
<p>处理订单三方沟通和平台咨询</p>
</div>
<div class="head-right">
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
</div>
</div>
<div class="chat-workbench">
<aside class="conversation-pane" v-loading="loading">
<div class="filter-tabs">
<el-radio-group v-model="filter" size="small" @change="handleFilterChange">
<el-radio-button value="mine">我的会话</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="unassigned">未分配</el-radio-button>
</el-radio-group>
</div>
<button
v-for="item in conversations"
:key="item.id"
type="button"
class="conversation-row"
:class="{ active: active?.id === item.id }"
@click="openConversation(item)"
>
<div class="row-title">
<strong>{{ getConversationTitle(item) }}</strong>
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<p>{{ item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建') }}</p>
<div class="row-meta">
<span class="support-name">{{ getSupportName(item) }}</span>
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
</div>
</button>
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
</aside>
<main class="message-pane">
<template v-if="active">
<header class="message-head">
<div class="head-title">
<template v-if="remarkEditing">
<el-input
v-model="remarkValue"
size="small"
style="width: 200px"
placeholder="输入备注"
@keyup.enter="saveRemark"
/>
<el-button size="small" type="primary" @click="saveRemark">保存</el-button>
<el-button size="small" @click="remarkEditing = false">取消</el-button>
</template>
<template v-else>
<h2>{{ getConversationTitle(active) }} <el-button link size="small" @click="startEditRemark">编辑备注</el-button></h2>
<p>{{ activeMembers }}</p>
</template>
</div>
<div class="head-actions">
<el-button size="small" @click="transferVisible = true">转接</el-button>
<RouterLink v-if="active.order_id" :to="`/admin/orders/${active.order_id}`">
<el-button size="small">查看订单</el-button>
</RouterLink>
</div>
</header>
<div ref="listRef" class="message-list" v-loading="messageLoading">
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span>{{ item.content }}</span>
</template>
<template v-else>
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small>
<p v-if="item.content">{{ item.content }}</p>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
admin
/>
</div>
</template>
</div>
</div>
<footer class="composer">
<div class="composer-tools">
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<el-dropdown trigger="click" @command="handleQuickReplySelect">
<el-button size="small" text>快捷回复</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="reply in quickReplies"
:key="reply.id"
:command="reply"
>
<span class="reply-title">{{ reply.title }}</span>
<span class="reply-preview">{{ reply.content.slice(0, 30) }}{{ reply.content.length > 30 ? '...' : '' }}</span>
</el-dropdown-item>
<el-dropdown-item v-if="quickReplies.length === 0" disabled>
暂无快捷回复
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" text :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
图片
</el-button>
</div>
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" admin />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<el-icon :size="14"><Close /></el-icon>
</button>
</div>
</div>
<div class="composer-input">
<el-input
v-model="content"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="输入客服回复"
@keydown.enter.exact.prevent="handleSend"
/>
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
</div>
</footer>
</template>
<el-empty v-else description="请选择会话" />
</main>
</div>
<TransferDialog
v-if="active"
v-model="transferVisible"
:conversation-id="active.id"
@success="handleTransferSuccess"
/>
<QuickReplyDialog
v-model="quickReplyVisible"
@success="loadQuickReplies"
/>
</section>
</template>
<style scoped>
.admin-page {
display: grid;
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
.page-head {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-head h1 {
margin: 0;
font-size: 22px;
}
.page-head p {
margin: 6px 0 0;
color: #6b7280;
}
.head-right {
display: flex;
gap: 8px;
}
.chat-workbench {
display: grid;
min-height: 0;
grid-template-columns: 330px minmax(0, 1fr);
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.conversation-pane {
min-height: 0;
overflow-y: auto;
border-right: 1px solid #e5e7eb;
background: #f8fafc;
}
.filter-tabs {
padding: 12px;
border-bottom: 1px solid #e5e7eb;
}
.conversation-row {
position: relative;
display: block;
width: 100%;
padding: 14px;
border: 0;
border-bottom: 1px solid #e5e7eb;
background: transparent;
text-align: left;
}
.conversation-row.active {
background: #eef6ff;
}
.row-title {
display: flex;
gap: 10px;
align-items: center;
}
.row-title strong {
min-width: 0;
flex: 1;
overflow: hidden;
color: #111827;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-title span {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-row p {
margin: 8px 24px 0 0;
overflow: hidden;
color: #6b7280;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.support-name {
color: #8a94a6;
font-size: 12px;
}
.row-meta em {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-style: normal;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.message-pane {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.message-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e5e7eb;
}
.head-title {
flex: 1;
min-width: 0;
}
.head-title h2 {
margin: 0;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.head-title p {
margin: 6px 0 0;
color: #6b7280;
font-size: 13px;
}
.head-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.head-actions a {
text-decoration: none;
}
.message-list {
min-height: 0;
overflow-y: auto;
padding: 18px;
background: #f3f6fa;
}
.message-row {
max-width: 70%;
margin-bottom: 14px;
}
.message-row.self {
margin-left: auto;
text-align: right;
}
.message-row.system {
max-width: none;
text-align: center;
}
.message-row small {
display: block;
margin-bottom: 5px;
color: #8a94a6;
}
.message-row p {
display: inline-block;
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: #fff;
color: #111827;
line-height: 1.5;
text-align: left;
}
.message-row.self p {
background: #dff5eb;
}
.message-attachments {
display: grid;
justify-items: start;
gap: 6px;
}
.message-row.self .message-attachments {
justify-items: end;
}
.message-row.system span {
display: inline-block;
padding: 5px 10px;
border-radius: 8px;
background: #e5e7eb;
color: #6b7280;
font-size: 12px;
}
.composer {
border-top: 1px solid #e5e7eb;
}
.composer-tools {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 14px;
border-bottom: 1px solid #f0f0f0;
}
.hidden-file {
display: none;
}
.pending-attachments {
display: flex;
gap: 8px;
padding: 10px 14px 0;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 84px;
height: 84px;
}
.pending-item :deep(.chat-image-button img) {
height: 84px;
}
.remove-attachment {
position: absolute;
top: 4px;
right: 4px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
cursor: pointer;
}
.composer-input {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
gap: 12px;
align-items: end;
padding: 14px;
}
.reply-title {
font-weight: 500;
margin-right: 8px;
}
.reply-preview {
color: #9ca3af;
font-size: 12px;
}
</style>