first commit

This commit is contained in:
super
2025-12-28 22:09:44 +08:00
commit 5457ce7cf4
83 changed files with 24640 additions and 0 deletions

View File

@@ -0,0 +1,832 @@
<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>