feat(rating): 结束服务、星级评价与管理员回看统计
结束会话后可提交 1-5 星与文字评价,重复提交更新原记录;管理端回看接待人与评分。
This commit is contained in:
1 parent
2c43a13774
commit
bf315b85ca
8 files changed
+3815
No files matched your search
@@ -0,0 +1,303 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="agent-performance-container">
|
||||
<div v-if="error" class="error-state">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="isLoading" class="loading-state">
|
||||
正在加载客服表现数据…
|
||||
</div>
|
||||
|
||||
<div v-else class="agent-performance-tabs">
|
||||
<div class="tab-buttons">
|
||||
<button
|
||||
:class="{ active: activeTab === 'bot' }"
|
||||
@click="activeTab = 'bot'"
|
||||
>
|
||||
AI 客服
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: activeTab === 'human' }"
|
||||
@click="activeTab = 'human'"
|
||||
>
|
||||
人工客服
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- Bot Agents Tab -->
|
||||
<div v-if="activeTab === 'bot'" class="agents-table-container">
|
||||
<div v-if="!performanceData?.bot_agents?.length" class="no-data">
|
||||
暂无 AI 客服数据
|
||||
</div>
|
||||
<table v-else class="agents-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客服名称</th>
|
||||
<th>会话总数</th>
|
||||
<th>已结束会话</th>
|
||||
<th>结束率</th>
|
||||
<th>平均评分</th>
|
||||
<th>评分数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="agent in performanceData.bot_agents" :key="agent.id">
|
||||
<td>{{ agent.name }}</td>
|
||||
<td>{{ agent.total_chats }}</td>
|
||||
<td>{{ agent.closed_chats }}</td>
|
||||
<td>{{ calculateClosureRate(agent.closed_chats, agent.total_chats) }}%</td>
|
||||
<td>
|
||||
<div class="rating-display">
|
||||
<span class="stars">
|
||||
<font-awesome-icon v-for="n in 5" :key="n" :class="{ filled: n <= Math.round(agent.avg_rating) }" icon="fa-solid fa-star" />
|
||||
</span>
|
||||
<span class="rating-value">{{ agent.avg_rating.toFixed(1) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ agent.rating_count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Human Agents Tab -->
|
||||
<div v-if="activeTab === 'human'" class="agents-table-container">
|
||||
<div v-if="!performanceData?.human_agents?.length" class="no-data">
|
||||
暂无人工客服数据
|
||||
</div>
|
||||
<table v-else class="agents-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客服名称</th>
|
||||
<th>会话总数</th>
|
||||
<th>已结束会话</th>
|
||||
<th>结束率</th>
|
||||
<th>平均评分</th>
|
||||
<th>评分数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="agent in performanceData.human_agents" :key="agent.id">
|
||||
<td>{{ agent.name }}</td>
|
||||
<td>{{ agent.total_chats }}</td>
|
||||
<td>{{ agent.closed_chats }}</td>
|
||||
<td>{{ calculateClosureRate(agent.closed_chats, agent.total_chats) }}%</td>
|
||||
<td>
|
||||
<div class="rating-display">
|
||||
<span class="stars">
|
||||
<font-awesome-icon v-for="n in 5" :key="n" :class="{ filled: n <= Math.round(agent.avg_rating) }" icon="fa-solid fa-star" />
|
||||
</span>
|
||||
<span class="rating-value">{{ agent.avg_rating.toFixed(1) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ agent.rating_count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface AgentData {
|
||||
id: string
|
||||
name: string
|
||||
total_chats: number
|
||||
closed_chats: number
|
||||
avg_rating: number
|
||||
rating_count: number
|
||||
}
|
||||
|
||||
interface PerformanceData {
|
||||
bot_agents: AgentData[]
|
||||
human_agents: AgentData[]
|
||||
time_range: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
timeRange: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'time-range-change', range: string): void
|
||||
}>()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const activeTab = ref('bot')
|
||||
const performanceData = ref<PerformanceData | null>(null)
|
||||
|
||||
const calculateClosureRate = (closed: number, total: number): string => {
|
||||
if (!total) return '0.0'
|
||||
return ((closed / total) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
const fetchPerformanceData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
const response = await api.get('/analytics/agent-performance', {
|
||||
params: { time_range: props.timeRange }
|
||||
})
|
||||
performanceData.value = response.data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '获取客服表现数据失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for time range changes from parent
|
||||
watch(() => props.timeRange, (newRange) => {
|
||||
fetchPerformanceData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchPerformanceData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-performance-container {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.agent-performance-tabs {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tab-buttons button {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-md);
|
||||
color: var(--text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-buttons button.active {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-buttons button.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent-solid);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.agents-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.agents-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.agents-table th,
|
||||
.agents-table td {
|
||||
padding: var(--space-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agents-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.agents-table td {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.rating-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.stars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stars svg {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.stars svg.filled {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.rating-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,889 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="customer-analytics-container">
|
||||
<div v-if="error" class="error-state">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="isLoading" class="loading-state">
|
||||
正在加载客户数据…
|
||||
</div>
|
||||
|
||||
<div v-else class="customer-analytics-content">
|
||||
<!-- Customer Table -->
|
||||
<div class="customers-table-container">
|
||||
<div v-if="!customerData?.customers?.length" class="no-data">
|
||||
暂无客户数据
|
||||
</div>
|
||||
<table v-else class="customers-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>会话总数</th>
|
||||
<th>最近互动</th>
|
||||
<th>平均评分</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="customer in paginatedCustomers" :key="customer.id">
|
||||
<td>
|
||||
<div class="customer-info">
|
||||
<span class="customer-name">{{ customer.full_name || '匿名' }}</span>
|
||||
<span class="customer-email">{{ customer.email }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ customer.total_chats }}</td>
|
||||
<td>{{ formatDate(customer.last_interaction) }}</td>
|
||||
<td>
|
||||
<div class="rating-display">
|
||||
<span class="stars">
|
||||
<svg v-for="n in 5" :key="n" class="star-icon" :class="{ filled: n <= Math.round(customer.avg_rating) }"
|
||||
xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="rating-value">{{ customer.avg_rating.toFixed(1) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="view-details-btn"
|
||||
@click="showCustomerDetails(customer)"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div v-if="customerData?.customers?.length" class="pagination-controls">
|
||||
<div class="pagination-summary">
|
||||
<span class="total-items">{{ customerData.pagination.total_count }} 位客户</span>
|
||||
</div>
|
||||
|
||||
<div class="pagination-buttons">
|
||||
<button
|
||||
class="pagination-btn first-page"
|
||||
:disabled="currentPage === 1"
|
||||
@click="goToPage(1)"
|
||||
title="首页"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="11 17 6 12 11 7"></polyline>
|
||||
<polyline points="18 17 13 12 18 7"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="pagination-btn"
|
||||
:disabled="currentPage === 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
title="上一页"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="page-indicator">
|
||||
<div class="page-input-container">
|
||||
<input
|
||||
type="number"
|
||||
class="page-input"
|
||||
:value="currentPage"
|
||||
min="1"
|
||||
:max="totalPages"
|
||||
@change="handlePageInputChange($event)"
|
||||
/>
|
||||
<span class="page-separator">/</span>
|
||||
<span class="total-pages">{{ totalPages }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="pagination-btn"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
title="下一页"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="pagination-btn last-page"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="goToPage(totalPages)"
|
||||
title="末页"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="13 17 18 12 13 7"></polyline>
|
||||
<polyline points="6 17 11 12 6 7"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pagination-size">
|
||||
<select
|
||||
class="page-size-select"
|
||||
:value="itemsPerPage"
|
||||
@change="handlePageSizeChange($event)"
|
||||
>
|
||||
<option value="5">每页 5 条</option>
|
||||
<option value="10">每页 10 条</option>
|
||||
<option value="25">每页 25 条</option>
|
||||
<option value="50">每页 50 条</option>
|
||||
</select>
|
||||
<svg class="dropdown-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Details Modal -->
|
||||
<div v-if="selectedCustomer" class="customer-details-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>客户详情</h3>
|
||||
<button class="close-btn" @click="selectedCustomer = null">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="customer-profile">
|
||||
<h4>{{ selectedCustomer.full_name || '匿名' }}</h4>
|
||||
<p class="customer-email">{{ selectedCustomer.email }}</p>
|
||||
<div class="customer-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">会话总数:</span>
|
||||
<span class="stat-value">{{ selectedCustomer.total_chats }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">平均评分:</span>
|
||||
<div class="rating-display">
|
||||
<span class="stars">
|
||||
<svg v-for="n in 5" :key="n" class="star-icon" :class="{ filled: n <= Math.round(selectedCustomer.avg_rating) }"
|
||||
xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="rating-value">{{ selectedCustomer.avg_rating.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">最近互动:</span>
|
||||
<span class="stat-value">{{ formatDate(selectedCustomer.last_interaction) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-section">
|
||||
<h4>互动记录与评价</h4>
|
||||
<div v-if="!selectedCustomer.feedback || !selectedCustomer.feedback.length" class="no-data">
|
||||
暂无评价或评分记录
|
||||
</div>
|
||||
<div v-else class="feedback-list">
|
||||
<div v-for="(item, index) in selectedCustomer.feedback" :key="index" class="feedback-item">
|
||||
<div class="feedback-header">
|
||||
<div class="feedback-date-agent">
|
||||
<span class="feedback-date">{{ formatDate(item.created_at) }}</span>
|
||||
<span class="agent-info">
|
||||
处理人:<span class="agent-name">{{ item.agent_name || 'AI 客服' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rating-display">
|
||||
<span class="stars">
|
||||
<svg v-for="n in 5" :key="n" class="star-icon" :class="{ filled: n <= item.rating }"
|
||||
xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="rating-value">{{ item.rating.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item.feedback" class="feedback-content">
|
||||
<h5 class="feedback-label">客户评价:</h5>
|
||||
<p class="feedback-text">{{ item.feedback }}</p>
|
||||
</div>
|
||||
<div v-else class="feedback-content no-comment">
|
||||
<p class="feedback-text">未填写评价内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface CustomerFeedback {
|
||||
rating: number
|
||||
feedback: string | null
|
||||
created_at: string
|
||||
agent_name: string | null
|
||||
}
|
||||
|
||||
interface CustomerData {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string | null
|
||||
total_chats: number
|
||||
last_interaction: string
|
||||
avg_rating: number
|
||||
rating_count: number
|
||||
feedback?: CustomerFeedback[]
|
||||
}
|
||||
|
||||
interface PaginationData {
|
||||
page: number
|
||||
page_size: number
|
||||
total_count: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
interface CustomerAnalyticsData {
|
||||
customers: CustomerData[]
|
||||
time_range: string
|
||||
pagination: PaginationData
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
timeRange: string
|
||||
}>()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const customerData = ref<CustomerAnalyticsData | null>(null)
|
||||
const selectedCustomer = ref<CustomerData | null>(null)
|
||||
|
||||
// Pagination
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (!customerData.value?.pagination) return 1
|
||||
return customerData.value.pagination.total_pages
|
||||
})
|
||||
|
||||
// Use the customers directly from the API response
|
||||
const paginatedCustomers = computed(() => {
|
||||
if (!customerData.value?.customers) return []
|
||||
return customerData.value.customers
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
if (!dateString) return 'N/A'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const fetchCustomerData = async (page = currentPage.value) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
const response = await api.get('/analytics/customer-analytics', {
|
||||
params: {
|
||||
time_range: props.timeRange,
|
||||
page: page,
|
||||
page_size: itemsPerPage.value
|
||||
}
|
||||
})
|
||||
customerData.value = response.data
|
||||
currentPage.value = response.data.pagination.page
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '获取客户数据失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showCustomerDetails = async (customer: CustomerData) => {
|
||||
try {
|
||||
const response = await api.get(`/analytics/customer-details/${customer.id}`, {
|
||||
params: { time_range: props.timeRange }
|
||||
})
|
||||
selectedCustomer.value = {
|
||||
...customer,
|
||||
feedback: response.data.feedback
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '获取客户详情失败'
|
||||
}
|
||||
}
|
||||
|
||||
// Handle page changes
|
||||
const goToPage = (page: number) => {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
fetchCustomerData(page)
|
||||
}
|
||||
|
||||
// Watch for time range changes from parent
|
||||
watch(() => props.timeRange, (newRange) => {
|
||||
// Reset to page 1 when time range changes
|
||||
fetchCustomerData(1)
|
||||
})
|
||||
|
||||
const handlePageInputChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const page = parseInt(input.value)
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
goToPage(page)
|
||||
} else {
|
||||
// Reset to current page if invalid input
|
||||
input.value = currentPage.value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (event: Event) => {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const newSize = parseInt(select.value)
|
||||
if (newSize !== itemsPerPage.value) {
|
||||
itemsPerPage.value = newSize
|
||||
// Reset to page 1 when changing page size
|
||||
fetchCustomerData(1)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCustomerData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:root {
|
||||
--primary-color-rgb: 59, 130, 246; /* Default blue color - should match your primary color */
|
||||
}
|
||||
|
||||
.customer-analytics-container {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.customer-analytics-content {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.customers-table-container {
|
||||
overflow-x: auto;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.customers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.customers-table th,
|
||||
.customers-table td {
|
||||
padding: var(--space-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.customers-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.customers-table td {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.customer-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.customer-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.customer-email {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rating-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.rating-display.small .stars i {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.stars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.star-icon {
|
||||
color: var(--text-muted);
|
||||
stroke-width: 1;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.star-icon.filled {
|
||||
color: #FFD700;
|
||||
fill: #FFD700;
|
||||
stroke: #FFD700;
|
||||
}
|
||||
|
||||
/* Add specific styles for the star SVGs */
|
||||
.stars svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rating-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.view-details-btn {
|
||||
background-color: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.view-details-btn:hover {
|
||||
background-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
/* Pagination Controls */
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--space-lg);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--background-soft);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.03);
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn.first-page:hover:not(:disabled) i,
|
||||
.pagination-btn.last-page:hover:not(:disabled) i {
|
||||
animation: pulse 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.page-input:focus {
|
||||
animation: pulse 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.pagination-summary {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.total-items {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
background-color: var(--background-mute);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.pagination-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--accent-solid);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
border-radius: var(--radius-full);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.pagination-btn i {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled)::before {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.pagination-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
margin: 0 var(--space-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 var(--space-sm);
|
||||
height: 36px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.page-input-container:focus-within {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.1);
|
||||
}
|
||||
|
||||
.page-input {
|
||||
width: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
padding: 0;
|
||||
-moz-appearance: textfield;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.page-input::-webkit-outer-spin-button,
|
||||
.page-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-separator {
|
||||
margin: 0 var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.total-pages {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-size {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
appearance: none;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 var(--space-xl) 0 var(--space-sm);
|
||||
height: 36px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.page-size-select:hover {
|
||||
border-color: var(--primary-color-light);
|
||||
}
|
||||
|
||||
.page-size-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.1);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
position: absolute;
|
||||
right: var(--space-sm);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.pagination-size:hover .dropdown-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Remove the old Font Awesome after pseudo-element */
|
||||
.pagination-size::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.customer-details-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--background-color);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--text-xl);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.customer-profile {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.customer-profile h4 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.customer-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feedback-section {
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.feedback-section h4 {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.feedback-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.feedback-item {
|
||||
background: var(--background-soft);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid var(--primary-color);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.feedback-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-md);
|
||||
padding-bottom: var(--space-sm);
|
||||
border-bottom: 1px solid var(--border-color-light);
|
||||
}
|
||||
|
||||
.feedback-date-agent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.feedback-date {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.agent-info {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feedback-content {
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.feedback-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.feedback-text {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.no-comment .feedback-text {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.feedback-meta {
|
||||
display: none; /* Hide the old meta section */
|
||||
}
|
||||
|
||||
.error-state {
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,702 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="sentiment-analytics-container">
|
||||
<div v-if="error" class="error-state">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="isLoading" class="loading-state">
|
||||
正在加载情感分析数据…
|
||||
</div>
|
||||
|
||||
<div v-else-if="!sentimentData?.total_analyzed" class="no-data">
|
||||
所选时间段暂无情感分析数据
|
||||
</div>
|
||||
|
||||
<div v-else class="sentiment-analytics-content">
|
||||
<!-- Summary Cards -->
|
||||
<div class="metrics-overview">
|
||||
<div class="metric-card">
|
||||
<h3>已分析消息数</h3>
|
||||
<div class="metric-value">{{ sentimentData.total_analyzed }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<h3>平均情感分</h3>
|
||||
<div class="metric-value">
|
||||
{{ sentimentData.avg_score.toFixed(2) }}
|
||||
<span class="change" :class="{ positive: sentimentData.score_change >= 0 }">
|
||||
{{ sentimentData.score_change >= 0 ? '+' : '' }}{{ sentimentData.score_change.toFixed(1) }}%
|
||||
<i :class="sentimentData.score_trend === 'up' ? 'trend-up' : 'trend-down'"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric-sub">{{ scoreLabel(sentimentData.avg_score) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<h3>正面</h3>
|
||||
<div class="metric-value positive-text">{{ sentimentData.distribution.positive }}</div>
|
||||
<div class="metric-sub">占总量 {{ percentOf(sentimentData.distribution.positive) }}%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<h3>负面</h3>
|
||||
<div class="metric-value negative-text">{{ sentimentData.distribution.negative }}</div>
|
||||
<div class="metric-sub">占总量 {{ percentOf(sentimentData.distribution.negative) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="charts-grid">
|
||||
<div class="chart-container">
|
||||
<h3>情感分布</h3>
|
||||
<div v-if="!hasDistribution" class="no-data">
|
||||
暂无分布数据
|
||||
</div>
|
||||
<apexchart
|
||||
v-else
|
||||
type="donut"
|
||||
height="300"
|
||||
:options="distributionOptions"
|
||||
:series="distributionSeries"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3>情感趋势</h3>
|
||||
<div v-if="!sentimentData.trend?.data?.length" class="no-data">
|
||||
暂无趋势数据
|
||||
</div>
|
||||
<apexchart
|
||||
v-else
|
||||
type="area"
|
||||
height="300"
|
||||
:options="trendOptions"
|
||||
:series="trendSeries"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Negative Sessions Needing Attention -->
|
||||
<div class="negative-sessions-section">
|
||||
<h3>需关注的会话</h3>
|
||||
<div v-if="!sentimentData.negative_sessions?.length" class="no-data small">
|
||||
该时间段没有负面情感会话 🎉
|
||||
</div>
|
||||
<table v-else class="sentiment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>会话</th>
|
||||
<th>情感</th>
|
||||
<th>分值</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="session in sentimentData.negative_sessions" :key="session.session_id">
|
||||
<td>
|
||||
<router-link
|
||||
class="session-link"
|
||||
:to="{ name: 'conversations', query: { session: session.session_id, status: session.status } }"
|
||||
title="查看会话记录"
|
||||
>
|
||||
{{ shortId(session.session_id) }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<span class="sentiment-badge" :class="session.sentiment_label">
|
||||
{{ session.sentiment_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ session.sentiment_score !== null ? session.sentiment_score.toFixed(2) : 'N/A' }}</td>
|
||||
<td><span class="status-text">{{ session.status }}</span></td>
|
||||
<td>
|
||||
<button class="view-details-btn" @click="showSessionDetails(session.session_id)">
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Session Detail Modal -->
|
||||
<div v-if="selectedSession" class="session-details-modal" @click.self="selectedSession = null">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>会话情感明细</h3>
|
||||
<button class="close-btn" @click="selectedSession = null">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="session-overall">
|
||||
<span class="overall-label">整体:</span>
|
||||
<span class="sentiment-badge" :class="selectedSession.overall_sentiment.label || 'neutral'">
|
||||
{{ selectedSession.overall_sentiment.label || 'N/A' }}
|
||||
</span>
|
||||
<span v-if="selectedSession.overall_sentiment.score !== null" class="overall-score">
|
||||
({{ selectedSession.overall_sentiment.score.toFixed(2) }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLoading" class="loading-state">正在加载消息…</div>
|
||||
<div v-else-if="!selectedSession.messages?.length" class="no-data small">
|
||||
该会话暂无已分析的客户消息
|
||||
</div>
|
||||
<div v-else class="message-list">
|
||||
<div
|
||||
v-for="msg in selectedSession.messages"
|
||||
:key="msg.id"
|
||||
class="message-item"
|
||||
:class="msg.sentiment_label"
|
||||
>
|
||||
<div class="message-header">
|
||||
<span class="sentiment-badge" :class="msg.sentiment_label">
|
||||
{{ msg.sentiment_label }}
|
||||
</span>
|
||||
<span class="message-score">
|
||||
{{ msg.sentiment_score !== null ? msg.sentiment_score.toFixed(2) : '' }}
|
||||
</span>
|
||||
<span class="message-date">{{ formatDate(msg.created_at) }}</span>
|
||||
</div>
|
||||
<p class="message-text">{{ msg.message || '无内容' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface SentimentDistribution {
|
||||
positive: number
|
||||
neutral: number
|
||||
negative: number
|
||||
}
|
||||
|
||||
interface SentimentTrend {
|
||||
data: number[]
|
||||
labels: string[]
|
||||
message_counts: number[]
|
||||
}
|
||||
|
||||
interface NegativeSession {
|
||||
session_id: string
|
||||
sentiment_label: string
|
||||
sentiment_score: number | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SentimentData {
|
||||
distribution: SentimentDistribution
|
||||
total_analyzed: number
|
||||
avg_score: number
|
||||
score_change: number
|
||||
score_trend: 'up' | 'down'
|
||||
trend: SentimentTrend
|
||||
negative_sessions: NegativeSession[]
|
||||
time_range: string
|
||||
}
|
||||
|
||||
interface SessionMessage {
|
||||
id: number
|
||||
message: string | null
|
||||
sentiment_label: string
|
||||
sentiment_score: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
session_id: string
|
||||
overall_sentiment: { label: string | null; score: number | null }
|
||||
messages: SessionMessage[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
timeRange: string
|
||||
}>()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const sentimentData = ref<SentimentData | null>(null)
|
||||
|
||||
const selectedSession = ref<SessionDetail | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
|
||||
// Theme-ish colors for the three sentiment classes
|
||||
const COLOR_POSITIVE = '#16a34a'
|
||||
const COLOR_NEUTRAL = '#9ca3af'
|
||||
const COLOR_NEGATIVE = '#ef4444'
|
||||
|
||||
const hasDistribution = computed(() => {
|
||||
const d = sentimentData.value?.distribution
|
||||
return !!d && (d.positive + d.neutral + d.negative) > 0
|
||||
})
|
||||
|
||||
const distributionSeries = computed(() => {
|
||||
const d = sentimentData.value?.distribution
|
||||
if (!d) return []
|
||||
return [d.positive, d.neutral, d.negative]
|
||||
})
|
||||
|
||||
const distributionOptions = computed(() => ({
|
||||
chart: { type: 'donut', toolbar: { show: false } },
|
||||
labels: ['正面', '中性', '负面'],
|
||||
colors: [COLOR_POSITIVE, COLOR_NEUTRAL, COLOR_NEGATIVE],
|
||||
legend: { position: 'bottom' },
|
||||
dataLabels: { enabled: true, formatter: (val: number) => `${val.toFixed(0)}%` },
|
||||
stroke: { width: 0 },
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
labels: {
|
||||
show: true,
|
||||
total: { show: true, label: '总计', formatter: () => `${sentimentData.value?.total_analyzed || 0}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const trendSeries = computed(() => {
|
||||
const t = sentimentData.value?.trend
|
||||
if (!t?.data) return []
|
||||
return [{
|
||||
name: '平均情感分',
|
||||
data: t.data.map((value, index) => ({
|
||||
x: new Date(t.labels[index]).getTime(),
|
||||
y: value
|
||||
}))
|
||||
}]
|
||||
})
|
||||
|
||||
const trendOptions = computed(() => ({
|
||||
chart: { type: 'area', toolbar: { show: false }, zoom: { enabled: false } },
|
||||
colors: ['#6366f1'],
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: { shadeIntensity: 1, opacityFrom: 0.6, opacityTo: 0.1, stops: [0, 90, 100] }
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
xaxis: { type: 'datetime' },
|
||||
yaxis: {
|
||||
min: -1,
|
||||
max: 1,
|
||||
tickAmount: 4,
|
||||
labels: { formatter: (val: number) => val.toFixed(1) }
|
||||
},
|
||||
annotations: {
|
||||
yaxis: [{ y: 0, borderColor: '#9ca3af', strokeDashArray: 4 }]
|
||||
},
|
||||
tooltip: { x: { format: 'MMM dd, yyyy' } }
|
||||
}))
|
||||
|
||||
const percentOf = (count: number): string => {
|
||||
const total = sentimentData.value?.total_analyzed || 0
|
||||
if (!total) return '0'
|
||||
return ((count / total) * 100).toFixed(0)
|
||||
}
|
||||
|
||||
const scoreLabel = (score: number): string => {
|
||||
if (score > 0.1) return '正面'
|
||||
if (score < -0.1) return '负面'
|
||||
return '中性'
|
||||
}
|
||||
|
||||
const shortId = (id: string): string => id.slice(0, 8)
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
if (!dateString) return 'N/A'
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
const fetchSentimentData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
const response = await api.get('/analytics/sentiment', {
|
||||
params: { time_range: props.timeRange }
|
||||
})
|
||||
sentimentData.value = response.data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '获取情感分析数据失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showSessionDetails = async (sessionId: string) => {
|
||||
try {
|
||||
detailLoading.value = true
|
||||
selectedSession.value = {
|
||||
session_id: sessionId,
|
||||
overall_sentiment: { label: null, score: null },
|
||||
messages: []
|
||||
}
|
||||
const response = await api.get(`/analytics/session-sentiment/${sessionId}`)
|
||||
selectedSession.value = response.data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '获取会话情感数据失败'
|
||||
selectedSession.value = null
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.timeRange, () => {
|
||||
fetchSentimentData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchSentimentData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sentiment-analytics-container {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.metrics-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--background-soft);
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.metric-card h3 {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.metric-value.positive-text {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.metric-value.negative-text {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.metric-sub {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.change {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.change.positive {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.trend-up, .trend-down {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: 4px;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
border-bottom: 4px solid currentColor;
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
border-top: 4px solid currentColor;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
background: var(--background-soft);
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.negative-sessions-section {
|
||||
background: var(--background-soft);
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.negative-sessions-section h3 {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.sentiment-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.sentiment-table th,
|
||||
.sentiment-table td {
|
||||
padding: var(--space-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sentiment-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sentiment-table td {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.session-link {
|
||||
font-family: monospace;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.session-link:hover {
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
text-transform: capitalize;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sentiment-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.sentiment-badge.positive {
|
||||
background-color: rgba(22, 163, 74, 0.12);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.sentiment-badge.neutral {
|
||||
background-color: rgba(156, 163, 175, 0.18);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.sentiment-badge.negative {
|
||||
background-color: rgba(239, 68, 68, 0.12);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.view-details-btn {
|
||||
background-color: var(--accent-solid);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.view-details-btn:hover {
|
||||
background-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.session-details-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--background-color);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--text-xl);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.session-overall {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.overall-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.overall-score {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.message-item {
|
||||
background: var(--background-soft);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid var(--border-color);
|
||||
}
|
||||
|
||||
.message-item.positive {
|
||||
border-left-color: #16a34a;
|
||||
}
|
||||
|
||||
.message-item.neutral {
|
||||
border-left-color: #9ca3af;
|
||||
}
|
||||
|
||||
.message-item.negative {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.message-score {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.message-date {
|
||||
margin-left: auto;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 250px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.no-data.small {
|
||||
height: 80px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user