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,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>