Files
codePortAdmin/src/views/support/conversations/index.vue
2025-12-28 22:09:44 +08:00

833 lines
23 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="support-page">
<a-page-header title="客服管理" sub-title="聚焦前台全渠道会话追踪实时处理效率">
<template #tags>
<a-tag color="volcano">待接入 {{ metrics.waitingCount }}</a-tag>
<a-tag color="blue">进行中 {{ metrics.activeCount }}</a-tag>
<a-tag color="purple">待跟进 {{ metrics.pendingCount }}</a-tag>
<a-tag color="green">今日结单 {{ metrics.resolvedToday }}</a-tag>
</template>
<template #extra>
<a-space>
<span class="metric-extra">满意度 {{ metrics.satisfaction }}/5</span>
<span class="metric-extra">首响 {{ metrics.avgFirstResponse }} 分钟</span>
</a-space>
</template>
</a-page-header>
<a-row :gutter="16" class="metric-row">
<a-col v-for="card in overviewCards" :key="card.key" :span="6">
<a-card class="metric-card">
<div class="metric-header">
<component :is="card.icon" :style="{ color: card.color }" class="metric-icon" />
<span class="metric-title">{{ card.title }}</span>
</div>
<div class="metric-value">{{ card.value }}</div>
<div class="metric-desc">{{ card.desc }}</div>
</a-card>
</a-col>
</a-row>
<a-card class="filter-card" :bordered="false">
<a-form layout="inline" :model="searchForm">
<a-form-item label="关键词">
<a-input v-model:value="searchForm.keyword" placeholder="搜索会话编码/客户/客服" allow-clear style="width: 220px" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchForm.status" placeholder="全部状态" style="width: 140px">
<a-select-option v-for="option in statusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="渠道">
<a-select v-model:value="searchForm.channel" placeholder="全部渠道" style="width: 140px">
<a-select-option v-for="option in channelOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="优先级">
<a-select v-model:value="searchForm.priority" placeholder="全部优先级" style="width: 140px">
<a-select-option v-for="option in priorityOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="日期范围">
<a-range-picker v-model:value="searchForm.dateRange" value-format="YYYY-MM-DD" format="YYYY-MM-DD"
style="width: 280px" allow-clear />
</a-form-item>
<a-form-item>
<a-switch v-model:checked="searchForm.vipOnly" /> 仅看 VIP
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<SearchOutlined /> 搜索
</a-button>
<a-button @click="handleReset">
<ReloadOutlined /> 重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card class="table-card" :bordered="false">
<template #title>会话列表</template>
<template #extra>
<a-space>
<a-button type="primary" :disabled="!selectedRowKeys.length" :loading="batchResolving"
@click="handleBatchResolve">
<CheckCircleOutlined /> 批量结单
</a-button>
</a-space>
</template>
<a-table row-key="id" size="middle" :columns="columns" :data-source="conversationList" :loading="loading"
:pagination="pagination" :row-selection="rowSelection" :scroll="{ y: 'calc(100vh - 420px)' }"
@change="handleTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'session'">
<div class="session-cell">
<div class="session-top">
<span class="session-code">{{ record.sessionCode }}</span>
<a-tag :color="getChannelInfo(record.channel).color">{{ getChannelInfo(record.channel).label }}</a-tag>
<a-tag :color="getPriorityInfo(record.priority).color">
{{ getPriorityInfo(record.priority).label }}
</a-tag>
<a-tag v-if="record.customer.vip" color="gold">VIP</a-tag>
</div>
<div class="session-customer">
<a-avatar :src="record.customer.avatar" :size="36" />
<div class="customer-info">
<div class="customer-name">
{{ record.customer.nickname }}
<span class="customer-level">{{ record.customer.level }}</span>
</div>
<div class="customer-meta">
<EnvironmentOutlined /> {{ record.customer.city }}
<span class="customer-intent">{{ record.autoDetectedIntent }}</span>
</div>
</div>
</div>
<div class="session-tags">
<a-tag v-for="tag in record.tags" :key="tag.id" :color="tag.color">{{ tag.name }}</a-tag>
</div>
</div>
</template>
<template v-else-if="column.key === 'lastMessage'">
<div class="message-cell">
<div class="message-text">{{ record.lastMessage }}</div>
<div class="message-meta">
<ClockCircleOutlined />
<span>{{ formatDateTime(record.lastMessageAt) }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'stats'">
<div class="stat-block">
<span>
<MessageOutlined /> {{ record.totalMessages }}
</span>
<span>
<BellOutlined /> 未读 {{ record.unreadCount }}
</span>
<span>
<DashboardOutlined /> 等待 {{ record.waitingTime }} 分钟
</span>
</div>
</template>
<template v-else-if="column.key === 'agent'">
<div class="agent-cell">
<template v-if="record.assignedAgent">
<a-avatar :src="record.assignedAgent.avatar" :size="36" />
<div class="agent-info">
<span class="agent-name">{{ record.assignedAgent.name }}</span>
<span class="agent-title">{{ record.assignedAgent.title }}</span>
</div>
</template>
<a-tag v-else color="orange">未分配</a-tag>
<a-dropdown trigger="click">
<a-button type="link" class="assign-btn">
<UserSwitchOutlined /> 转派
</a-button>
<template #overlay>
<a-menu @click="({ key }) => handleAssign(record as ConversationSession, Number(key))">
<a-menu-item v-for="agent in supportAgents" :key="agent.id">
<span>{{ agent.name }} · {{ agent.title }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusInfo(record.status).color">
{{ getStatusInfo(record.status).label }}
</a-tag>
</template>
<template v-else-if="column.key === 'updatedAt'">
{{ formatDateTime(record.lastMessageAt) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="showDetail(record as ConversationSession)">
<EyeOutlined /> 查看
</a-button>
<a-popconfirm title="确认将该会话标记为已结单?" ok-text="确认" cancel-text="取消"
@confirm="handleResolve(record as ConversationSession)">
<a-button type="link" size="small" :loading="updatingSessionId === record.id">
<CheckCircleOutlined /> 结单
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-drawer v-model:open="detailVisible" width="720px" placement="right"
:title="currentSession ? `会话 ${currentSession.sessionCode}` : '会话详情'">
<template #extra>
<a-button type="link" v-if="currentSession && currentSession.status !== 'resolved'"
:loading="updatingSessionId === currentSession.id" @click="handleResolve(currentSession)">
<CheckCircleOutlined /> 标记结单
</a-button>
</template>
<div v-if="currentSession" class="drawer-content">
<div class="drawer-section">
<div class="drawer-customer">
<a-avatar :src="currentSession.customer.avatar" :size="48" />
<div class="drawer-customer-info">
<div class="name-line">
<span class="name">{{ currentSession.customer.nickname }}</span>
<a-tag color="gold" v-if="currentSession.customer.vip">VIP</a-tag>
</div>
<div class="customer-extra">
<EnvironmentOutlined /> {{ currentSession.customer.city }}
<span>等级 {{ currentSession.customer.level }}</span>
</div>
</div>
</div>
<div class="drawer-meta">
<a-tag :color="channelMap[currentSession.channel].color">{{ channelMap[currentSession.channel].label
}}</a-tag>
<a-tag :color="priorityMap[currentSession.priority].color">{{ priorityMap[currentSession.priority].label
}}</a-tag>
<a-tag :color="statusMap[currentSession.status].color">{{ statusMap[currentSession.status].label }}</a-tag>
</div>
<div class="drawer-tags">
<a-tag v-for="tag in currentSession.tags" :key="tag.id" :color="tag.color">{{ tag.name }}</a-tag>
</div>
<div class="drawer-stats">
<div>
<span class="stat-label">来源</span>
<span>{{ currentSession.source }}</span>
</div>
<div>
<span class="stat-label">识别意图</span>
<span>{{ currentSession.autoDetectedIntent }}</span>
</div>
<div>
<span class="stat-label">首响</span>
<span>{{ currentSession.firstResponseAt ? formatDateTime(currentSession.firstResponseAt) : '待响应' }}</span>
</div>
</div>
</div>
<a-divider />
<div class="message-list">
<div v-for="message in currentSession.messages" :key="message.id" :class="['message-item', message.sender]">
<div class="drawer-message-meta">
<span class="meta-name">{{ message.senderName }}</span>
<span class="meta-time">{{ formatDateTime(message.timestamp) }}</span>
</div>
<div class="message-content">{{ message.content }}</div>
</div>
</div>
</div>
<a-empty v-else description="暂无会话数据" />
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import type { Key } from 'ant-design-vue/es/table/interface'
import {
SearchOutlined,
ReloadOutlined,
CustomerServiceOutlined,
MessageOutlined,
TeamOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
BellOutlined,
DashboardOutlined,
EyeOutlined,
EnvironmentOutlined,
UserSwitchOutlined
} from '@ant-design/icons-vue'
import type { ConversationAgent, ConversationChannel, ConversationPriority, ConversationSession, ConversationStatus } from '@/types'
import {
mockGetConversationList,
mockUpdateConversationStatus,
mockAssignConversationAgent,
mockGetSupportAgents
} from '@/mock'
import { formatDateTime } from '@/utils/common'
type StatusFilter = ConversationStatus | 'all'
type PriorityFilter = ConversationPriority | 'all'
type ChannelFilter = ConversationSession['channel'] | 'all'
const loading = ref(false)
const conversationList = ref<ConversationSession[]>([])
const selectedRowKeys = ref<Key[]>([])
const batchResolving = ref(false)
const updatingSessionId = ref<number | null>(null)
const detailVisible = ref(false)
const currentSession = ref<ConversationSession | null>(null)
const supportAgents = ref<ConversationAgent[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `${total}`
})
const metrics = reactive({
waitingCount: 0,
activeCount: 0,
pendingCount: 0,
resolvedToday: 0,
satisfaction: 0,
avgFirstResponse: 0
})
const searchForm = reactive({
keyword: '',
status: 'all' as StatusFilter,
channel: 'all' as ChannelFilter,
priority: 'all' as PriorityFilter,
vipOnly: false,
dateRange: undefined as [string, string] | undefined
})
const overviewCards = computed(() => [
{
key: 'waiting',
title: '待接入',
value: metrics.waitingCount,
desc: `平均等待 ${averageWaitingTime.value} 分钟`,
icon: CustomerServiceOutlined,
color: '#fa8c16'
},
{
key: 'progress',
title: '进行中',
value: metrics.activeCount,
desc: `首响 ${metrics.avgFirstResponse} 分钟`,
icon: TeamOutlined,
color: '#1890ff'
},
{
key: 'pending',
title: '待跟进',
value: metrics.pendingCount,
desc: '建议优先处理',
icon: BellOutlined,
color: '#722ed1'
},
{
key: 'satisfaction',
title: '满意度',
value: `${metrics.satisfaction}/5`,
desc: `今日结单 ${metrics.resolvedToday}`,
icon: MessageOutlined,
color: '#52c41a'
}
])
const averageWaitingTime = computed(() => {
if (!conversationList.value.length) return 0
const total = conversationList.value.reduce((sum, item) => sum + item.waitingTime, 0)
return Math.round(total / conversationList.value.length)
})
const statusOptions = [
{ label: '全部', value: 'all' },
{ label: '待接入', value: 'waiting' },
{ label: '进行中', value: 'active' },
{ label: '待跟进', value: 'pending' },
{ label: '已结单', value: 'resolved' }
]
const channelOptions = [
{ label: '全部渠道', value: 'all' },
{ label: 'App', value: 'app' },
{ label: 'Web', value: 'web' },
{ label: '企业微信', value: 'wechat' },
{ label: '小程序', value: 'miniapp' }
]
const priorityOptions = [
{ label: '全部优先级', value: 'all' },
{ label: '普通', value: 'normal' },
{ label: '加急', value: 'high' },
{ label: 'VIP', value: 'vip' }
]
const columns = [
{ title: '会话', key: 'session', width: 280 },
{ title: '最近内容', key: 'lastMessage', width: 260 },
{ title: '统计', key: 'stats', width: 200 },
{ title: '处理人', key: 'agent', width: 200 },
{ title: '状态', key: 'status', width: 100 },
{ title: '更新时间', key: 'updatedAt', width: 180 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const }
]
const statusMap: Record<ConversationStatus, { label: string; color: string }> = {
waiting: { label: '待接入', color: 'orange' },
active: { label: '进行中', color: 'blue' },
pending: { label: '待跟进', color: 'purple' },
resolved: { label: '已结单', color: 'green' },
closed: { label: '已关闭', color: 'default' }
}
const channelMap: Record<ConversationChannel, { label: string; color: string }> = {
app: { label: 'App', color: 'blue' },
web: { label: 'Web', color: 'geekblue' },
wechat: { label: '企业微信', color: 'cyan' },
miniapp: { label: '小程序', color: 'purple' }
}
const priorityMap: Record<ConversationPriority, { label: string; color: string }> = {
normal: { label: '普通', color: 'default' },
high: { label: '加急', color: 'orange' },
vip: { label: 'VIP', color: 'red' }
}
// 辅助函数用于处理模板中的类型安全访问
function getChannelInfo(channel: string): { label: string; color: string } {
return channelMap[channel as ConversationChannel] || { label: channel, color: 'default' }
}
function getPriorityInfo(priority: string): { label: string; color: string } {
return priorityMap[priority as ConversationPriority] || { label: priority, color: 'default' }
}
function getStatusInfo(status: string): { label: string; color: string } {
return statusMap[status as ConversationStatus] || { label: status, color: 'default' }
}
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: onSelectChange
}))
function onSelectChange(keys: Key[]) {
selectedRowKeys.value = keys
}
async function loadSessions() {
loading.value = true
try {
const res = await mockGetConversationList({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
status: searchForm.status,
channel: searchForm.channel,
priority: searchForm.priority,
vipOnly: searchForm.vipOnly,
dateRange: searchForm.dateRange
})
conversationList.value = res.list
pagination.total = res.total
Object.assign(metrics, res.metrics)
if (detailVisible.value && currentSession.value) {
const refreshed = res.list.find(item => item.id === currentSession.value?.id)
if (refreshed) {
currentSession.value = refreshed
}
}
} catch (error) {
console.error(error)
message.error('客服会话数据加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.current = 1
loadSessions()
}
function handleReset() {
searchForm.keyword = ''
searchForm.status = 'all'
searchForm.channel = 'all'
searchForm.priority = 'all'
searchForm.vipOnly = false
searchForm.dateRange = undefined
pagination.current = 1
loadSessions()
}
function handleTableChange(pag: TablePaginationConfig) {
pagination.current = pag.current || 1
pagination.pageSize = pag.pageSize || 10
loadSessions()
}
async function handleBatchResolve() {
if (!selectedRowKeys.value.length) return
batchResolving.value = true
try {
await Promise.all(selectedRowKeys.value.map(key => mockUpdateConversationStatus(Number(key), 'resolved')))
message.success(`已结单 ${selectedRowKeys.value.length} 条会话`)
selectedRowKeys.value = []
await loadSessions()
} catch (error) {
console.error(error)
message.error('批量结单失败,请稍后重试')
} finally {
batchResolving.value = false
}
}
async function handleResolve(record: ConversationSession) {
updatingSessionId.value = record.id
try {
await mockUpdateConversationStatus(record.id, 'resolved')
message.success('会话已结单')
await loadSessions()
} catch (error) {
console.error(error)
message.error('结单失败,请稍后重试')
} finally {
updatingSessionId.value = null
}
}
async function handleAssign(record: ConversationSession, agentId: number) {
updatingSessionId.value = record.id
try {
await mockAssignConversationAgent(record.id, agentId)
message.success('分配成功')
await loadSessions()
} catch (error) {
console.error(error)
message.error('分配失败,请稍后重试')
} finally {
updatingSessionId.value = null
}
}
function showDetail(record: ConversationSession) {
currentSession.value = record
detailVisible.value = true
}
onMounted(() => {
supportAgents.value = mockGetSupportAgents()
loadSessions()
})
</script>
<style scoped>
.support-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow: hidden;
}
.metric-row {
margin-bottom: 8px;
}
.metric-card {
border-radius: 10px;
background: linear-gradient(120deg, #f8fbff, #ffffff);
min-height: 120px;
}
.metric-header {
display: flex;
align-items: center;
gap: 8px;
color: #7a8194;
font-size: 13px;
}
.metric-icon {
font-size: 22px;
}
.metric-value {
font-size: 32px;
font-weight: 600;
color: #1a1a2e;
margin-top: 8px;
}
.metric-desc {
font-size: 12px;
color: #8c8c8c;
}
.metric-extra {
font-size: 13px;
color: #5c6370;
}
.filter-card {
flex-shrink: 0;
}
.table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
:deep(.table-card .ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding-bottom: 12px;
}
:deep(.ant-table-wrapper) {
flex: 1;
min-height: 0;
}
.session-cell {
display: flex;
flex-direction: column;
gap: 8px;
}
.session-top {
display: flex;
align-items: center;
gap: 6px;
}
.session-code {
font-weight: 600;
color: #1a1a2e;
}
.session-customer {
display: flex;
align-items: center;
gap: 12px;
}
.customer-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.customer-name {
font-weight: 500;
color: #1a1a2e;
}
.customer-level {
font-size: 12px;
color: #8c8c8c;
margin-left: 8px;
}
.customer-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #8c8c8c;
}
.customer-intent {
padding: 1px 8px;
border-radius: 999px;
background-color: #f6ffed;
color: #389e0d;
font-size: 12px;
}
.session-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.message-cell {
display: flex;
flex-direction: column;
gap: 6px;
}
.message-text {
color: #1a1a2e;
font-weight: 500;
}
.message-meta {
font-size: 12px;
color: #8c8c8c;
display: flex;
align-items: center;
gap: 6px;
}
.stat-block {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: #5c6370;
}
.agent-cell {
display: flex;
align-items: center;
gap: 10px;
}
.agent-info {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
color: #5c6370;
}
.agent-name {
font-weight: 500;
color: #1a1a2e;
}
.assign-btn {
padding-left: 0;
}
.drawer-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.drawer-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.drawer-customer {
display: flex;
gap: 12px;
align-items: center;
}
.drawer-customer-info {
display: flex;
flex-direction: column;
}
.name-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.drawer-meta {
display: flex;
gap: 8px;
}
.drawer-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.drawer-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
font-size: 13px;
color: #5c6370;
}
.stat-label {
display: block;
font-size: 12px;
color: #8c8c8c;
}
.message-list {
max-height: calc(100vh - 280px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 8px;
}
.message-item {
border-radius: 8px;
padding: 12px;
border: 1px solid #f0f0f0;
}
.message-item.user {
background-color: #fff7e6;
border-color: #ffe7ba;
}
.message-item.agent {
background-color: #f6ffed;
border-color: #d9f7be;
}
.drawer-message-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #8c8c8c;
margin-bottom: 4px;
}
.message-content {
font-size: 14px;
color: #1a1a2e;
line-height: 1.6;
}
</style>