新增订单群聊
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
fetchAdminChat,
|
||||
fetchAdminChatMessages,
|
||||
fetchAdminChats,
|
||||
markAdminChatRead,
|
||||
sendAdminChatMessage,
|
||||
type ChatConversation,
|
||||
type ChatMessage,
|
||||
} from '@/api/chats'
|
||||
import { formatDateMinute } from '@/utils/time'
|
||||
|
||||
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 content = ref('')
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
let timer: number | undefined
|
||||
|
||||
const activeMembers = computed(() => {
|
||||
const participants = active.value?.participants || []
|
||||
return participants.map(item => `${roleLabel(item.role)}:${item.display_name}`).join(' / ')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConversations()
|
||||
timer = window.setInterval(async () => {
|
||||
await loadConversations(false)
|
||||
if (active.value) await loadMessages(active.value.id, false)
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) window.clearInterval(timer)
|
||||
})
|
||||
|
||||
async function loadConversations(showLoading = true) {
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const res = await fetchAdminChats(1, 100)
|
||||
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 openConversation(item: ChatConversation) {
|
||||
messageLoading.value = true
|
||||
try {
|
||||
active.value = await fetchAdminChat(item.id)
|
||||
await loadMessages(item.id)
|
||||
await markAdminChatRead(item.id)
|
||||
await loadConversations(false)
|
||||
} 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()
|
||||
if (!active.value || !text || sending.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const message = await sendAdminChatMessage(active.value.id, text)
|
||||
messages.value = [...messages.value, message]
|
||||
content.value = ''
|
||||
await nextTick()
|
||||
scrollBottom()
|
||||
await loadConversations(false)
|
||||
} catch {
|
||||
ElMessage.error('发送失败')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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: '客服',
|
||||
system: '系统',
|
||||
}
|
||||
return map[role] || '成员'
|
||||
}
|
||||
|
||||
function senderLabel(item: ChatMessage) {
|
||||
if (item.sender_type === 'system') return '系统'
|
||||
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>客服群聊</h1>
|
||||
<p>处理订单三方沟通</p>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div class="chat-workbench">
|
||||
<aside class="conversation-pane" v-loading="loading">
|
||||
<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>{{ item.title }}</strong>
|
||||
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
|
||||
</div>
|
||||
<p>{{ item.last_message_preview || '订单群聊已创建' }}</p>
|
||||
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
||||
</button>
|
||||
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
|
||||
</aside>
|
||||
|
||||
<main class="message-pane">
|
||||
<template v-if="active">
|
||||
<header class="message-head">
|
||||
<div>
|
||||
<h2>{{ active.title }}</h2>
|
||||
<p>{{ activeMembers }}</p>
|
||||
</div>
|
||||
<RouterLink :to="`/admin/orders/${active.order_id}`">查看订单</RouterLink>
|
||||
</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>{{ item.content }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="composer">
|
||||
<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="!content.trim()" @click="handleSend">发送</el-button>
|
||||
</footer>
|
||||
</template>
|
||||
<el-empty v-else description="请选择会话" />
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.chat-workbench {
|
||||
display: grid;
|
||||
min-height: 640px;
|
||||
grid-template-columns: 330px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.conversation-pane {
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.conversation-row em {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
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;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.message-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.message-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.message-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
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-row.system span {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 88px;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
padding: 14px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user