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