1174 lines
38 KiB
Vue
1174 lines
38 KiB
Vue
<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>
|