feat(rating): 结束服务、星级评价与管理员回看统计

结束会话后可提交 1-5 星与文字评价,重复提交更新原记录;管理端回看接待人与评分。
This commit is contained in:
WangShen007 committed 2026-10-13 21:55:00 +08:00
1 parent ad90b830c4
commit 2adf288cd2
8 files changed
+3815

No files matched your search

@@ -0,0 +1,303 @@
<!--
Copyright 2024-2026 ChatterMate
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 ChatterMate
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">&times;</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 ChatterMate
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">&times;</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>