Files
zyt/admin/src/views/chat/index.vue
T
2026-04-21 09:49:44 +08:00

1174 lines
38 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.
<template>
<div class="qywx-im">
<el-alert
v-if="archiveStatus && !archiveStatus.available"
:title="archiveWarning"
type="warning"
:closable="false"
show-icon
class="archive-warning"
/>
<div class="im-layout">
<!-- 会话列表 -->
<section class="col-session">
<header class="col-header">
<div class="col-title">会话</div>
<div class="col-actions">
<el-tooltip content="刷新">
<el-button
size="small"
link
:loading="sessionPager.loading"
@click="refreshSession(true)"
>
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="手动拉一次存档">
<el-button
size="small"
link
:disabled="!archiveStatus?.available"
:loading="pullingArchive"
@click="onPullArchive"
>
<el-icon><Download /></el-icon>
</el-button>
</el-tooltip>
</div>
</header>
<div class="col-filters">
<el-select
v-model="sessionQuery.staff_userid"
placeholder="全部员工"
clearable
filterable
size="default"
style="width: 100%"
@change="refreshSession()"
>
<el-option
v-for="s in staffList"
:key="s.userid"
:label="(s.name || s.userid) + '' + s.userid + ''"
:value="s.userid"
>
<div class="staff-option">
<el-avatar :size="20" :src="s.avatar">{{ (s.name || '').slice(-2) }}</el-avatar>
<span>{{ s.name }}</span>
<span class="staff-userid">{{ s.userid }}</span>
</div>
</el-option>
</el-select>
<el-input
v-model="sessionQuery.keyword"
placeholder="搜索客户或消息"
clearable
size="default"
@keyup.enter="refreshSession()"
@clear="refreshSession()"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-checkbox v-model="onlyUnread" @change="refreshSession()">
仅未读
</el-checkbox>
</div>
<div v-loading="sessionPager.loading" class="session-list">
<div v-if="!sessionPager.lists.length && !sessionPager.loading" class="empty-tip">
<el-empty description="暂无会话" :image-size="80" />
</div>
<div
v-for="session in sessionPager.lists as QywxMsgSession[]"
:key="session.id"
class="session-item"
:class="{ active: activeSession?.id === session.id }"
@click="selectSession(session)"
>
<el-badge
:value="session.unread_staff"
:hidden="!session.unread_staff"
:max="99"
>
<el-avatar :size="42" :src="session.customer?.avatar || ''">
{{ sessionDisplayName(session).slice(-2) }}
</el-avatar>
</el-badge>
<div class="session-meta">
<div class="session-top">
<span class="session-name">{{ sessionDisplayName(session) }}</span>
<span class="session-time">{{ formatRelativeTime(session.last_msg_time) }}</span>
</div>
<div class="session-bottom">
<span class="session-preview">
{{ renderPreview(session) }}
</span>
<el-tag
v-if="session.session_type === 2"
size="small"
type="info"
class="session-tag"
>
</el-tag>
</div>
<div v-if="session.staff" class="session-staff">
员工: {{ session.staff.name || session.staff_userid }}
</div>
</div>
</div>
<div
v-if="sessionPager.lists.length < sessionPager.count"
class="session-load-more"
>
<el-button link type="primary" @click="loadMoreSessions">
加载更多
</el-button>
</div>
</div>
</section>
<!-- 消息流 -->
<section class="col-message">
<template v-if="!activeSession">
<div class="no-session">
<el-empty description="选择左侧会话查看消息">
<template #image>
<el-icon :size="60" color="#ccc"><ChatDotRound /></el-icon>
</template>
</el-empty>
</div>
</template>
<template v-else>
<header class="col-header">
<div class="msg-header-title">
<div class="msg-customer-name">
{{ sessionDisplayName(activeSession) }}
</div>
<div class="msg-customer-sub">
<template v-if="activeSession.customer?.corp_name">
{{ activeSession.customer.corp_name }} ·
</template>
代发员工: {{ activeSession.staff?.name || activeSession.staff_userid }}
<el-tag
v-if="activeSession.session_type === 2"
size="small"
type="info"
class="ml-1"
>
群聊
</el-tag>
</div>
</div>
<div class="col-actions">
<el-tooltip content="刷新消息">
<el-button link size="small" :loading="loadingMsg" @click="reloadMessages()">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
</header>
<div ref="messageListRef" class="message-list" @scroll="onMessageScroll">
<div v-if="hasMoreHistory" class="load-history">
<el-button
link
type="primary"
:loading="loadingHistory"
@click="loadHistory"
>
加载更早的消息
</el-button>
</div>
<div v-else-if="messages.length" class="load-history end-tip">
—— 没有更早的消息了 ——
</div>
<div v-if="!messages.length && !loadingMsg" class="empty-tip">
<el-empty description="暂无消息" :image-size="80" />
</div>
<MessageBubble
v-for="msg in messages"
:key="msg.id"
:message="msg"
/>
</div>
<SendPanel
:can-send-message="canSendMessage"
@send="onSendMessage"
/>
</template>
</section>
<!-- 客户资料 & 发送任务 -->
<section class="col-profile">
<template v-if="activeSession">
<el-tabs v-model="profileTab" class="profile-tabs">
<el-tab-pane label="客户资料" name="profile">
<div class="profile-panel">
<el-avatar :size="72" :src="activeSession.customer?.avatar || ''">
{{ sessionDisplayName(activeSession).slice(-2) }}
</el-avatar>
<div class="profile-name">
{{ sessionDisplayName(activeSession) }}
</div>
<div class="profile-fields">
<div class="field">
<span class="label">客户类型</span>
<span class="value">
{{ activeSession.customer?.type === 2 ? '@微信' : '@企业微信' }}
</span>
</div>
<div class="field">
<span class="label">外部 ID</span>
<span class="value ellipsis" :title="activeSession.external_userid">
{{ activeSession.external_userid || '-' }}
</span>
</div>
<div v-if="activeSession.customer?.corp_name" class="field">
<span class="label">所属企业</span>
<span class="value">{{ activeSession.customer.corp_name }}</span>
</div>
<div v-if="activeSession.customer?.gender" class="field">
<span class="label">性别</span>
<span class="value">
{{ activeSession.customer.gender === 1 ? '男' : activeSession.customer.gender === 2 ? '女' : '未知' }}
</span>
</div>
<div class="field">
<span class="label">代发员工</span>
<span class="value">
{{ activeSession.staff?.name || activeSession.staff_userid }}
</span>
</div>
<div class="field">
<span class="label">最近消息</span>
<span class="value">
{{ formatTime(activeSession.last_msg_time) }}
</span>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="`发送任务(${sendTasks.length})`" name="tasks">
<div v-loading="loadingTasks" class="tasks-panel">
<div v-if="!sendTasks.length" class="empty-tip">
<el-empty description="暂无任务" :image-size="80" />
</div>
<div
v-for="task in sendTasks"
:key="task.id"
class="task-item"
>
<div class="task-top">
<el-tag
:type="taskStatusType(task.status)"
size="small"
>
{{ taskStatusLabel(task.status) }}
</el-tag>
<span class="task-time">
{{ formatTime(task.create_time) }}
</span>
</div>
<div class="task-body">
<div v-if="task.msg_payload?.text?.content" class="task-text">
{{ task.msg_payload.text.content }}
</div>
<div v-if="task.msg_payload?.attachments?.length" class="task-attachments">
附件:
<el-tag
v-for="(att, i) in task.msg_payload.attachments"
:key="i"
size="small"
type="info"
class="mr-1"
>
{{ attachmentLabel(att.msgtype) }}
</el-tag>
</div>
<div v-if="task.error" class="task-error">
{{ task.error }}
</div>
</div>
<div class="task-actions">
<el-button
size="small"
link
type="primary"
@click="openTaskDetail(task)"
>
送达详情
</el-button>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
<template v-else>
<div class="no-session">
<div class="tips">
<h4>企业微信 IM</h4>
<p>选择一个会话查看客户资料与群发任务</p>
<el-divider />
<p class="text-sm">
· 实时消息依赖会话内容存档」,由后台计划任务每 30 秒拉取一次
</p>
<p class="text-sm">
· 发送使用企业群发能力员工手机端确认后送达客户
</p>
</div>
</div>
</template>
</section>
</div>
<!-- 任务送达详情弹窗 -->
<el-dialog
v-model="showTaskDetail"
title="群发送达详情"
width="640px"
top="8vh"
>
<el-table
v-if="currentTaskDetail"
v-loading="loadingTaskDetail"
:data="currentTaskDetail.detail_list"
border
size="small"
max-height="420"
>
<el-table-column label="外部客户" prop="external_userid" min-width="200" />
<el-table-column label="员工" prop="userid" min-width="120" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="detailStatusType(row.status)" size="small">
{{ detailStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="送达时间" width="160">
<template #default="{ row }">
{{ row.send_time ? formatTime(row.send_time) : '-' }}
</template>
</el-table-column>
</el-table>
<div v-if="currentTaskDetail?.next_cursor" class="mt-2 text-center">
<el-button link type="primary" @click="loadNextTaskDetailPage">
加载更多
</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
ChatDotRound,
Download,
Refresh,
Search
} from '@element-plus/icons-vue'
import feedback from '@/utils/feedback'
import { timeFormat } from '@/utils/util'
import { usePaging } from '@/hooks/usePaging'
import MessageBubble from './components/MessageBubble.vue'
import SendPanel from './components/SendPanel.vue'
import {
qywxMsgArchiveLists,
qywxMsgArchiveStatus,
qywxMsgCustomerOfStaff,
qywxMsgMarkRead,
qywxMsgPullArchive,
qywxMsgSend,
qywxMsgSendTaskDetail,
qywxMsgSendTaskLists,
qywxMsgSessionLists,
qywxMsgStaffList,
type QywxMsgArchiveItem,
type QywxMsgSession,
type QywxSendPayload,
type QywxSendTask
} from '@/api/qywx-msg'
interface StaffItem {
id: number
name: string
avatar: string
userid: string
department?: string
}
/* ==================== 会话存档状态 ==================== */
const archiveStatus = ref<{
enabled: boolean
available: boolean
error?: string
lib_path?: string
public_key_ver?: number
has_private_key?: boolean
} | null>(null)
const pullingArchive = ref(false)
const archiveWarning = computed(() => {
if (!archiveStatus.value) return ''
const s = archiveStatus.value
if (!s.enabled) {
return '「会话内容存档」未启用,实时消息不可用。请在后端 pay.php 中开启 msgaudit_enabled 并配置 SDK 动态库与 RSA 私钥。'
}
return `会话存档 SDK 不可用:${s.error || '未知错误'}`
})
async function loadArchiveStatus() {
try {
archiveStatus.value = (await qywxMsgArchiveStatus()) as any
} catch (_) {
/* ignore */
}
}
async function onPullArchive() {
pullingArchive.value = true
try {
await qywxMsgPullArchive(5, true)
feedback.msgSuccess('已触发一次拉取,稍后刷新可见')
await refreshSession(true)
if (activeSession.value) {
await reloadMessages()
}
} catch (err: any) {
feedback.msgError(err?.msg || err?.message || '拉取失败')
} finally {
pullingArchive.value = false
}
}
/* ==================== 员工列表 ==================== */
const staffList = ref<StaffItem[]>([])
async function loadStaff() {
try {
const res: any = await qywxMsgStaffList()
staffList.value = (Array.isArray(res) ? res : res?.lists || []) as StaffItem[]
} catch (_) {
staffList.value = []
}
}
/* ==================== 会话列表 ==================== */
const sessionQuery = ref<{ staff_userid: string; keyword: string; only_unread: 0 | 1 }>({
staff_userid: '',
keyword: '',
only_unread: 0
})
const onlyUnread = ref(false)
watch(onlyUnread, (v) => {
sessionQuery.value.only_unread = v ? 1 : 0
})
const { pager: sessionPager, getLists: fetchSessions } = usePaging({
fetchFun: qywxMsgSessionLists,
params: sessionQuery.value,
size: 30
})
function refreshSession(keepPage = false) {
if (!keepPage) sessionPager.page = 1
return fetchSessions()
}
async function loadMoreSessions() {
const existing = [...(sessionPager.lists || [])]
sessionPager.page += 1
try {
await fetchSessions()
const fresh = (sessionPager.lists || []) as QywxMsgSession[]
const seen = new Set(existing.map((s: any) => s.id))
const merged: QywxMsgSession[] = [
...(existing as QywxMsgSession[]),
...fresh.filter((s) => !seen.has(s.id))
]
sessionPager.lists = merged
} catch (_) {
/* ignore */
}
}
/* ==================== 选中会话 & 消息流 ==================== */
const activeSession = ref<QywxMsgSession | null>(null)
const messages = ref<QywxMsgArchiveItem[]>([])
const loadingMsg = ref(false)
const loadingHistory = ref(false)
const hasMoreHistory = ref(false)
const earliestTime = ref<number>(0)
const latestTime = ref<number>(0)
const messageListRef = ref<HTMLDivElement | null>(null)
const canSendMessage = computed(
() => !!activeSession.value && activeSession.value.session_type !== 2
)
async function selectSession(session: QywxMsgSession) {
if (activeSession.value?.id === session.id) return
activeSession.value = session
messages.value = []
earliestTime.value = 0
latestTime.value = 0
hasMoreHistory.value = false
await reloadMessages()
await loadSessionTasks()
// 清零未读(本地 + 后端)
if (session.unread_staff > 0) {
try {
await qywxMsgMarkRead(session.id)
session.unread_staff = 0
} catch (_) {
/* ignore */
}
}
profileTab.value = 'profile'
}
async function reloadMessages() {
if (!activeSession.value) return
loadingMsg.value = true
try {
const res: any = await qywxMsgArchiveLists({
session_id: activeSession.value.id,
page_no: 1,
page_size: 30
})
const lists: QywxMsgArchiveItem[] = res?.lists || []
messages.value = lists
if (lists.length) {
earliestTime.value = lists[0].send_time
latestTime.value = lists[lists.length - 1].send_time
}
hasMoreHistory.value = lists.length >= 30
await nextTick()
scrollToBottom()
} catch (err: any) {
feedback.msgError(err?.msg || err?.message || '加载消息失败')
} finally {
loadingMsg.value = false
}
}
async function loadHistory() {
if (!activeSession.value || loadingHistory.value) return
loadingHistory.value = true
const prevHeight = messageListRef.value?.scrollHeight || 0
try {
const res: any = await qywxMsgArchiveLists({
session_id: activeSession.value.id,
page_no: 1,
page_size: 30,
before_time: earliestTime.value
})
const lists: QywxMsgArchiveItem[] = res?.lists || []
if (lists.length) {
earliestTime.value = lists[0].send_time
messages.value = [...lists, ...messages.value]
}
hasMoreHistory.value = lists.length >= 30
await nextTick()
if (messageListRef.value) {
messageListRef.value.scrollTop =
messageListRef.value.scrollHeight - prevHeight
}
} finally {
loadingHistory.value = false
}
}
async function pollNewMessages() {
if (!activeSession.value) return
try {
const res: any = await qywxMsgArchiveLists({
session_id: activeSession.value.id,
page_no: 1,
page_size: 50,
after_time: latestTime.value
})
const lists: QywxMsgArchiveItem[] = res?.lists || []
if (!lists.length) return
const existingIds = new Set(messages.value.map((m) => m.id))
const fresh = lists.filter((m) => !existingIds.has(m.id))
if (!fresh.length) return
messages.value = [...messages.value, ...fresh]
latestTime.value = fresh[fresh.length - 1].send_time
const el = messageListRef.value
const nearBottom = el
? el.scrollHeight - el.scrollTop - el.clientHeight < 120
: true
if (nearBottom) {
await nextTick()
scrollToBottom()
}
} catch (_) {
/* silent */
}
}
function onMessageScroll() {
// 占位:滚动到顶自动拉历史可在此触发
const el = messageListRef.value
if (!el) return
if (el.scrollTop < 40 && hasMoreHistory.value && !loadingHistory.value) {
loadHistory()
}
}
function scrollToBottom() {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
}
/* ==================== 发送 ==================== */
async function onSendMessage(payload: QywxSendPayload) {
if (!activeSession.value) return
try {
await qywxMsgSend({
sender_userid: activeSession.value.staff_userid,
external_userids: [activeSession.value.external_userid],
msg_payload: payload,
chat_type: 'single'
})
feedback.msgSuccess('已创建群发任务,员工手机确认后送达客户')
await loadSessionTasks()
profileTab.value = 'tasks'
} catch (err: any) {
feedback.msgError(err?.msg || err?.message || '发送失败')
}
}
/* ==================== 任务面板 ==================== */
const profileTab = ref<'profile' | 'tasks'>('profile')
const sendTasks = ref<QywxSendTask[]>([])
const loadingTasks = ref(false)
async function loadSessionTasks() {
if (!activeSession.value) return
loadingTasks.value = true
try {
const res: any = await qywxMsgSendTaskLists({
sender_userid: activeSession.value.staff_userid,
page_no: 1,
page_size: 50
})
const all: QywxSendTask[] = res?.lists || []
sendTasks.value = all.filter((t) =>
(t.external_userids || []).includes(activeSession.value!.external_userid)
)
} finally {
loadingTasks.value = false
}
}
function taskStatusLabel(s: number) {
const map: Record<number, string> = {
0: '待提交',
1: '已提交',
2: '已送达',
3: '失败'
}
return map[s] ?? `状态${s}`
}
function taskStatusType(s: number): 'info' | 'primary' | 'success' | 'danger' | 'warning' {
const map: Record<number, 'info' | 'primary' | 'success' | 'danger' | 'warning'> = {
0: 'info',
1: 'primary',
2: 'success',
3: 'danger'
}
return map[s] ?? 'info'
}
function attachmentLabel(t: string) {
const m: Record<string, string> = {
image: '图片',
video: '视频',
file: '文件',
link: '链接',
miniprogram: '小程序'
}
return m[t] || t
}
/* ==================== 任务详情 ==================== */
const showTaskDetail = ref(false)
const loadingTaskDetail = ref(false)
const currentTaskId = ref(0)
const currentTaskDetail = ref<{
detail_list: Array<{
external_userid: string
userid: string
status: number
send_time: number
}>
next_cursor: string
task?: any
} | null>(null)
async function openTaskDetail(task: QywxSendTask) {
currentTaskId.value = task.id
currentTaskDetail.value = { detail_list: [], next_cursor: '' }
showTaskDetail.value = true
loadingTaskDetail.value = true
try {
const res: any = await qywxMsgSendTaskDetail(task.id, '')
currentTaskDetail.value = {
detail_list: res?.detail_list || [],
next_cursor: res?.next_cursor || '',
task: res?.task
}
} catch (err: any) {
feedback.msgError(err?.msg || err?.message || '加载失败')
} finally {
loadingTaskDetail.value = false
}
}
async function loadNextTaskDetailPage() {
if (!currentTaskDetail.value?.next_cursor) return
loadingTaskDetail.value = true
try {
const res: any = await qywxMsgSendTaskDetail(
currentTaskId.value,
currentTaskDetail.value.next_cursor
)
currentTaskDetail.value = {
detail_list: [
...currentTaskDetail.value.detail_list,
...(res?.detail_list || [])
],
next_cursor: res?.next_cursor || '',
task: res?.task
}
} finally {
loadingTaskDetail.value = false
}
}
function detailStatusLabel(s: number) {
switch (s) {
case 0:
return '未发送'
case 1:
return '已送达'
case 2:
return '被拒收'
case 3:
return '已送达(未读)'
default:
return `状态${s}`
}
}
function detailStatusType(s: number): 'info' | 'success' | 'warning' | 'primary' {
if (s === 1) return 'success'
if (s === 2) return 'warning'
if (s === 3) return 'primary'
return 'info'
}
/* ==================== 展示工具 ==================== */
function sessionDisplayName(session: QywxMsgSession) {
if (session.customer?.name) return session.customer.name
if (session.session_type === 2) return `群聊 ${session.roomid.slice(-6)}`
return session.external_userid || '未命名客户'
}
function renderPreview(session: QywxMsgSession) {
const summary = session.last_msg_summary || ''
const type = session.last_msg_type
if (summary) return summary
if (type === 'image') return '[图片]'
if (type === 'video') return '[视频]'
if (type === 'voice') return '[语音]'
if (type === 'file') return '[文件]'
if (type === 'link') return '[链接]'
if (type === 'weapp') return '[小程序]'
if (type === 'chatrecord') return '[聊天记录]'
return type ? `[${type}]` : '暂无消息'
}
function formatTime(sec: number) {
if (!sec) return '-'
return timeFormat(sec * 1000, 'yyyy-mm-dd hh:MM')
}
function formatRelativeTime(sec: number) {
if (!sec) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - sec
if (diff < 60) return `${diff}秒前`
if (diff < 3600) return `${Math.floor(diff / 60)}分前`
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
const d = new Date(sec * 1000)
return `${d.getMonth() + 1}-${d.getDate()}`
}
/* ==================== 生命周期 & 定时刷新 ==================== */
let sessionTimer: number | null = null
let messageTimer: number | null = null
onMounted(async () => {
await Promise.all([loadArchiveStatus(), loadStaff()])
await refreshSession()
sessionTimer = window.setInterval(() => {
fetchSessions({ silent: true }).catch(() => {})
}, 15000)
messageTimer = window.setInterval(() => {
pollNewMessages()
}, 8000)
})
onBeforeUnmount(() => {
if (sessionTimer) window.clearInterval(sessionTimer)
if (messageTimer) window.clearInterval(messageTimer)
})
</script>
<style lang="scss" scoped>
.qywx-im {
display: flex;
flex-direction: column;
height: calc(100vh - 90px);
background: #f5f7f9;
.archive-warning {
margin-bottom: 8px;
}
.im-layout {
display: flex;
flex: 1;
overflow: hidden;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
}
}
.col-session {
width: 300px;
display: flex;
flex-direction: column;
border-right: 1px solid #ebeef5;
}
.col-message {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #f0f2f5;
}
.col-profile {
width: 320px;
border-left: 1px solid #ebeef5;
background: #fafbfc;
display: flex;
flex-direction: column;
overflow: hidden;
}
.col-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #ebeef5;
background: #fff;
flex-shrink: 0;
.col-title {
font-size: 16px;
font-weight: 500;
}
.col-actions {
display: flex;
gap: 8px;
}
}
.col-filters {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-bottom: 1px solid #ebeef5;
background: #fff;
}
.session-list {
flex: 1;
overflow-y: auto;
}
.session-item {
display: flex;
gap: 10px;
padding: 12px;
cursor: pointer;
border-bottom: 1px solid #f0f2f5;
transition: background 0.15s;
&:hover {
background: #f5f7fa;
}
&.active {
background: #e6f1fc;
}
.session-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
}
.session-top {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: baseline;
}
.session-name {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.session-time {
font-size: 12px;
color: #999;
flex-shrink: 0;
}
.session-bottom {
display: flex;
align-items: center;
gap: 6px;
}
.session-preview {
flex: 1;
font-size: 12px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-staff {
font-size: 11px;
color: #aaa;
}
}
.session-load-more {
text-align: center;
padding: 10px 0;
}
.staff-option {
display: flex;
align-items: center;
gap: 6px;
.staff-userid {
color: #999;
font-size: 12px;
}
}
/* 消息列 */
.no-session {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
color: #999;
.tips {
max-width: 260px;
padding: 24px;
text-align: left;
h4 {
font-size: 16px;
margin-bottom: 8px;
}
.text-sm {
font-size: 12px;
color: #777;
line-height: 1.6;
}
}
}
.msg-header-title {
display: flex;
flex-direction: column;
gap: 2px;
.msg-customer-name {
font-size: 15px;
font-weight: 500;
}
.msg-customer-sub {
font-size: 12px;
color: #888;
}
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 12px 0;
scroll-behavior: smooth;
.load-history {
text-align: center;
padding: 6px 0;
}
.end-tip {
color: #bbb;
font-size: 12px;
}
}
.empty-tip {
display: flex;
justify-content: center;
padding: 40px 0;
}
/* 右侧资料 */
.profile-tabs {
height: 100%;
display: flex;
flex-direction: column;
:deep(.el-tabs__content) {
flex: 1;
overflow: auto;
}
:deep(.el-tabs__header) {
margin: 0;
padding: 0 12px;
}
}
.profile-panel {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.profile-name {
font-size: 16px;
font-weight: 500;
}
.profile-fields {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
.field {
display: flex;
justify-content: space-between;
font-size: 13px;
gap: 8px;
.label {
color: #888;
flex-shrink: 0;
}
.value {
color: #333;
text-align: right;
overflow-wrap: anywhere;
}
.ellipsis {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.tasks-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
.task-item {
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px;
background: #fff;
.task-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
.task-time {
font-size: 12px;
color: #999;
}
}
.task-body {
font-size: 13px;
color: #333;
.task-text {
margin-bottom: 4px;
white-space: pre-wrap;
word-break: break-word;
}
.task-attachments {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.task-error {
color: #e6a23c;
font-size: 12px;
margin-top: 4px;
}
}
.task-actions {
text-align: right;
}
}
}
</style>