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

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

No files matched your search

+694
View File
@@ -0,0 +1,694 @@
"""
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.
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.core.auth import get_current_user, require_permissions
from app.services.feature_gate import check_feature_access
from app.models.user import User
from app.models.agent import Agent
from app.models.customer import Customer
from app.models.rating import Rating
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.models.chat_history import ChatHistory
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, and_, or_, desc, distinct, case
from typing import Optional, List
from app.core.logger import get_logger
from uuid import UUID
logger = get_logger(__name__)
UPGRADE_MESSAGE = "Analytics are available on paid plans. Upgrade to see them."
def analytics_enabled(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> None:
"""Every analytics endpoint is a plan feature: the dashboard's upgrade
overlay is not the gate, this is."""
check_feature_access(db, current_user.organization_id, "analytics", UPGRADE_MESSAGE)
router = APIRouter(dependencies=[Depends(analytics_enabled)])
def get_time_range_dates(time_range: str) -> tuple[datetime, datetime]:
"""Get start and end dates based on time range"""
# Use timezone-aware UTC so comparisons against timestamptz columns are correct
# regardless of the database session timezone (naive bounds get interpreted in
# the session tz, which silently drops recent rows when that tz isn't UTC).
end_date = datetime.now(timezone.utc)
if time_range == '24h':
start_date = end_date - timedelta(days=1)
elif time_range == '7d':
start_date = end_date - timedelta(days=7)
elif time_range == '30d':
start_date = end_date - timedelta(days=30)
else: # 90d
start_date = end_date - timedelta(days=90)
return start_date, end_date
def get_interval(time_range: str) -> str:
"""Get SQL interval based on time range"""
if time_range == '24h':
return 'hour'
elif time_range == '7d':
return 'day'
elif time_range == '30d':
return 'day'
else: # 90d
return 'week'
@router.get("/agent-performance")
async def get_agent_performance(
time_range: str = Query('7d', regex='^(24h|7d|30d|90d)$'),
db: Session = Depends(get_db),
current_user: User = Depends(require_permissions("view_analytics"))
):
"""Get agent performance analytics data for the organization"""
try:
start_date, end_date = get_time_range_dates(time_range)
org_id = current_user.organization_id
# Get bot agent performance
bot_agents_query = db.query(
Agent.id,
Agent.name,
func.count(SessionToAgent.session_id).label('total_chats'),
func.sum(
case((SessionToAgent.status == SessionStatus.CLOSED, 1), else_=0)
).label('closed_chats'),
func.avg(Rating.rating).label('avg_rating'),
func.count(Rating.id).label('rating_count')
).outerjoin(
SessionToAgent,
and_(
SessionToAgent.agent_id == Agent.id,
SessionToAgent.assigned_at.between(start_date, end_date)
)
).outerjoin(
Rating,
and_(
Rating.session_id == SessionToAgent.session_id,
Rating.created_at.between(start_date, end_date)
)
).filter(
Agent.organization_id == org_id
).group_by(
Agent.id
).order_by(
desc('total_chats')
)
bot_agents = bot_agents_query.all()
# Get human agent performance
human_agents_query = db.query(
User.id,
User.full_name.label('name'),
func.count(SessionToAgent.session_id).label('total_chats'),
func.sum(
case((SessionToAgent.status == SessionStatus.CLOSED, 1), else_=0)
).label('closed_chats'),
func.avg(Rating.rating).label('avg_rating'),
func.count(Rating.id).label('rating_count')
).outerjoin(
SessionToAgent,
and_(
SessionToAgent.user_id == User.id,
SessionToAgent.assigned_at.between(start_date, end_date)
)
).outerjoin(
Rating,
and_(
Rating.session_id == SessionToAgent.session_id,
Rating.created_at.between(start_date, end_date)
)
).filter(
User.organization_id == org_id
).group_by(
User.id
).order_by(
desc('total_chats')
)
human_agents = human_agents_query.all()
# Format the results
bot_results = []
for agent in bot_agents:
bot_results.append({
"id": str(agent.id),
"name": agent.name,
"total_chats": agent.total_chats,
"closed_chats": agent.closed_chats,
"avg_rating": float(agent.avg_rating) if agent.avg_rating else 0,
"rating_count": agent.rating_count
})
human_results = []
for agent in human_agents:
human_results.append({
"id": str(agent.id),
"name": agent.name or "Unknown User",
"total_chats": agent.total_chats,
"closed_chats": agent.closed_chats,
"avg_rating": float(agent.avg_rating) if agent.avg_rating else 0,
"rating_count": agent.rating_count
})
return {
"bot_agents": bot_results,
"human_agents": human_results,
"time_range": time_range
}
except Exception as e:
logger.error(f"Error getting agent performance analytics: {str(e)}")
raise
@router.get("")
async def get_analytics(
time_range: str = Query('7d', regex='^(24h|7d|30d|90d)$'),
db: Session = Depends(get_db),
current_user: User = Depends(require_permissions("view_analytics"))
):
"""Get analytics data for the organization"""
try:
start_date, end_date = get_time_range_dates(time_range)
interval = get_interval(time_range)
org_id = current_user.organization_id
# Get conversation metrics
conversations_query = db.query(
func.count(SessionToAgent.session_id).label('total'),
func.date_trunc(interval, SessionToAgent.assigned_at).label('period')
).filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.assigned_at.between(start_date, end_date)
).group_by('period').order_by('period')
conversations = conversations_query.all()
# Get previous period data for comparison
prev_start = start_date - (end_date - start_date)
prev_total = db.query(func.count(SessionToAgent.session_id)).filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.assigned_at.between(prev_start, start_date)
).scalar() or 0
current_total = sum(c.total for c in conversations)
conv_change = ((current_total - prev_total) / prev_total * 100) if prev_total > 0 else 0
# Get AI chat closures
ai_closures_query = db.query(
func.count(SessionToAgent.session_id).label('total'),
func.date_trunc(interval, SessionToAgent.updated_at).label('period')
).filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.updated_at.between(start_date, end_date),
SessionToAgent.status == SessionStatus.CLOSED,
SessionToAgent.user_id == None # AI closures have no user_id
).group_by('period').order_by('period')
ai_closures = ai_closures_query.all()
current_ai_closures = sum(c.total for c in ai_closures)
prev_ai_closures = db.query(func.count(SessionToAgent.session_id)).filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.updated_at.between(prev_start, start_date),
SessionToAgent.status == SessionStatus.CLOSED,
SessionToAgent.user_id == None
).scalar() or 0
ai_closures_change = ((current_ai_closures - prev_ai_closures) / prev_ai_closures * 100) if prev_ai_closures > 0 else 0
# Get human transfers
transfers_query = db.query(
func.count(SessionToAgent.session_id).label('total'),
func.date_trunc(interval, SessionToAgent.updated_at).label('period')
).filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.updated_at.between(start_date, end_date),
SessionToAgent.user_id != None # Human transfers have a user_id
).group_by('period').order_by('period')
transfers = transfers_query.all()
current_transfers = sum(t.total for t in transfers)
prev_transfers = db.query(func.count(SessionToAgent.session_id)).filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.updated_at.between(prev_start, start_date),
SessionToAgent.user_id != None # Human transfers have a user_id
).scalar() or 0
transfers_change = ((current_transfers - prev_transfers) / prev_transfers * 100) if prev_transfers > 0 else 0
# Get bot ratings
bot_ratings_query = db.query(
func.avg(Rating.rating).label('avg_rating'),
func.count(Rating.id).label('total'),
func.date_trunc(interval, Rating.created_at).label('period')
).join(
SessionToAgent, Rating.session_id == SessionToAgent.session_id
).filter(
Rating.organization_id == org_id,
Rating.created_at.between(start_date, end_date),
SessionToAgent.user_id == None # Bot sessions have no user_id
).group_by('period').order_by('period')
bot_ratings = bot_ratings_query.all()
# Get human ratings
human_ratings_query = db.query(
func.avg(Rating.rating).label('avg_rating'),
func.count(Rating.id).label('total'),
func.date_trunc(interval, Rating.created_at).label('period')
).join(
SessionToAgent, Rating.session_id == SessionToAgent.session_id
).filter(
Rating.organization_id == org_id,
Rating.created_at.between(start_date, end_date),
SessionToAgent.user_id != None # Human sessions have a user_id
).group_by('period').order_by('period')
human_ratings = human_ratings_query.all()
# Calculate rating averages and changes
current_bot_avg = db.query(func.avg(Rating.rating)).join(
SessionToAgent, Rating.session_id == SessionToAgent.session_id
).filter(
Rating.organization_id == org_id,
Rating.created_at.between(start_date, end_date),
SessionToAgent.user_id == None
).scalar() or 0
prev_bot_avg = db.query(func.avg(Rating.rating)).join(
SessionToAgent, Rating.session_id == SessionToAgent.session_id
).filter(
Rating.organization_id == org_id,
Rating.created_at.between(prev_start, start_date),
SessionToAgent.user_id == None
).scalar() or 0
current_human_avg = db.query(func.avg(Rating.rating)).join(
SessionToAgent, Rating.session_id == SessionToAgent.session_id
).filter(
Rating.organization_id == org_id,
Rating.created_at.between(start_date, end_date),
SessionToAgent.user_id != None
).scalar() or 0
prev_human_avg = db.query(func.avg(Rating.rating)).join(
SessionToAgent, Rating.session_id == SessionToAgent.session_id
).filter(
Rating.organization_id == org_id,
Rating.created_at.between(prev_start, start_date),
SessionToAgent.user_id != None
).scalar() or 0
# Calculate rating counts
bot_count = db.query(func.count(Rating.id)).join(
SessionToAgent, Rating.session_id == SessionToAgent.session_id
).filter(
Rating.organization_id == org_id,
SessionToAgent.user_id == None
).scalar() or 0
human_count = db.query(func.count(Rating.id)).join(
SessionToAgent, Rating.session_id == SessionToAgent.session_id
).filter(
Rating.organization_id == org_id,
SessionToAgent.user_id != None
).scalar() or 0
# Calculate rating changes
bot_change = ((current_bot_avg - prev_bot_avg) / prev_bot_avg * 100) if prev_bot_avg > 0 else 0
human_change = ((current_human_avg - prev_human_avg) / prev_human_avg * 100) if prev_human_avg > 0 else 0
return {
"conversations": {
"total": current_total,
"change": conv_change,
"trend": "up" if conv_change >= 0 else "down",
"data": [r.total for r in conversations],
"labels": [r.period.strftime("%Y-%m-%d") for r in conversations]
},
"aiClosures": {
"total": current_ai_closures,
"change": ai_closures_change,
"trend": "up" if ai_closures_change >= 0 else "down",
"data": [r.total for r in ai_closures],
"labels": [r.period.strftime("%Y-%m-%d") for r in ai_closures]
},
"transfers": {
"total": current_transfers,
"change": transfers_change,
"trend": "up" if transfers_change >= 0 else "down",
"data": [r.total for r in transfers],
"labels": [r.period.strftime("%Y-%m-%d") for r in transfers]
},
"ratings": {
"bot": {
"data": [float(r.avg_rating or 0) for r in bot_ratings],
"labels": [r.period.strftime("%Y-%m-%d") for r in bot_ratings],
"change": bot_change,
"trend": "up" if bot_change >= 0 else "down"
},
"human": {
"data": [float(r.avg_rating or 0) for r in human_ratings],
"labels": [r.period.strftime("%Y-%m-%d") for r in human_ratings],
"change": human_change,
"trend": "up" if human_change >= 0 else "down"
},
"bot_avg": float(current_bot_avg),
"human_avg": float(current_human_avg),
"bot_count": bot_count,
"human_count": human_count,
"bot_change": bot_change,
"human_change": human_change,
"bot_trend": "up" if bot_change >= 0 else "down",
"human_trend": "up" if human_change >= 0 else "down"
}
}
except Exception as e:
logger.error(f"Error getting analytics: {str(e)}")
raise
@router.get("/customer-analytics")
async def get_customer_analytics(
time_range: str = Query('7d', pattern='^(24h|7d|30d|90d)$'),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
db: Session = Depends(get_db),
current_user: User = Depends(require_permissions("view_analytics"))
):
"""Get customer analytics data for the organization"""
try:
start_date, end_date = get_time_range_dates(time_range)
org_id = current_user.organization_id
# Get base customer query
customer_query = db.query(
Customer.id,
Customer.email,
Customer.full_name,
func.count(func.distinct(SessionToAgent.session_id)).label('total_chats'),
func.max(SessionToAgent.assigned_at).label('last_interaction'),
func.avg(Rating.rating).label('avg_rating'),
func.count(Rating.id).label('rating_count')
).outerjoin(
SessionToAgent,
and_(
Customer.id == SessionToAgent.customer_id,
SessionToAgent.assigned_at.between(start_date, end_date)
)
).outerjoin(
Rating,
and_(
Rating.customer_id == Customer.id,
Rating.organization_id == Customer.organization_id
)
).filter(
Customer.organization_id == org_id
).group_by(
Customer.id
).order_by(
Customer.created_at.desc()
)
# Get total count for pagination
total_count = customer_query.count()
# Apply pagination
customers = customer_query.offset((page - 1) * page_size).limit(page_size).all()
result = []
for customer in customers:
result.append({
"id": str(customer.id),
"email": customer.email,
"full_name": customer.full_name,
"total_chats": customer.total_chats,
"last_interaction": customer.last_interaction.isoformat() if customer.last_interaction else None,
"avg_rating": float(customer.avg_rating) if customer.avg_rating else 0,
"rating_count": customer.rating_count
})
return {
"customers": result,
"time_range": time_range,
"pagination": {
"page": page,
"page_size": page_size,
"total_count": total_count,
"total_pages": (total_count + page_size - 1) // page_size
}
}
except Exception as e:
logger.error(f"Error getting customer analytics: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error getting customer analytics: {str(e)}")
@router.get("/customer-details/{customer_id}")
async def get_customer_details(
customer_id: UUID,
time_range: str = Query('7d', regex='^(24h|7d|30d|90d)$'),
db: Session = Depends(get_db),
current_user: User = Depends(require_permissions("view_analytics"))
):
"""Get detailed information about a specific customer"""
try:
start_date, end_date = get_time_range_dates(time_range)
org_id = current_user.organization_id
# Verify customer belongs to organization
customer = db.query(Customer).filter(
Customer.id == customer_id,
Customer.organization_id == org_id
).first()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
# Get customer feedback
feedback_query = db.query(
Rating.rating,
Rating.feedback,
Rating.created_at,
Agent.name.label('agent_name'),
User.full_name.label('user_name')
).join(
SessionToAgent, Rating.session_id == SessionToAgent.session_id
).outerjoin(
Agent, SessionToAgent.agent_id == Agent.id
).outerjoin(
User, SessionToAgent.user_id == User.id
).filter(
Rating.customer_id == customer_id,
Rating.organization_id == org_id
).order_by(
desc(Rating.created_at)
)
feedback = feedback_query.all()
# Format the results
feedback_results = []
for item in feedback:
agent_name = item.agent_name
if item.user_name: # If handled by a human agent
agent_name = item.user_name
feedback_results.append({
"rating": item.rating,
"feedback": item.feedback,
"created_at": item.created_at.isoformat(),
"agent_name": agent_name
})
return {
"feedback": feedback_results
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting customer details: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sentiment")
async def get_sentiment_analytics(
time_range: str = Query('7d', regex='^(24h|7d|30d|90d)$'),
db: Session = Depends(get_db),
current_user: User = Depends(require_permissions("view_analytics"))
):
"""Get sentiment distribution and trends for the organization"""
try:
start_date, end_date = get_time_range_dates(time_range)
interval = get_interval(time_range)
org_id = current_user.organization_id
# Sentiment distribution (counts of positive/neutral/negative)
distribution = db.query(
ChatHistory.sentiment_label,
func.count(ChatHistory.id).label('count')
).filter(
ChatHistory.organization_id == org_id,
ChatHistory.created_at.between(start_date, end_date),
ChatHistory.message_type == 'user',
ChatHistory.sentiment_label.isnot(None)
).group_by(
ChatHistory.sentiment_label
).all()
dist_dict = {"positive": 0, "neutral": 0, "negative": 0}
for row in distribution:
if row.sentiment_label in dist_dict:
dist_dict[row.sentiment_label] = row.count
total_analyzed = sum(dist_dict.values())
# Sentiment trend over time
trend = db.query(
func.date_trunc(interval, ChatHistory.created_at).label('period'),
func.avg(ChatHistory.sentiment_score).label('avg_score'),
func.count(ChatHistory.id).label('message_count')
).filter(
ChatHistory.organization_id == org_id,
ChatHistory.created_at.between(start_date, end_date),
ChatHistory.message_type == 'user',
ChatHistory.sentiment_score.isnot(None)
).group_by('period').order_by('period').all()
# Average sentiment score
avg_score = db.query(
func.avg(ChatHistory.sentiment_score)
).filter(
ChatHistory.organization_id == org_id,
ChatHistory.created_at.between(start_date, end_date),
ChatHistory.message_type == 'user',
ChatHistory.sentiment_score.isnot(None)
).scalar() or 0.0
# Previous period comparison
prev_start = start_date - (end_date - start_date)
prev_avg = db.query(
func.avg(ChatHistory.sentiment_score)
).filter(
ChatHistory.organization_id == org_id,
ChatHistory.created_at.between(prev_start, start_date),
ChatHistory.message_type == 'user',
ChatHistory.sentiment_score.isnot(None)
).scalar() or 0.0
score_change = ((float(avg_score) - float(prev_avg)) / abs(float(prev_avg)) * 100) if prev_avg else 0.0
# Sessions with negative sentiment (for agent attention)
negative_sessions = db.query(
SessionToAgent.session_id,
SessionToAgent.sentiment_label,
SessionToAgent.sentiment_score,
SessionToAgent.status
).filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.assigned_at.between(start_date, end_date),
SessionToAgent.sentiment_label == 'negative'
).order_by(
SessionToAgent.sentiment_score.asc()
).limit(10).all()
return {
"distribution": dist_dict,
"total_analyzed": total_analyzed,
"avg_score": round(float(avg_score), 4),
"score_change": round(score_change, 2),
"score_trend": "up" if score_change >= 0 else "down",
"trend": {
"data": [round(float(r.avg_score or 0), 4) for r in trend],
"labels": [r.period.strftime("%Y-%m-%d") for r in trend],
"message_counts": [r.message_count for r in trend]
},
"negative_sessions": [
{
"session_id": str(s.session_id),
"sentiment_label": s.sentiment_label,
"sentiment_score": round(float(s.sentiment_score), 4) if s.sentiment_score else None,
"status": s.status
}
for s in negative_sessions
],
"time_range": time_range
}
except Exception as e:
logger.error(f"Error getting sentiment analytics: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error getting sentiment analytics: {str(e)}")
@router.get("/session-sentiment/{session_id}")
async def get_session_sentiment(
session_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_permissions("view_analytics"))
):
"""Get sentiment breakdown for a specific session"""
try:
org_id = current_user.organization_id
# Verify session belongs to organization
session = db.query(SessionToAgent).filter(
SessionToAgent.session_id == session_id,
SessionToAgent.organization_id == org_id
).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Get per-message sentiment for customer messages
messages = db.query(
ChatHistory.id,
ChatHistory.message,
ChatHistory.sentiment_label,
ChatHistory.sentiment_score,
ChatHistory.created_at
).filter(
ChatHistory.session_id == session_id,
ChatHistory.message_type == 'user',
ChatHistory.sentiment_label.isnot(None)
).order_by(
ChatHistory.created_at.asc()
).all()
return {
"session_id": str(session_id),
"overall_sentiment": {
"label": session.sentiment_label,
"score": round(float(session.sentiment_score), 4) if session.sentiment_score else None
},
"messages": [
{
"id": m.id,
"message": m.message[:200] if m.message else None, # Truncate for privacy
"sentiment_label": m.sentiment_label,
"sentiment_score": round(float(m.sentiment_score), 4) if m.sentiment_score else None,
"created_at": m.created_at.isoformat()
}
for m in messages
]
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting session sentiment: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
+136
View File
@@ -0,0 +1,136 @@
"""
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.
"""
from typing import Tuple, List, Optional
from app.core.logger import get_logger
logger = get_logger(__name__)
# Try to import TextBlob, fall back to basic keyword analysis if unavailable
try:
from textblob import TextBlob
TEXTBLOB_AVAILABLE = True
except ImportError:
TEXTBLOB_AVAILABLE = False
logger.warning("TextBlob not installed. Using basic keyword-based sentiment analysis. "
"Install with: pip install textblob")
# Basic keyword lists for fallback sentiment analysis
POSITIVE_KEYWORDS = {
'thank', 'thanks', 'great', 'awesome', 'excellent', 'good', 'love',
'perfect', 'amazing', 'wonderful', 'helpful', 'appreciate', 'happy',
'pleased', 'fantastic', 'brilliant', 'nice', 'best', 'super', 'cool',
'resolved', 'fixed', 'works', 'working', 'solved'
}
NEGATIVE_KEYWORDS = {
'bad', 'terrible', 'awful', 'horrible', 'worst', 'hate', 'angry',
'frustrated', 'annoyed', 'disappointed', 'useless', 'broken', 'bug',
'error', 'fail', 'failed', 'wrong', 'issue', 'problem', 'slow',
'crash', 'crashed', 'stuck', 'ridiculous', 'unacceptable', 'poor',
'waste', 'stupid', 'pathetic', 'sucks', 'rubbish'
}
def _analyze_with_textblob(text: str) -> Tuple[str, float]:
"""Analyze sentiment using TextBlob."""
blob = TextBlob(text)
polarity = blob.sentiment.polarity # -1.0 to 1.0
if polarity > 0.1:
label = "positive"
elif polarity < -0.1:
label = "negative"
else:
label = "neutral"
return label, round(polarity, 4)
def _analyze_with_keywords(text: str) -> Tuple[str, float]:
"""Fallback keyword-based sentiment analysis."""
words = set(text.lower().split())
positive_count = len(words & POSITIVE_KEYWORDS)
negative_count = len(words & NEGATIVE_KEYWORDS)
total = positive_count + negative_count
if total == 0:
return "neutral", 0.0
# Simple scoring: range from -1 to 1
score = (positive_count - negative_count) / total
if score > 0.1:
label = "positive"
elif score < -0.1:
label = "negative"
else:
label = "neutral"
return label, round(score, 4)
def analyze_sentiment(text: str) -> Tuple[str, float]:
"""
Analyze the sentiment of a text message.
Returns:
Tuple of (sentiment_label, sentiment_score)
- sentiment_label: 'positive', 'neutral', or 'negative'
- sentiment_score: float from -1.0 (most negative) to 1.0 (most positive)
"""
if not text or not text.strip():
return "neutral", 0.0
try:
if TEXTBLOB_AVAILABLE:
return _analyze_with_textblob(text)
else:
return _analyze_with_keywords(text)
except Exception as e:
logger.error(f"Error analyzing sentiment: {str(e)}")
return "neutral", 0.0
def compute_session_sentiment(
sentiment_scores: List[float],
sentiment_labels: List[str]
) -> Tuple[Optional[str], Optional[float]]:
"""
Compute the overall sentiment for a session from individual message sentiments.
Args:
sentiment_scores: List of sentiment scores from customer messages
sentiment_labels: List of sentiment labels from customer messages
Returns:
Tuple of (overall_label, overall_score) or (None, None) if no data
"""
if not sentiment_scores:
return None, None
avg_score = sum(sentiment_scores) / len(sentiment_scores)
if avg_score > 0.1:
label = "positive"
elif avg_score < -0.1:
label = "negative"
else:
label = "neutral"
return label, round(avg_score, 4)
@@ -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">&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 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">&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>
+54
View File
@@ -0,0 +1,54 @@
/*
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.
*/
/**
* Channels served by our own widget rather than an external messaging platform.
* Mirrors WIDGET_CHANNELS in backend/app/channels/constants.py — keep the two
* in step. 'shopify' is the widget embedded in a storefront: a separate label
* in the inbox, the same chat surface.
*/
const WIDGET_CHANNELS = ['web', 'shopify']
/**
* Rating is a widget-only feature. External channels (WhatsApp, Messenger,
* Instagram, Telegram, ...) render plain text and have no rating UI, so asking
* there leaves the customer a question they cannot answer. The backend applies
* the same rule to AI-ended chats.
*/
export const canRequestRating = (channel?: string | null): boolean =>
!channel || WIDGET_CHANNELS.includes(channel)
/** Closing message shown to the customer when an agent ends the chat. */
export const endChatMessage = (channel?: string | null): string =>
canRequestRating(channel)
? '感谢您的咨询。方便为我们的服务评分吗?'
: '感谢您的咨询。'
interface EndChatCandidate {
attributes?: { end_chat?: boolean } | null
}
/**
* Whether a widget message is the one that actually ends the conversation.
*
* `attributes.end_chat` is the only signal for this — message_type and the
* presence of a session_id are not. The takeover notice and form prompts are
* ordinary conversation messages that happen to carry a real session_id, and
* treating those as an end-chat closed live conversations the moment a human
* agent joined.
*/
export const isEndChatMessage = (message?: EndChatCandidate | null): boolean =>
Boolean(message?.attributes?.end_chat)
+26
View File
@@ -0,0 +1,26 @@
<!--
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.
-->
<script setup lang="ts">
import DashboardLayout from '@/layouts/DashboardLayout.vue'
import AnalyticsDashboard from '@/components/analytics/AnalyticsDashboard.vue'
</script>
<template>
<DashboardLayout>
<AnalyticsDashboard />
</DashboardLayout>
</template>