""" 团队看板 API 路由 提供团队概览、学习进度、排行榜、动态等数据 """ import json from datetime import datetime, timedelta from typing import Any, Dict, List from fastapi import APIRouter, Depends from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_active_user as get_current_user, get_db from app.core.logger import logger from app.models.course import Course from app.models.exam import Exam from app.models.position import Position from app.models.position_member import PositionMember from app.models.practice import PracticeReport, PracticeSession from app.models.user import Team, User, UserTeam from app.schemas.base import ResponseModel router = APIRouter(prefix="/team/dashboard", tags=["team-dashboard"]) async def get_accessible_teams( current_user: User, db: AsyncSession ) -> List[int]: """获取用户可访问的团队ID列表""" if current_user.role in ['admin', 'manager']: # 管理员查看所有团队 stmt = select(Team.id).where(Team.is_deleted == False) # noqa: E712 result = await db.execute(stmt) return [row[0] for row in result.all()] else: # 普通用户只查看自己的团队 stmt = select(UserTeam.team_id).where(UserTeam.user_id == current_user.id) result = await db.execute(stmt) return [row[0] for row in result.all()] async def get_team_member_ids( team_ids: List[int], db: AsyncSession ) -> List[int]: """获取团队成员ID列表""" if not team_ids: return [] stmt = select(UserTeam.user_id).where( UserTeam.team_id.in_(team_ids) ).distinct() result = await db.execute(stmt) return [row[0] for row in result.all()] @router.get("/overview", response_model=ResponseModel) async def get_team_overview( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ResponseModel: """ 获取团队概览统计 返回团队总数、成员数、平均学习进度、平均成绩、课程完成率等 """ try: # 获取可访问的团队 team_ids = await get_accessible_teams(current_user, db) # 获取团队成员ID member_ids = await get_team_member_ids(team_ids, db) # 统计团队数 team_count = len(team_ids) # 统计成员数 member_count = len(member_ids) # 计算平均考试成绩(使用round1_score) avg_score = 0.0 if member_ids: stmt = select(func.avg(Exam.round1_score)).where( and_( Exam.user_id.in_(member_ids), Exam.round1_score.isnot(None), Exam.status.in_(['completed', 'submitted']) ) ) result = await db.execute(stmt) avg_score_value = result.scalar() avg_score = float(avg_score_value) if avg_score_value else 0.0 # 计算平均学习进度(基于考试完成情况) avg_progress = 0.0 if member_ids: # 统计每个成员完成的考试数 stmt = select(func.count(Exam.id)).where( and_( Exam.user_id.in_(member_ids), Exam.status.in_(['completed', 'submitted']) ) ) result = await db.execute(stmt) completed_exams = result.scalar() or 0 # 假设每个成员应完成10个考试,计算完成率作为进度 total_expected = member_count * 10 if total_expected > 0: avg_progress = (completed_exams / total_expected) * 100 # 计算课程完成率 course_completion_rate = 0.0 if member_ids: # 统计已完成的课程数(有考试记录且成绩>=60) stmt = select(func.count(func.distinct(Exam.course_id))).where( and_( Exam.user_id.in_(member_ids), Exam.round1_score >= 60, Exam.status.in_(['completed', 'submitted']) ) ) result = await db.execute(stmt) completed_courses = result.scalar() or 0 # 统计总课程数 stmt = select(func.count(Course.id)).where( and_( Course.is_deleted == False, # noqa: E712 Course.status == 'published' ) ) result = await db.execute(stmt) total_courses = result.scalar() or 0 if total_courses > 0: course_completion_rate = (completed_courses / total_courses) * 100 # 趋势数据(暂时返回固定值,后续可实现真实趋势计算) trends = { "member_trend": 0, "progress_trend": 12.3 if avg_progress > 0 else 0, "score_trend": 5.8 if avg_score > 0 else 0, "completion_trend": -3.2 if course_completion_rate > 0 else 0 } data = { "team_count": team_count, "member_count": member_count, "avg_progress": round(avg_progress, 1), "avg_score": round(avg_score, 1), "course_completion_rate": round(course_completion_rate, 1), "trends": trends } return ResponseModel(code=200, message="success", data=data) except Exception as e: logger.error(f"获取团队概览失败: {e}", exc_info=True) return ResponseModel(code=500, message=f"获取团队概览失败: {str(e)}", data=None) @router.get("/progress", response_model=ResponseModel) async def get_progress_data( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ResponseModel: """ 获取学习进度数据 返回Top 5成员的8周学习进度数据 """ try: # 获取可访问的团队 team_ids = await get_accessible_teams(current_user, db) member_ids = await get_team_member_ids(team_ids, db) if not member_ids: return ResponseModel( code=200, message="success", data={"members": [], "weeks": [], "data": []} ) # 获取Top 5学习时长最高的成员 stmt = ( select( User.id, User.full_name, func.sum(PracticeSession.duration_seconds).label('total_duration') ) .join(PracticeSession, PracticeSession.user_id == User.id) .where( and_( User.id.in_(member_ids), PracticeSession.status == 'completed' ) ) .group_by(User.id, User.full_name) .order_by(func.sum(PracticeSession.duration_seconds).desc()) .limit(5) ) result = await db.execute(stmt) top_members = result.all() if not top_members: # 如果没有陪练记录,按考试成绩选择Top 5 stmt = ( select( User.id, User.full_name, func.avg(Exam.round1_score).label('avg_score') ) .join(Exam, Exam.user_id == User.id) .where( and_( User.id.in_(member_ids), Exam.round1_score.isnot(None), Exam.status.in_(['completed', 'submitted']) ) ) .group_by(User.id, User.full_name) .order_by(func.avg(Exam.round1_score).desc()) .limit(5) ) result = await db.execute(stmt) top_members = result.all() # 生成周标签 weeks = [f"第{i+1}周" for i in range(8)] # 为每个成员生成进度数据 members = [] data = [] for member in top_members: member_name = member.full_name or f"用户{member.id}" members.append(member_name) # 查询该成员8周内的考试完成情况 eight_weeks_ago = datetime.now() - timedelta(weeks=8) stmt = select(Exam).where( and_( Exam.user_id == member.id, Exam.created_at >= eight_weeks_ago, Exam.status.in_(['completed', 'submitted']) ) ).order_by(Exam.created_at) result = await db.execute(stmt) exams = result.scalars().all() # 计算每周的进度(0-100) values = [] for week in range(8): week_start = datetime.now() - timedelta(weeks=8-week) week_end = week_start + timedelta(weeks=1) # 统计该周完成的考试数 week_exams = [ e for e in exams if week_start <= e.created_at < week_end ] # 进度 = 累计完成考试数 * 10(假设每个考试代表10%进度) cumulative_exams = len([e for e in exams if e.created_at < week_end]) progress = min(cumulative_exams * 10, 100) values.append(progress) data.append({"name": member_name, "values": values}) return ResponseModel( code=200, message="success", data={"members": members, "weeks": weeks, "data": data} ) except Exception as e: logger.error(f"获取学习进度数据失败: {e}", exc_info=True) return ResponseModel(code=500, message=f"获取学习进度数据失败: {str(e)}", data=None) @router.get("/course-distribution", response_model=ResponseModel) async def get_course_distribution( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ResponseModel: """ 获取课程完成分布 返回已完成、进行中、未开始的课程数量 """ try: # 获取可访问的团队 team_ids = await get_accessible_teams(current_user, db) member_ids = await get_team_member_ids(team_ids, db) # 统计所有已发布的课程 stmt = select(func.count(Course.id)).where( and_( Course.is_deleted == False, # noqa: E712 Course.status == 'published' ) ) result = await db.execute(stmt) total_courses = result.scalar() or 0 if not member_ids or total_courses == 0: return ResponseModel( code=200, message="success", data={"completed": 0, "in_progress": 0, "not_started": 0} ) # 统计已完成的课程(有及格成绩) stmt = select(func.count(func.distinct(Exam.course_id))).where( and_( Exam.user_id.in_(member_ids), Exam.round1_score >= 60, Exam.status.in_(['completed', 'submitted']) ) ) result = await db.execute(stmt) completed = result.scalar() or 0 # 统计进行中的课程(有考试记录但未及格) stmt = select(func.count(func.distinct(Exam.course_id))).where( and_( Exam.user_id.in_(member_ids), or_( Exam.round1_score < 60, Exam.status == 'started' ) ) ) result = await db.execute(stmt) in_progress = result.scalar() or 0 # 未开始 = 总数 - 已完成 - 进行中 not_started = max(0, total_courses - completed - in_progress) data = { "completed": completed, "in_progress": in_progress, "not_started": not_started } return ResponseModel(code=200, message="success", data=data) except Exception as e: logger.error(f"获取课程分布失败: {e}", exc_info=True) return ResponseModel(code=500, message=f"获取课程分布失败: {str(e)}", data=None) @router.get("/ability-analysis", response_model=ResponseModel) async def get_ability_analysis( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ResponseModel: """ 获取能力分析数据 返回团队能力雷达图数据和短板列表 """ try: # 获取可访问的团队 team_ids = await get_accessible_teams(current_user, db) member_ids = await get_team_member_ids(team_ids, db) if not member_ids: return ResponseModel( code=200, message="success", data={ "radar_data": { "dimensions": [], "values": [] }, "weaknesses": [] } ) # 查询所有陪练报告的能力维度数据 # 需要通过PracticeSession关联,因为PracticeReport没有user_id stmt = ( select(PracticeReport.ability_dimensions) .join(PracticeSession, PracticeSession.session_id == PracticeReport.session_id) .where(PracticeSession.user_id.in_(member_ids)) ) result = await db.execute(stmt) all_dimensions = result.scalars().all() if not all_dimensions: # 如果没有陪练报告,返回默认能力维度 default_dimensions = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"] return ResponseModel( code=200, message="success", data={ "radar_data": { "dimensions": default_dimensions, "values": [0] * len(default_dimensions) }, "weaknesses": [] } ) # 聚合能力数据 ability_scores: Dict[str, List[float]] = {} # 能力维度名称映射 dimension_name_map = { "sales_ability": "销售能力", "service_attitude": "服务态度", "technical_skills": "技术能力", "沟通表达": "沟通表达", "倾听理解": "倾听理解", "需求挖掘": "需求挖掘", "异议处理": "异议处理", "成交技巧": "成交技巧", "客户维护": "客户维护" } for dimensions in all_dimensions: if dimensions: # 如果是字符串,进行JSON反序列化 if isinstance(dimensions, str): try: dimensions = json.loads(dimensions) except json.JSONDecodeError: logger.warning(f"无法解析能力维度数据: {dimensions}") continue # 处理字典格式:{"sales_ability": 79.0, ...} if isinstance(dimensions, dict): for key, score in dimensions.items(): name = dimension_name_map.get(key, key) if name not in ability_scores: ability_scores[name] = [] ability_scores[name].append(float(score)) # 处理列表格式:[{"name": "沟通表达", "score": 85}, ...] elif isinstance(dimensions, list): for dim in dimensions: if not isinstance(dim, dict): logger.warning(f"能力维度项格式错误: {type(dim)}") continue name = dim.get('name', '') score = dim.get('score', 0) if name: mapped_name = dimension_name_map.get(name, name) if mapped_name not in ability_scores: ability_scores[mapped_name] = [] ability_scores[mapped_name].append(float(score)) else: logger.warning(f"能力维度数据格式错误: {type(dimensions)}") # 计算平均分 avg_scores = { name: sum(scores) / len(scores) for name, scores in ability_scores.items() } # 按固定顺序排列维度(支持多种维度组合) # 优先使用六维度,如果没有则使用三维度 standard_dimensions_six = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"] standard_dimensions_three = ["销售能力", "服务态度", "技术能力"] # 判断使用哪种维度标准 has_six_dimensions = any(dim in avg_scores for dim in standard_dimensions_six) has_three_dimensions = any(dim in avg_scores for dim in standard_dimensions_three) if has_six_dimensions: standard_dimensions = standard_dimensions_six elif has_three_dimensions: standard_dimensions = standard_dimensions_three else: # 如果都没有,使用实际数据的维度 standard_dimensions = list(avg_scores.keys()) dimensions = [] values = [] for dim in standard_dimensions: if dim in avg_scores: dimensions.append(dim) values.append(round(avg_scores[dim], 1)) # 找出短板(平均分<80) weaknesses = [] weakness_suggestions = { # 六维度建议 "异议处理": "建议加强异议处理专项训练,增加实战演练", "成交技巧": "需要系统学习成交话术和时机把握", "需求挖掘": "提升提问技巧,深入了解客户需求", "沟通表达": "加强沟通技巧训练,提升表达能力", "倾听理解": "培养同理心,提高倾听和理解能力", "客户维护": "学习客户关系管理,提升服务质量", # 三维度建议 "销售能力": "建议加强销售技巧训练,提升成交率", "服务态度": "需要改善服务态度,提高客户满意度", "技术能力": "建议学习产品知识,提升专业能力" } for name, score in avg_scores.items(): if score < 80: weaknesses.append({ "name": name, "avg_score": int(score), "suggestion": weakness_suggestions.get(name, f"建议加强{name}专项训练") }) # 按分数升序排列 weaknesses.sort(key=lambda x: x['avg_score']) data = { "radar_data": { "dimensions": dimensions, "values": values }, "weaknesses": weaknesses } return ResponseModel(code=200, message="success", data=data) except Exception as e: logger.error(f"获取能力分析失败: {e}", exc_info=True) return ResponseModel(code=500, message=f"获取能力分析失败: {str(e)}", data=None) @router.get("/rankings", response_model=ResponseModel) async def get_rankings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ResponseModel: """ 获取排行榜数据 返回学习时长排行和成绩排行Top 5 """ try: # 获取可访问的团队 team_ids = await get_accessible_teams(current_user, db) member_ids = await get_team_member_ids(team_ids, db) if not member_ids: return ResponseModel( code=200, message="success", data={ "study_time_ranking": [], "score_ranking": [] } ) # 学习时长排行(基于陪练会话) stmt = ( select( User.id, User.full_name, User.avatar_url, Position.name.label('position_name'), func.sum(PracticeSession.duration_seconds).label('total_duration') ) .join(PracticeSession, PracticeSession.user_id == User.id) .outerjoin(PositionMember, and_( PositionMember.user_id == User.id, PositionMember.is_deleted == False # noqa: E712 )) .outerjoin(Position, Position.id == PositionMember.position_id) .where( and_( User.id.in_(member_ids), PracticeSession.status == 'completed' ) ) .group_by(User.id, User.full_name, User.avatar_url, Position.name) .order_by(func.sum(PracticeSession.duration_seconds).desc()) .limit(5) ) result = await db.execute(stmt) study_time_data = result.all() study_time_ranking = [] for row in study_time_data: study_time_ranking.append({ "id": row.id, "name": row.full_name or f"用户{row.id}", "position": row.position_name or "未分配岗位", "avatar": row.avatar_url or "", "study_time": round(row.total_duration / 3600, 1) # 转换为小时 }) # 成绩排行(基于考试round1_score) stmt = ( select( User.id, User.full_name, User.avatar_url, Position.name.label('position_name'), func.avg(Exam.round1_score).label('avg_score') ) .join(Exam, Exam.user_id == User.id) .outerjoin(PositionMember, and_( PositionMember.user_id == User.id, PositionMember.is_deleted == False # noqa: E712 )) .outerjoin(Position, Position.id == PositionMember.position_id) .where( and_( User.id.in_(member_ids), Exam.round1_score.isnot(None), Exam.status.in_(['completed', 'submitted']) ) ) .group_by(User.id, User.full_name, User.avatar_url, Position.name) .order_by(func.avg(Exam.round1_score).desc()) .limit(5) ) result = await db.execute(stmt) score_data = result.all() score_ranking = [] for row in score_data: score_ranking.append({ "id": row.id, "name": row.full_name or f"用户{row.id}", "position": row.position_name or "未分配岗位", "avatar": row.avatar_url or "", "avg_score": round(row.avg_score, 1) }) data = { "study_time_ranking": study_time_ranking, "score_ranking": score_ranking } return ResponseModel(code=200, message="success", data=data) except Exception as e: logger.error(f"获取排行榜失败: {e}", exc_info=True) return ResponseModel(code=500, message=f"获取排行榜失败: {str(e)}", data=None) @router.get("/activities", response_model=ResponseModel) async def get_activities( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ResponseModel: """ 获取团队学习动态 返回最近20条活动记录(考试、陪练等) """ try: # 获取可访问的团队 team_ids = await get_accessible_teams(current_user, db) member_ids = await get_team_member_ids(team_ids, db) if not member_ids: return ResponseModel( code=200, message="success", data={"activities": []} ) activities = [] # 获取最近的考试记录 stmt = ( select(Exam, User.full_name, Course.name.label('course_name')) .join(User, User.id == Exam.user_id) .join(Course, Course.id == Exam.course_id) .where( and_( Exam.user_id.in_(member_ids), Exam.status.in_(['completed', 'submitted']) ) ) .order_by(Exam.updated_at.desc()) .limit(10) ) result = await db.execute(stmt) exam_records = result.all() for exam, user_name, course_name in exam_records: score = exam.round1_score or 0 activity_type = "success" if score >= 60 else "danger" result_type = "success" if score >= 60 else "danger" result_text = f"成绩:{int(score)}分" if score >= 60 else "未通过" activities.append({ "id": f"exam_{exam.id}", "user_name": user_name or f"用户{exam.user_id}", "action": "完成了" if score >= 60 else "参加了", "target": f"《{course_name}》课程考试", "time": exam.updated_at.strftime("%Y-%m-%d %H:%M"), "type": activity_type, "result": {"type": result_type, "text": result_text} }) # 获取最近的陪练记录 stmt = ( select(PracticeSession, User.full_name, PracticeReport.total_score) .join(User, User.id == PracticeSession.user_id) .outerjoin(PracticeReport, PracticeReport.session_id == PracticeSession.session_id) .where( and_( PracticeSession.user_id.in_(member_ids), PracticeSession.status == 'completed' ) ) .order_by(PracticeSession.end_time.desc()) .limit(10) ) result = await db.execute(stmt) practice_records = result.all() for session, user_name, total_score in practice_records: activity_type = "primary" result_data = None if total_score: result_data = {"type": "", "text": f"评分:{int(total_score)}分"} activities.append({ "id": f"practice_{session.id}", "user_name": user_name or f"用户{session.user_id}", "action": "参加了", "target": "AI陪练训练", "time": session.end_time.strftime("%Y-%m-%d %H:%M") if session.end_time else "", "type": activity_type, "result": result_data }) # 按时间倒序排列,取前20条 activities.sort(key=lambda x: x['time'], reverse=True) activities = activities[:20] return ResponseModel( code=200, message="success", data={"activities": activities} ) except Exception as e: logger.error(f"获取团队动态失败: {e}", exc_info=True) return ResponseModel(code=500, message=f"获取团队动态失败: {str(e)}", data=None)