833 lines
23 KiB
Vue
833 lines
23 KiB
Vue
<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>
|