diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 2a8a627..fbb89d1 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -29,6 +29,7 @@ from .sql_executor import router as sql_executor_router from .exam import router as exam_router from .practice import router as practice_router +from .practice_room import router as practice_room_router from .course_chat import router as course_chat_router from .broadcast import router as broadcast_router from .preview import router as preview_router @@ -69,6 +70,8 @@ api_router.include_router(sql_executor_router, prefix="/sql", tags=["sql-executo api_router.include_router(exam_router, tags=["exams"]) # practice_router 陪练功能路由 api_router.include_router(practice_router, prefix="/practice", tags=["practice"]) +# practice_room_router 双人对练房间路由(prefix在router内部定义为/practice/rooms) +api_router.include_router(practice_room_router, tags=["practice-room"]) # course_chat_router 与课程对话路由 api_router.include_router(course_chat_router, prefix="/course", tags=["course-chat"]) # broadcast_router 播课功能路由(不添加prefix,路径在router内部定义) diff --git a/backend/app/api/v1/practice_room.py b/backend/app/api/v1/practice_room.py new file mode 100644 index 0000000..baefaee --- /dev/null +++ b/backend/app/api/v1/practice_room.py @@ -0,0 +1,617 @@ +""" +双人对练房间 API + +功能: +- 房间创建、加入、退出 +- 房间状态查询 +- 实时消息推送(SSE) +- 消息发送 +""" +import asyncio +import logging +from datetime import datetime +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, Field + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.services.practice_room_service import PracticeRoomService + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/practice/rooms", tags=["双人对练房间"]) + + +# ==================== Schema 定义 ==================== + +class CreateRoomRequest(BaseModel): + """创建房间请求""" + scene_id: Optional[int] = Field(None, description="场景ID") + scene_name: Optional[str] = Field(None, description="场景名称") + scene_type: Optional[str] = Field(None, description="场景类型") + scene_background: Optional[str] = Field(None, description="场景背景") + role_a_name: str = Field("销售顾问", description="角色A名称") + role_b_name: str = Field("顾客", description="角色B名称") + role_a_description: Optional[str] = Field(None, description="角色A描述") + role_b_description: Optional[str] = Field(None, description="角色B描述") + host_role: str = Field("A", description="房主选择的角色(A/B)") + room_name: Optional[str] = Field(None, description="房间名称") + + +class JoinRoomRequest(BaseModel): + """加入房间请求""" + room_code: str = Field(..., description="房间码") + + +class SendMessageRequest(BaseModel): + """发送消息请求""" + content: str = Field(..., description="消息内容") + + +class RoomResponse(BaseModel): + """房间响应""" + id: int + room_code: str + room_name: Optional[str] + scene_id: Optional[int] + scene_name: Optional[str] + scene_type: Optional[str] + role_a_name: str + role_b_name: str + host_user_id: int + guest_user_id: Optional[int] + host_role: str + status: str + created_at: datetime + started_at: Optional[datetime] + ended_at: Optional[datetime] + duration_seconds: int + total_turns: int + + class Config: + from_attributes = True + + +class RoomDetailResponse(BaseModel): + """房间详情响应(包含用户信息)""" + room: RoomResponse + host_user: Optional[dict] + guest_user: Optional[dict] + host_role_name: Optional[str] + guest_role_name: Optional[str] + my_role: Optional[str] + my_role_name: Optional[str] + + +class MessageResponse(BaseModel): + """消息响应""" + id: int + room_id: int + user_id: Optional[int] + message_type: str + content: Optional[str] + role_name: Optional[str] + sequence: int + created_at: datetime + + class Config: + from_attributes = True + + +# ==================== API 端点 ==================== + +@router.post("", summary="创建房间") +async def create_room( + request: CreateRoomRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 创建双人对练房间 + + - 返回房间码,可分享给对方加入 + - 房主可选择扮演角色A或B + """ + service = PracticeRoomService(db) + + try: + room = await service.create_room( + host_user_id=current_user.id, + scene_id=request.scene_id, + scene_name=request.scene_name, + scene_type=request.scene_type, + scene_background=request.scene_background, + role_a_name=request.role_a_name, + role_b_name=request.role_b_name, + role_a_description=request.role_a_description, + role_b_description=request.role_b_description, + host_role=request.host_role, + room_name=request.room_name + ) + + return { + "code": 200, + "message": "房间创建成功", + "data": { + "room_code": room.room_code, + "room_id": room.id, + "room_name": room.room_name, + "my_role": room.host_role, + "my_role_name": room.get_role_name(room.host_role) + } + } + except Exception as e: + logger.error(f"创建房间失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/join", summary="加入房间") +async def join_room( + request: JoinRoomRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 通过房间码加入房间 + + - 房间最多容纳2人 + - 加入后自动分配对方角色 + """ + service = PracticeRoomService(db) + + try: + room = await service.join_room( + room_code=request.room_code.upper(), + user_id=current_user.id + ) + + my_role = room.get_user_role(current_user.id) + + return { + "code": 200, + "message": "加入房间成功", + "data": { + "room_code": room.room_code, + "room_id": room.id, + "room_name": room.room_name, + "status": room.status, + "my_role": my_role, + "my_role_name": room.get_role_name(my_role) + } + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"加入房间失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{room_code}", summary="获取房间详情") +async def get_room( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取房间详情,包含参与者信息 + """ + service = PracticeRoomService(db) + + room_data = await service.get_room_with_users(room_code.upper()) + if not room_data: + raise HTTPException(status_code=404, detail="房间不存在") + + room = room_data["room"] + host_user = room_data["host_user"] + guest_user = room_data["guest_user"] + + my_role = room.get_user_role(current_user.id) + + return { + "code": 200, + "message": "success", + "data": { + "room": { + "id": room.id, + "room_code": room.room_code, + "room_name": room.room_name, + "scene_id": room.scene_id, + "scene_name": room.scene_name, + "scene_type": room.scene_type, + "scene_background": room.scene_background, + "role_a_name": room.role_a_name, + "role_b_name": room.role_b_name, + "role_a_description": room.role_a_description, + "role_b_description": room.role_b_description, + "host_role": room.host_role, + "status": room.status, + "created_at": room.created_at.isoformat() if room.created_at else None, + "started_at": room.started_at.isoformat() if room.started_at else None, + "ended_at": room.ended_at.isoformat() if room.ended_at else None, + "duration_seconds": room.duration_seconds, + "total_turns": room.total_turns, + "role_a_turns": room.role_a_turns, + "role_b_turns": room.role_b_turns + }, + "host_user": { + "id": host_user.id, + "username": host_user.username, + "full_name": host_user.full_name, + "avatar_url": host_user.avatar_url + } if host_user else None, + "guest_user": { + "id": guest_user.id, + "username": guest_user.username, + "full_name": guest_user.full_name, + "avatar_url": guest_user.avatar_url + } if guest_user else None, + "host_role_name": room_data["host_role_name"], + "guest_role_name": room_data["guest_role_name"], + "my_role": my_role, + "my_role_name": room.get_role_name(my_role) if my_role else None, + "is_host": current_user.id == room.host_user_id + } + } + + +@router.post("/{room_code}/start", summary="开始对练") +async def start_practice( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 开始对练(仅房主可操作) + + - 需要房间状态为 ready(双方都已加入) + """ + service = PracticeRoomService(db) + + try: + room = await service.start_practice( + room_code=room_code.upper(), + user_id=current_user.id + ) + + return { + "code": 200, + "message": "对练已开始", + "data": { + "room_code": room.room_code, + "status": room.status, + "started_at": room.started_at.isoformat() if room.started_at else None + } + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"开始对练失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{room_code}/end", summary="结束对练") +async def end_practice( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 结束对练 + + - 任意参与者都可结束 + - 结束后可查看分析报告 + """ + service = PracticeRoomService(db) + + try: + room = await service.end_practice( + room_code=room_code.upper(), + user_id=current_user.id + ) + + return { + "code": 200, + "message": "对练已结束", + "data": { + "room_code": room.room_code, + "room_id": room.id, + "status": room.status, + "duration_seconds": room.duration_seconds, + "total_turns": room.total_turns + } + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"结束对练失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{room_code}/leave", summary="离开房间") +async def leave_room( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 离开房间 + + - 房主离开则关闭房间 + - 嘉宾离开则房间回到等待状态 + """ + service = PracticeRoomService(db) + + success = await service.leave_room( + room_code=room_code.upper(), + user_id=current_user.id + ) + + if not success: + raise HTTPException(status_code=400, detail="离开房间失败") + + return { + "code": 200, + "message": "已离开房间" + } + + +@router.post("/{room_code}/message", summary="发送消息") +async def send_message( + room_code: str, + request: SendMessageRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 发送聊天消息 + + - 仅对练中状态可发送 + """ + service = PracticeRoomService(db) + + # 获取房间 + room = await service.get_room_by_code(room_code.upper()) + if not room: + raise HTTPException(status_code=404, detail="房间不存在") + + if room.status != "practicing": + raise HTTPException(status_code=400, detail="对练未在进行中") + + # 获取用户角色 + role_name = room.get_user_role_name(current_user.id) + if not role_name: + raise HTTPException(status_code=403, detail="您不是房间参与者") + + # 发送消息 + message = await service.send_message( + room_id=room.id, + user_id=current_user.id, + content=request.content, + role_name=role_name + ) + + return { + "code": 200, + "message": "发送成功", + "data": message.to_dict() + } + + +@router.get("/{room_code}/messages", summary="获取消息列表") +async def get_messages( + room_code: str, + since_sequence: int = Query(0, description="从该序号之后开始获取"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取房间消息列表 + + - 用于轮询获取新消息 + - 传入 since_sequence 只获取新消息 + """ + service = PracticeRoomService(db) + + room = await service.get_room_by_code(room_code.upper()) + if not room: + raise HTTPException(status_code=404, detail="房间不存在") + + messages = await service.get_messages( + room_id=room.id, + since_sequence=since_sequence + ) + + return { + "code": 200, + "message": "success", + "data": { + "messages": [msg.to_dict() for msg in messages], + "room_status": room.status, + "last_sequence": messages[-1].sequence if messages else since_sequence + } + } + + +@router.get("/{room_code}/stream", summary="消息流(SSE)") +async def message_stream( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 实时消息流(Server-Sent Events) + + - 用于实时接收房间消息 + - 前端使用 EventSource 连接 + """ + service = PracticeRoomService(db) + + room = await service.get_room_by_code(room_code.upper()) + if not room: + raise HTTPException(status_code=404, detail="房间不存在") + + async def event_generator(): + last_sequence = 0 + + while True: + # 获取新消息 + messages = await service.get_messages( + room_id=room.id, + since_sequence=last_sequence + ) + + for msg in messages: + yield f"event: message\ndata: {msg.to_dict()}\n\n" + last_sequence = msg.sequence + + # 检查房间状态 + room_status = await service.get_room_by_id(room.id) + if room_status and room_status.status in ["completed", "canceled"]: + yield f"event: room_closed\ndata: {{\"status\": \"{room_status.status}\"}}\n\n" + break + + # 等待一段时间再轮询 + await asyncio.sleep(0.5) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + +@router.get("/{room_code}/report", summary="获取或生成对练报告") +async def get_practice_report( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取双人对练报告 + + - 如果报告不存在,则调用 AI 生成 + - 返回双方表现评估 + """ + from app.services.ai.duo_practice_analysis_service import DuoPracticeAnalysisService + + service = PracticeRoomService(db) + + # 获取房间详情 + room_data = await service.get_room_with_users(room_code.upper()) + if not room_data: + raise HTTPException(status_code=404, detail="房间不存在") + + room = room_data["room"] + + # 检查房间是否已完成 + if room.status != "completed": + raise HTTPException(status_code=400, detail="对练尚未结束,无法生成报告") + + # 获取所有消息 + messages = await service.get_all_messages(room.id) + chat_messages = [m for m in messages if m.message_type == "chat"] + + if not chat_messages: + raise HTTPException(status_code=400, detail="暂无对话记录") + + # 准备对话历史 + dialogue_history = [ + { + "sequence": m.sequence, + "role_name": m.role_name, + "content": m.content, + "user_id": m.user_id + } + for m in chat_messages + ] + + # 获取用户名称 + host_name = room_data["host_user"].full_name if room_data["host_user"] else "用户A" + guest_name = room_data["guest_user"].full_name if room_data["guest_user"] else "用户B" + + # 确定角色对应的用户名 + if room.host_role == "A": + user_a_name = host_name + user_b_name = guest_name + else: + user_a_name = guest_name + user_b_name = host_name + + # 调用 AI 分析 + analysis_service = DuoPracticeAnalysisService() + result = await analysis_service.analyze( + scene_name=room.scene_name or "双人对练", + scene_background=room.scene_background or "", + role_a_name=room.role_a_name, + role_b_name=room.role_b_name, + role_a_description=room.role_a_description or f"扮演{room.role_a_name}", + role_b_description=room.role_b_description or f"扮演{room.role_b_name}", + user_a_name=user_a_name, + user_b_name=user_b_name, + dialogue_history=dialogue_history, + duration_seconds=room.duration_seconds, + total_turns=room.total_turns, + db=db + ) + + return { + "code": 200, + "message": "success", + "data": { + "room": { + "id": room.id, + "room_code": room.room_code, + "room_name": room.room_name, + "scene_name": room.scene_name, + "duration_seconds": room.duration_seconds, + "total_turns": room.total_turns + }, + "analysis": analysis_service.result_to_dict(result) + } + } + + +@router.get("", summary="获取我的房间列表") +async def get_my_rooms( + status: Optional[str] = Query(None, description="按状态筛选"), + limit: int = Query(20, description="数量限制"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取当前用户的房间列表 + """ + service = PracticeRoomService(db) + + rooms = await service.get_user_rooms( + user_id=current_user.id, + status=status, + limit=limit + ) + + return { + "code": 200, + "message": "success", + "data": { + "rooms": [ + { + "id": room.id, + "room_code": room.room_code, + "room_name": room.room_name, + "scene_name": room.scene_name, + "status": room.status, + "is_host": room.host_user_id == current_user.id, + "created_at": room.created_at.isoformat() if room.created_at else None, + "duration_seconds": room.duration_seconds, + "total_turns": room.total_turns + } + for room in rooms + ] + } + } diff --git a/backend/app/models/practice_room.py b/backend/app/models/practice_room.py new file mode 100644 index 0000000..6e58516 --- /dev/null +++ b/backend/app/models/practice_room.py @@ -0,0 +1,122 @@ +""" +双人对练房间模型 + +功能: +- 房间管理(创建、加入、状态) +- 参与者管理 +- 实时消息同步 +""" +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.models.base import Base + + +class PracticeRoom(Base): + """双人对练房间模型""" + __tablename__ = "practice_rooms" + + id = Column(Integer, primary_key=True, index=True, comment="房间ID") + room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码") + room_name = Column(String(200), comment="房间名称") + + # 场景信息 + scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID") + scene_name = Column(String(200), comment="场景名称") + scene_type = Column(String(50), comment="场景类型") + scene_background = Column(Text, comment="场景背景") + + # 角色设置 + role_a_name = Column(String(50), default="角色A", comment="角色A名称(如销售顾问)") + role_b_name = Column(String(50), default="角色B", comment="角色B名称(如顾客)") + role_a_description = Column(Text, comment="角色A描述") + role_b_description = Column(Text, comment="角色B描述") + + # 参与者信息 + host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID") + guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID") + host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)") + max_participants = Column(Integer, default=2, comment="最大参与人数") + + # 状态和时间 + status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + started_at = Column(DateTime, comment="开始时间") + ended_at = Column(DateTime, comment="结束时间") + duration_seconds = Column(Integer, default=0, comment="对练时长(秒)") + + # 对话统计 + total_turns = Column(Integer, default=0, comment="总对话轮次") + role_a_turns = Column(Integer, default=0, comment="角色A发言次数") + role_b_turns = Column(Integer, default=0, comment="角色B发言次数") + + # 软删除 + is_deleted = Column(Boolean, default=False, comment="是否删除") + deleted_at = Column(DateTime, comment="删除时间") + + def __repr__(self): + return f"" + + @property + def is_full(self) -> bool: + """房间是否已满""" + return self.guest_user_id is not None + + @property + def participant_count(self) -> int: + """当前参与人数""" + count = 1 # 房主 + if self.guest_user_id: + count += 1 + return count + + def get_user_role(self, user_id: int) -> str: + """获取用户在房间中的角色""" + if user_id == self.host_user_id: + return self.host_role + elif user_id == self.guest_user_id: + return "B" if self.host_role == "A" else "A" + return None + + def get_role_name(self, role: str) -> str: + """获取角色名称""" + if role == "A": + return self.role_a_name + elif role == "B": + return self.role_b_name + return None + + def get_user_role_name(self, user_id: int) -> str: + """获取用户的角色名称""" + role = self.get_user_role(user_id) + return self.get_role_name(role) if role else None + + +class PracticeRoomMessage(Base): + """房间实时消息模型""" + __tablename__ = "practice_room_messages" + + id = Column(Integer, primary_key=True, index=True, comment="消息ID") + room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID") + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID") + message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end") + content = Column(Text, comment="消息内容") + role_name = Column(String(50), comment="角色名称") + sequence = Column(Integer, nullable=False, comment="消息序号") + created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间") + + def __repr__(self): + return f"" + + def to_dict(self) -> dict: + """转换为字典(用于SSE推送)""" + return { + "id": self.id, + "room_id": self.room_id, + "user_id": self.user_id, + "message_type": self.message_type, + "content": self.content, + "role_name": self.role_name, + "sequence": self.sequence, + "created_at": self.created_at.isoformat() if self.created_at else None + } diff --git a/backend/app/services/ai/duo_practice_analysis_service.py b/backend/app/services/ai/duo_practice_analysis_service.py new file mode 100644 index 0000000..f598703 --- /dev/null +++ b/backend/app/services/ai/duo_practice_analysis_service.py @@ -0,0 +1,323 @@ +""" +双人对练分析服务 + +功能: +- 分析双人对练对话 +- 生成双方评估报告 +- 对话标注和建议 +""" +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from app.services.ai.ai_service import AIService +from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT + +logger = logging.getLogger(__name__) + + +@dataclass +class UserEvaluation: + """用户评估结果""" + user_name: str + role_name: str + total_score: int + dimensions: Dict[str, Dict[str, Any]] + highlights: List[str] + improvements: List[Dict[str, str]] + + +@dataclass +class DuoPracticeAnalysisResult: + """双人对练分析结果""" + # 整体评估 + interaction_quality: int = 0 + scene_restoration: int = 0 + overall_comment: str = "" + + # 用户A评估 + user_a_evaluation: Optional[UserEvaluation] = None + + # 用户B评估 + user_b_evaluation: Optional[UserEvaluation] = None + + # 对话标注 + dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list) + + # AI 元数据 + raw_response: str = "" + ai_provider: str = "" + ai_model: str = "" + ai_latency_ms: int = 0 + + +class DuoPracticeAnalysisService: + """ + 双人对练分析服务 + + 使用示例: + ```python + service = DuoPracticeAnalysisService() + result = await service.analyze( + scene_name="销售场景", + scene_background="客户咨询产品", + role_a_name="销售顾问", + role_b_name="顾客", + user_a_name="张三", + user_b_name="李四", + dialogue_history=dialogue_list, + duration_seconds=300, + total_turns=20 + ) + ``` + """ + + MODULE_CODE = "duo_practice_analysis" + + async def analyze( + self, + scene_name: str, + scene_background: str, + role_a_name: str, + role_b_name: str, + role_a_description: str, + role_b_description: str, + user_a_name: str, + user_b_name: str, + dialogue_history: List[Dict[str, Any]], + duration_seconds: int, + total_turns: int, + db: Any = None + ) -> DuoPracticeAnalysisResult: + """ + 分析双人对练 + + Args: + scene_name: 场景名称 + scene_background: 场景背景 + role_a_name: 角色A名称 + role_b_name: 角色B名称 + role_a_description: 角色A描述 + role_b_description: 角色B描述 + user_a_name: 用户A名称 + user_b_name: 用户B名称 + dialogue_history: 对话历史列表 + duration_seconds: 对练时长(秒) + total_turns: 总对话轮次 + db: 数据库会话 + + Returns: + DuoPracticeAnalysisResult: 分析结果 + """ + try: + logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}") + + # 格式化对话历史 + dialogue_text = self._format_dialogue_history(dialogue_history) + + # 创建 AI 服务 + ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + + # 构建用户提示词 + user_prompt = USER_PROMPT.format( + scene_name=scene_name, + scene_background=scene_background or "未设置", + role_a_name=role_a_name, + role_b_name=role_b_name, + role_a_description=role_a_description or f"扮演{role_a_name}角色", + role_b_description=role_b_description or f"扮演{role_b_name}角色", + user_a_name=user_a_name, + user_b_name=user_b_name, + dialogue_history=dialogue_text, + duration_seconds=duration_seconds, + total_turns=total_turns + ) + + # 调用 AI + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt} + ] + + ai_response = await ai_service.chat( + messages=messages, + model="gemini-3-flash-preview", # 使用快速模型 + temperature=0.3, + prompt_name="duo_practice_analysis" + ) + + logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms") + + # 解析 AI 输出 + result = self._parse_analysis_result( + ai_response.content, + user_a_name=user_a_name, + user_b_name=user_b_name, + role_a_name=role_a_name, + role_b_name=role_b_name + ) + + # 填充 AI 元数据 + result.raw_response = ai_response.content + result.ai_provider = ai_response.provider + result.ai_model = ai_response.model + result.ai_latency_ms = ai_response.latency_ms + + return result + + except Exception as e: + logger.error(f"双人对练分析失败: {e}", exc_info=True) + # 返回空结果 + return DuoPracticeAnalysisResult( + overall_comment=f"分析失败: {str(e)}" + ) + + def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str: + """格式化对话历史""" + lines = [] + for d in dialogues: + speaker = d.get("role_name") or d.get("speaker", "未知") + content = d.get("content", "") + seq = d.get("sequence", 0) + lines.append(f"[{seq}] {speaker}:{content}") + return "\n".join(lines) + + def _parse_analysis_result( + self, + ai_output: str, + user_a_name: str, + user_b_name: str, + role_a_name: str, + role_b_name: str + ) -> DuoPracticeAnalysisResult: + """解析 AI 输出""" + result = DuoPracticeAnalysisResult() + + try: + # 尝试提取 JSON + json_str = ai_output + + # 如果输出包含 markdown 代码块,提取其中的 JSON + if "```json" in ai_output: + start = ai_output.find("```json") + 7 + end = ai_output.find("```", start) + json_str = ai_output[start:end].strip() + elif "```" in ai_output: + start = ai_output.find("```") + 3 + end = ai_output.find("```", start) + json_str = ai_output[start:end].strip() + + data = json.loads(json_str) + + # 解析整体评估 + overall = data.get("overall_evaluation", {}) + result.interaction_quality = overall.get("interaction_quality", 0) + result.scene_restoration = overall.get("scene_restoration", 0) + result.overall_comment = overall.get("overall_comment", "") + + # 解析用户A评估 + user_a_data = data.get("user_a_evaluation", {}) + if user_a_data: + result.user_a_evaluation = UserEvaluation( + user_name=user_a_data.get("user_name", user_a_name), + role_name=user_a_data.get("role_name", role_a_name), + total_score=user_a_data.get("total_score", 0), + dimensions=user_a_data.get("dimensions", {}), + highlights=user_a_data.get("highlights", []), + improvements=user_a_data.get("improvements", []) + ) + + # 解析用户B评估 + user_b_data = data.get("user_b_evaluation", {}) + if user_b_data: + result.user_b_evaluation = UserEvaluation( + user_name=user_b_data.get("user_name", user_b_name), + role_name=user_b_data.get("role_name", role_b_name), + total_score=user_b_data.get("total_score", 0), + dimensions=user_b_data.get("dimensions", {}), + highlights=user_b_data.get("highlights", []), + improvements=user_b_data.get("improvements", []) + ) + + # 解析对话标注 + result.dialogue_annotations = data.get("dialogue_annotations", []) + + except json.JSONDecodeError as e: + logger.warning(f"JSON 解析失败: {e}") + result.overall_comment = "AI 输出格式异常,请重试" + except Exception as e: + logger.error(f"解析分析结果失败: {e}") + result.overall_comment = f"解析失败: {str(e)}" + + return result + + def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]: + """将结果转换为字典(用于 API 响应)""" + return { + "overall_evaluation": { + "interaction_quality": result.interaction_quality, + "scene_restoration": result.scene_restoration, + "overall_comment": result.overall_comment + }, + "user_a_evaluation": { + "user_name": result.user_a_evaluation.user_name, + "role_name": result.user_a_evaluation.role_name, + "total_score": result.user_a_evaluation.total_score, + "dimensions": result.user_a_evaluation.dimensions, + "highlights": result.user_a_evaluation.highlights, + "improvements": result.user_a_evaluation.improvements + } if result.user_a_evaluation else None, + "user_b_evaluation": { + "user_name": result.user_b_evaluation.user_name, + "role_name": result.user_b_evaluation.role_name, + "total_score": result.user_b_evaluation.total_score, + "dimensions": result.user_b_evaluation.dimensions, + "highlights": result.user_b_evaluation.highlights, + "improvements": result.user_b_evaluation.improvements + } if result.user_b_evaluation else None, + "dialogue_annotations": result.dialogue_annotations, + "ai_metadata": { + "provider": result.ai_provider, + "model": result.ai_model, + "latency_ms": result.ai_latency_ms + } + } + + +# ==================== 全局实例 ==================== + +duo_practice_analysis_service = DuoPracticeAnalysisService() + + +# ==================== 便捷函数 ==================== + +async def analyze_duo_practice( + scene_name: str, + scene_background: str, + role_a_name: str, + role_b_name: str, + role_a_description: str, + role_b_description: str, + user_a_name: str, + user_b_name: str, + dialogue_history: List[Dict[str, Any]], + duration_seconds: int, + total_turns: int, + db: Any = None +) -> DuoPracticeAnalysisResult: + """便捷函数:分析双人对练""" + return await duo_practice_analysis_service.analyze( + scene_name=scene_name, + scene_background=scene_background, + role_a_name=role_a_name, + role_b_name=role_b_name, + role_a_description=role_a_description, + role_b_description=role_b_description, + user_a_name=user_a_name, + user_b_name=user_b_name, + dialogue_history=dialogue_history, + duration_seconds=duration_seconds, + total_turns=total_turns, + db=db + ) diff --git a/backend/app/services/ai/prompts/duo_practice_prompts.py b/backend/app/services/ai/prompts/duo_practice_prompts.py new file mode 100644 index 0000000..a9190b2 --- /dev/null +++ b/backend/app/services/ai/prompts/duo_practice_prompts.py @@ -0,0 +1,207 @@ +""" +双人对练评估提示词模板 + +功能:评估双人角色扮演对练的表现 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "duo_practice_analysis", + "display_name": "双人对练评估", + "description": "评估双人角色扮演对练中双方的表现", + "module": "kaopeilian", + "variables": [ + "scene_name", "scene_background", + "role_a_name", "role_b_name", + "role_a_description", "role_b_description", + "user_a_name", "user_b_name", + "dialogue_history", + "duration_seconds", "total_turns" + ], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。 +你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。 + +评估原则: +1. 客观公正,基于对话内容给出评价 +2. 突出亮点,指出不足 +3. 给出具体、可操作的改进建议 +4. 考虑角色特点,评估角色代入度 + +输出格式要求: +- 必须返回有效的 JSON 格式 +- 分数范围 0-100 +- 建议具体可行""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """# 双人对练评估任务 + +## 场景信息 +- **场景名称**:{scene_name} +- **场景背景**:{scene_background} + +## 角色设置 +### {role_a_name} +- **扮演者**:{user_a_name} +- **角色描述**:{role_a_description} + +### {role_b_name} +- **扮演者**:{user_b_name} +- **角色描述**:{role_b_description} + +## 对练数据 +- **对练时长**:{duration_seconds} 秒 +- **总对话轮次**:{total_turns} 轮 + +## 对话记录 +{dialogue_history} + +--- + +## 评估要求 + +请按以下 JSON 格式输出评估结果: + +```json +{{ + "overall_evaluation": {{ + "interaction_quality": 85, + "scene_restoration": 80, + "overall_comment": "整体评价..." + }}, + "user_a_evaluation": {{ + "user_name": "{user_a_name}", + "role_name": "{role_a_name}", + "total_score": 85, + "dimensions": {{ + "role_immersion": {{ + "score": 85, + "comment": "角色代入度评价..." + }}, + "communication": {{ + "score": 80, + "comment": "沟通表达能力评价..." + }}, + "professional_knowledge": {{ + "score": 75, + "comment": "专业知识运用评价..." + }}, + "response_quality": {{ + "score": 82, + "comment": "回应质量评价..." + }}, + "goal_achievement": {{ + "score": 78, + "comment": "目标达成度评价..." + }} + }}, + "highlights": [ + "亮点1...", + "亮点2..." + ], + "improvements": [ + {{ + "issue": "问题描述", + "suggestion": "改进建议", + "example": "示例话术" + }} + ] + }}, + "user_b_evaluation": {{ + "user_name": "{user_b_name}", + "role_name": "{role_b_name}", + "total_score": 82, + "dimensions": {{ + "role_immersion": {{ + "score": 80, + "comment": "角色代入度评价..." + }}, + "communication": {{ + "score": 85, + "comment": "沟通表达能力评价..." + }}, + "professional_knowledge": {{ + "score": 78, + "comment": "专业知识运用评价..." + }}, + "response_quality": {{ + "score": 80, + "comment": "回应质量评价..." + }}, + "goal_achievement": {{ + "score": 75, + "comment": "目标达成度评价..." + }} + }}, + "highlights": [ + "亮点1...", + "亮点2..." + ], + "improvements": [ + {{ + "issue": "问题描述", + "suggestion": "改进建议", + "example": "示例话术" + }} + ] + }}, + "dialogue_annotations": [ + {{ + "sequence": 1, + "speaker": "{role_a_name}", + "tags": ["good_opening"], + "comment": "开场白自然得体" + }}, + {{ + "sequence": 3, + "speaker": "{role_b_name}", + "tags": ["needs_improvement"], + "comment": "可以更主动表达需求" + }} + ] +}} +``` + +请基于对话内容,给出客观、专业的评估。""" + + +# ==================== 维度说明 ==================== + +DIMENSION_DESCRIPTIONS = { + "role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定", + "communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体", + "professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备", + "response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性", + "goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果" +} + + +# ==================== 对话标签 ==================== + +DIALOGUE_TAGS = { + # 正面标签 + "good_opening": "开场良好", + "active_listening": "积极倾听", + "empathy": "共情表达", + "professional": "专业表现", + "good_closing": "结束得体", + "creative_response": "创意回应", + "problem_solving": "问题解决", + + # 需改进标签 + "needs_improvement": "需要改进", + "off_topic": "偏离主题", + "too_passive": "过于被动", + "lack_detail": "缺乏细节", + "missed_opportunity": "错失机会", + "unclear_expression": "表达不清" +} diff --git a/backend/app/services/practice_room_service.py b/backend/app/services/practice_room_service.py new file mode 100644 index 0000000..d9afa13 --- /dev/null +++ b/backend/app/services/practice_room_service.py @@ -0,0 +1,514 @@ +""" +双人对练房间服务 + +功能: +- 房间创建、加入、退出 +- 房间状态管理 +- 消息广播 +- 对练结束处理 +""" +import logging +import random +import string +from datetime import datetime +from typing import Optional, List, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, and_ +from sqlalchemy.orm import selectinload + +from app.models.practice_room import PracticeRoom, PracticeRoomMessage +from app.models.practice import PracticeDialogue, PracticeSession +from app.models.user import User + +logger = logging.getLogger(__name__) + + +class PracticeRoomService: + """双人对练房间服务""" + + # 房间状态常量 + STATUS_WAITING = "waiting" # 等待加入 + STATUS_READY = "ready" # 准备就绪 + STATUS_PRACTICING = "practicing" # 对练中 + STATUS_COMPLETED = "completed" # 已完成 + STATUS_CANCELED = "canceled" # 已取消 + + # 消息类型常量 + MSG_TYPE_CHAT = "chat" # 聊天消息 + MSG_TYPE_SYSTEM = "system" # 系统消息 + MSG_TYPE_JOIN = "join" # 加入消息 + MSG_TYPE_LEAVE = "leave" # 离开消息 + MSG_TYPE_START = "start" # 开始消息 + MSG_TYPE_END = "end" # 结束消息 + + def __init__(self, db: AsyncSession): + self.db = db + + # ==================== 房间管理 ==================== + + async def create_room( + self, + host_user_id: int, + scene_id: Optional[int] = None, + scene_name: Optional[str] = None, + scene_type: Optional[str] = None, + scene_background: Optional[str] = None, + role_a_name: str = "销售顾问", + role_b_name: str = "顾客", + role_a_description: Optional[str] = None, + role_b_description: Optional[str] = None, + host_role: str = "A", + room_name: Optional[str] = None + ) -> PracticeRoom: + """ + 创建对练房间 + + Args: + host_user_id: 房主用户ID + scene_id: 场景ID(可选) + scene_name: 场景名称 + scene_type: 场景类型 + scene_background: 场景背景 + role_a_name: 角色A名称 + role_b_name: 角色B名称 + role_a_description: 角色A描述 + role_b_description: 角色B描述 + host_role: 房主选择的角色(A或B) + room_name: 房间名称 + + Returns: + PracticeRoom: 创建的房间对象 + """ + # 生成唯一的6位房间码 + room_code = await self._generate_unique_room_code() + + # 创建房间 + room = PracticeRoom( + room_code=room_code, + room_name=room_name or f"{scene_name or '双人对练'}房间", + scene_id=scene_id, + scene_name=scene_name, + scene_type=scene_type, + scene_background=scene_background, + role_a_name=role_a_name, + role_b_name=role_b_name, + role_a_description=role_a_description, + role_b_description=role_b_description, + host_user_id=host_user_id, + host_role=host_role, + status=self.STATUS_WAITING + ) + + self.db.add(room) + await self.db.commit() + await self.db.refresh(room) + + logger.info(f"创建房间成功: room_code={room_code}, host_user_id={host_user_id}") + return room + + async def join_room( + self, + room_code: str, + user_id: int + ) -> PracticeRoom: + """ + 加入房间 + + Args: + room_code: 房间码 + user_id: 用户ID + + Returns: + PracticeRoom: 房间对象 + + Raises: + ValueError: 房间不存在、已满或状态不允许加入 + """ + # 查询房间 + room = await self.get_room_by_code(room_code) + if not room: + raise ValueError("房间不存在或已过期") + + # 检查是否是房主(房主重新进入) + if room.host_user_id == user_id: + return room + + # 检查房间状态 + if room.status not in [self.STATUS_WAITING, self.STATUS_READY]: + raise ValueError("房间已开始对练或已结束,无法加入") + + # 检查是否已满 + if room.guest_user_id and room.guest_user_id != user_id: + raise ValueError("房间已满") + + # 加入房间 + room.guest_user_id = user_id + room.status = self.STATUS_READY + + await self.db.commit() + await self.db.refresh(room) + + # 发送系统消息 + await self._add_system_message(room.id, f"用户已加入房间", self.MSG_TYPE_JOIN, user_id) + + logger.info(f"用户加入房间: room_code={room_code}, user_id={user_id}") + return room + + async def leave_room( + self, + room_code: str, + user_id: int + ) -> bool: + """ + 离开房间 + + Args: + room_code: 房间码 + user_id: 用户ID + + Returns: + bool: 是否成功离开 + """ + room = await self.get_room_by_code(room_code) + if not room: + return False + + # 如果是房主离开,取消房间 + if room.host_user_id == user_id: + room.status = self.STATUS_CANCELED + await self._add_system_message(room.id, "房主离开,房间已关闭", self.MSG_TYPE_LEAVE, user_id) + # 如果是嘉宾离开 + elif room.guest_user_id == user_id: + room.guest_user_id = None + room.status = self.STATUS_WAITING + await self._add_system_message(room.id, "对方已离开房间", self.MSG_TYPE_LEAVE, user_id) + else: + return False + + await self.db.commit() + logger.info(f"用户离开房间: room_code={room_code}, user_id={user_id}") + return True + + async def start_practice( + self, + room_code: str, + user_id: int + ) -> PracticeRoom: + """ + 开始对练(仅房主可操作) + + Args: + room_code: 房间码 + user_id: 用户ID(必须是房主) + + Returns: + PracticeRoom: 房间对象 + """ + room = await self.get_room_by_code(room_code) + if not room: + raise ValueError("房间不存在") + + if room.host_user_id != user_id: + raise ValueError("只有房主可以开始对练") + + if room.status != self.STATUS_READY: + raise ValueError("房间未就绪,请等待对方加入") + + room.status = self.STATUS_PRACTICING + room.started_at = datetime.now() + + await self.db.commit() + await self.db.refresh(room) + + # 发送开始消息 + await self._add_system_message(room.id, "对练开始!", self.MSG_TYPE_START) + + logger.info(f"对练开始: room_code={room_code}") + return room + + async def end_practice( + self, + room_code: str, + user_id: int + ) -> PracticeRoom: + """ + 结束对练 + + Args: + room_code: 房间码 + user_id: 用户ID + + Returns: + PracticeRoom: 房间对象 + """ + room = await self.get_room_by_code(room_code) + if not room: + raise ValueError("房间不存在") + + if room.status != self.STATUS_PRACTICING: + raise ValueError("对练未在进行中") + + # 计算时长 + if room.started_at: + duration = (datetime.now() - room.started_at).total_seconds() + room.duration_seconds = int(duration) + + room.status = self.STATUS_COMPLETED + room.ended_at = datetime.now() + + await self.db.commit() + await self.db.refresh(room) + + # 发送结束消息 + await self._add_system_message(room.id, "对练结束!", self.MSG_TYPE_END) + + logger.info(f"对练结束: room_code={room_code}, duration={room.duration_seconds}s") + return room + + # ==================== 消息管理 ==================== + + async def send_message( + self, + room_id: int, + user_id: int, + content: str, + role_name: Optional[str] = None + ) -> PracticeRoomMessage: + """ + 发送聊天消息 + + Args: + room_id: 房间ID + user_id: 发送者ID + content: 消息内容 + role_name: 角色名称 + + Returns: + PracticeRoomMessage: 消息对象 + """ + # 获取当前消息序号 + sequence = await self._get_next_sequence(room_id) + + message = PracticeRoomMessage( + room_id=room_id, + user_id=user_id, + message_type=self.MSG_TYPE_CHAT, + content=content, + role_name=role_name, + sequence=sequence + ) + + self.db.add(message) + + # 更新房间统计 + room = await self.get_room_by_id(room_id) + if room: + room.total_turns += 1 + user_role = room.get_user_role(user_id) + if user_role == "A": + room.role_a_turns += 1 + elif user_role == "B": + room.role_b_turns += 1 + + await self.db.commit() + await self.db.refresh(message) + + return message + + async def get_messages( + self, + room_id: int, + since_sequence: int = 0, + limit: int = 100 + ) -> List[PracticeRoomMessage]: + """ + 获取房间消息(用于SSE轮询) + + Args: + room_id: 房间ID + since_sequence: 从该序号之后开始获取 + limit: 最大数量 + + Returns: + List[PracticeRoomMessage]: 消息列表 + """ + result = await self.db.execute( + select(PracticeRoomMessage) + .where( + and_( + PracticeRoomMessage.room_id == room_id, + PracticeRoomMessage.sequence > since_sequence + ) + ) + .order_by(PracticeRoomMessage.sequence) + .limit(limit) + ) + return list(result.scalars().all()) + + async def get_all_messages(self, room_id: int) -> List[PracticeRoomMessage]: + """ + 获取房间所有消息 + + Args: + room_id: 房间ID + + Returns: + List[PracticeRoomMessage]: 消息列表 + """ + result = await self.db.execute( + select(PracticeRoomMessage) + .where(PracticeRoomMessage.room_id == room_id) + .order_by(PracticeRoomMessage.sequence) + ) + return list(result.scalars().all()) + + # ==================== 查询方法 ==================== + + async def get_room_by_code(self, room_code: str) -> Optional[PracticeRoom]: + """根据房间码获取房间""" + result = await self.db.execute( + select(PracticeRoom).where( + and_( + PracticeRoom.room_code == room_code, + PracticeRoom.is_deleted == False + ) + ) + ) + return result.scalar_one_or_none() + + async def get_room_by_id(self, room_id: int) -> Optional[PracticeRoom]: + """根据ID获取房间""" + result = await self.db.execute( + select(PracticeRoom).where( + and_( + PracticeRoom.id == room_id, + PracticeRoom.is_deleted == False + ) + ) + ) + return result.scalar_one_or_none() + + async def get_user_rooms( + self, + user_id: int, + status: Optional[str] = None, + limit: int = 20 + ) -> List[PracticeRoom]: + """获取用户的房间列表""" + query = select(PracticeRoom).where( + and_( + (PracticeRoom.host_user_id == user_id) | (PracticeRoom.guest_user_id == user_id), + PracticeRoom.is_deleted == False + ) + ) + + if status: + query = query.where(PracticeRoom.status == status) + + query = query.order_by(PracticeRoom.created_at.desc()).limit(limit) + + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def get_room_with_users(self, room_code: str) -> Optional[Dict[str, Any]]: + """获取房间详情(包含用户信息)""" + room = await self.get_room_by_code(room_code) + if not room: + return None + + # 获取用户信息 + host_user = None + guest_user = None + + if room.host_user_id: + result = await self.db.execute( + select(User).where(User.id == room.host_user_id) + ) + host_user = result.scalar_one_or_none() + + if room.guest_user_id: + result = await self.db.execute( + select(User).where(User.id == room.guest_user_id) + ) + guest_user = result.scalar_one_or_none() + + return { + "room": room, + "host_user": host_user, + "guest_user": guest_user, + "host_role_name": room.get_role_name(room.host_role), + "guest_role_name": room.get_role_name("B" if room.host_role == "A" else "A") if guest_user else None + } + + # ==================== 辅助方法 ==================== + + async def _generate_unique_room_code(self) -> str: + """生成唯一的6位房间码""" + for _ in range(10): # 最多尝试10次 + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + # 排除容易混淆的字符 + code = code.replace('0', 'X').replace('O', 'Y').replace('I', 'Z').replace('1', 'W') + + # 检查是否已存在 + existing = await self.get_room_by_code(code) + if not existing: + return code + + raise ValueError("无法生成唯一房间码,请稍后重试") + + async def _get_next_sequence(self, room_id: int) -> int: + """获取下一个消息序号""" + result = await self.db.execute( + select(PracticeRoomMessage.sequence) + .where(PracticeRoomMessage.room_id == room_id) + .order_by(PracticeRoomMessage.sequence.desc()) + .limit(1) + ) + last_seq = result.scalar_one_or_none() + return (last_seq or 0) + 1 + + async def _add_system_message( + self, + room_id: int, + content: str, + msg_type: str, + user_id: Optional[int] = None + ) -> PracticeRoomMessage: + """添加系统消息""" + sequence = await self._get_next_sequence(room_id) + + message = PracticeRoomMessage( + room_id=room_id, + user_id=user_id, + message_type=msg_type, + content=content, + sequence=sequence + ) + + self.db.add(message) + await self.db.commit() + await self.db.refresh(message) + + return message + + +# ==================== 便捷函数 ==================== + +async def create_practice_room( + db: AsyncSession, + host_user_id: int, + **kwargs +) -> PracticeRoom: + """便捷函数:创建房间""" + service = PracticeRoomService(db) + return await service.create_room(host_user_id, **kwargs) + + +async def join_practice_room( + db: AsyncSession, + room_code: str, + user_id: int +) -> PracticeRoom: + """便捷函数:加入房间""" + service = PracticeRoomService(db) + return await service.join_room(room_code, user_id) diff --git a/backend/migrations/README.md b/backend/migrations/README.md new file mode 100644 index 0000000..48b15b0 --- /dev/null +++ b/backend/migrations/README.md @@ -0,0 +1,82 @@ +# 数据库迁移说明 + +## 目录结构 + +``` +migrations/ +├── README.md # 本说明文件 +└── versions/ + └── add_practice_rooms_table.sql # 双人对练功能迁移脚本 +``` + +## 迁移脚本列表 + +### 1. add_practice_rooms_table.sql + +**功能**:双人对练功能数据库结构 + +**新增表**: +- `practice_rooms` - 对练房间表 +- `practice_room_messages` - 房间实时消息表 + +**修改表**: +- `practice_dialogues` - 新增 `user_id`, `role_name`, `room_id`, `message_type` 字段 +- `practice_sessions` - 新增 `room_id`, `participant_role`, `session_type` 字段 +- `practice_reports` - 新增 `room_id`, `user_id`, `report_type`, `partner_feedback`, `interaction_score` 字段 + +## 执行方法 + +### 方法一:直接登录 MySQL 执行 + +```bash +# 1. 登录 MySQL +docker exec -it kpl-mysql-dev mysql -uroot -p + +# 2. 选择数据库 +USE kaopeilian; + +# 3. 复制并执行 SQL 脚本内容 +# 或使用 source 命令执行脚本文件 +``` + +### 方法二:使用 docker exec 执行 + +```bash +# 1. 将 SQL 文件复制到容器 +docker cp migrations/versions/add_practice_rooms_table.sql kpl-mysql-dev:/tmp/ + +# 2. 执行迁移 +docker exec -i kpl-mysql-dev mysql -uroot -pYourPassword kaopeilian < /tmp/add_practice_rooms_table.sql +``` + +### 方法三:远程 SSH 执行 + +```bash +# SSH 到服务器后执行 +ssh user@your-server "docker exec -i kpl-mysql-dev mysql -uroot -pYourPassword kaopeilian" < migrations/versions/add_practice_rooms_table.sql +``` + +## 回滚方法 + +每个迁移脚本底部都包含了回滚 SQL。如需回滚,取消注释并执行回滚部分的 SQL 即可。 + +## 生产环境迁移检查清单 + +- [ ] 备份数据库 +- [ ] 在测试环境验证迁移脚本 +- [ ] 检查是否有正在进行的事务 +- [ ] 执行迁移脚本 +- [ ] 验证表结构是否正确 +- [ ] 验证索引是否创建成功 +- [ ] 重启后端服务(如有必要) +- [ ] 验证功能是否正常 + +## 注意事项 + +1. **MySQL 版本兼容性**:脚本使用了 `IF NOT EXISTS` 和 `IF EXISTS`,确保 MySQL 版本支持这些语法(8.0+ 完全支持) + +2. **字符集**:表默认使用 `utf8mb4` 字符集,支持表情符号等特殊字符 + +3. **外键约束**:脚本中的外键约束默认被注释,根据实际需求决定是否启用 + +4. **索引优化**:已为常用查询字段创建索引,如需调整请根据实际查询模式优化 diff --git a/backend/migrations/versions/add_practice_rooms_table.sql b/backend/migrations/versions/add_practice_rooms_table.sql new file mode 100644 index 0000000..dd26d62 --- /dev/null +++ b/backend/migrations/versions/add_practice_rooms_table.sql @@ -0,0 +1,186 @@ +-- ============================================================================ +-- 双人对练功能数据库迁移脚本 +-- 版本: 2026-01-28 +-- 功能: 新增对练房间表,扩展现有表支持多人对练 +-- ============================================================================ + +-- ============================================================================ +-- 1. 创建对练房间表 practice_rooms +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `practice_rooms` ( + `id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '房间ID', + `room_code` VARCHAR(10) NOT NULL UNIQUE COMMENT '6位邀请码', + `room_name` VARCHAR(200) COMMENT '房间名称', + + -- 场景信息 + `scene_id` INT COMMENT '关联场景ID', + `scene_name` VARCHAR(200) COMMENT '场景名称', + `scene_type` VARCHAR(50) COMMENT '场景类型', + `scene_background` TEXT COMMENT '场景背景', + + -- 角色设置 + `role_a_name` VARCHAR(50) DEFAULT '角色A' COMMENT '角色A名称(如销售顾问)', + `role_b_name` VARCHAR(50) DEFAULT '角色B' COMMENT '角色B名称(如顾客)', + `role_a_description` TEXT COMMENT '角色A描述', + `role_b_description` TEXT COMMENT '角色B描述', + + -- 参与者信息 + `host_user_id` INT NOT NULL COMMENT '房主用户ID', + `guest_user_id` INT COMMENT '加入者用户ID', + `host_role` VARCHAR(10) DEFAULT 'A' COMMENT '房主选择的角色(A/B)', + `max_participants` INT DEFAULT 2 COMMENT '最大参与人数', + + -- 状态和时间 + `status` VARCHAR(20) DEFAULT 'waiting' COMMENT '状态: waiting/ready/practicing/completed/canceled', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `started_at` DATETIME COMMENT '开始时间', + `ended_at` DATETIME COMMENT '结束时间', + `duration_seconds` INT DEFAULT 0 COMMENT '对练时长(秒)', + + -- 对话统计 + `total_turns` INT DEFAULT 0 COMMENT '总对话轮次', + `role_a_turns` INT DEFAULT 0 COMMENT '角色A发言次数', + `role_b_turns` INT DEFAULT 0 COMMENT '角色B发言次数', + + -- 软删除 + `is_deleted` TINYINT(1) DEFAULT 0 COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + + -- 索引 + INDEX `idx_room_code` (`room_code`), + INDEX `idx_host_user` (`host_user_id`), + INDEX `idx_guest_user` (`guest_user_id`), + INDEX `idx_status` (`status`), + INDEX `idx_created_at` (`created_at`), + + -- 外键(可选,根据实际需求决定是否启用) + -- FOREIGN KEY (`scene_id`) REFERENCES `practice_scenes`(`id`) ON DELETE SET NULL, + -- FOREIGN KEY (`host_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, + -- FOREIGN KEY (`guest_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL + + CONSTRAINT `chk_host_role` CHECK (`host_role` IN ('A', 'B')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='双人对练房间表'; + + +-- ============================================================================ +-- 2. 扩展对话记录表 practice_dialogues +-- ============================================================================ + +-- 添加用户ID字段(区分说话人) +ALTER TABLE `practice_dialogues` +ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '说话人用户ID' AFTER `speaker`; + +-- 添加角色名称字段 +ALTER TABLE `practice_dialogues` +ADD COLUMN IF NOT EXISTS `role_name` VARCHAR(50) COMMENT '角色名称(如销售顾问/顾客)' AFTER `user_id`; + +-- 添加房间ID字段 +ALTER TABLE `practice_dialogues` +ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`; + +-- 添加消息类型字段 +ALTER TABLE `practice_dialogues` +ADD COLUMN IF NOT EXISTS `message_type` VARCHAR(20) DEFAULT 'text' COMMENT '消息类型: text/voice/system' AFTER `content`; + +-- 添加索引 +ALTER TABLE `practice_dialogues` +ADD INDEX IF NOT EXISTS `idx_user_id` (`user_id`), +ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`); + + +-- ============================================================================ +-- 3. 扩展会话表 practice_sessions +-- ============================================================================ + +-- 添加房间ID字段 +ALTER TABLE `practice_sessions` +ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `scene_type`; + +-- 添加参与者角色字段 +ALTER TABLE `practice_sessions` +ADD COLUMN IF NOT EXISTS `participant_role` VARCHAR(50) COMMENT '用户在房间中的角色' AFTER `room_id`; + +-- 添加会话类型字段 +ALTER TABLE `practice_sessions` +ADD COLUMN IF NOT EXISTS `session_type` VARCHAR(20) DEFAULT 'solo' COMMENT '会话类型: solo/duo' AFTER `participant_role`; + +-- 添加索引 +ALTER TABLE `practice_sessions` +ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`); + + +-- ============================================================================ +-- 4. 扩展报告表 practice_reports(支持双人报告) +-- ============================================================================ + +-- 添加房间ID字段 +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`; + +-- 添加用户ID字段(双人模式下每人一份报告) +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '用户ID' AFTER `room_id`; + +-- 添加报告类型字段 +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `report_type` VARCHAR(20) DEFAULT 'solo' COMMENT '报告类型: solo/duo' AFTER `user_id`; + +-- 添加对方评价字段(双人模式) +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `partner_feedback` JSON COMMENT '对方表现评价' AFTER `suggestions`; + +-- 添加互动质量评分 +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `interaction_score` INT COMMENT '互动质量评分(0-100)' AFTER `partner_feedback`; + +-- 修改唯一索引(允许同一session有多个报告) +-- 注意:需要先删除旧的唯一索引 +-- ALTER TABLE `practice_reports` DROP INDEX `session_id`; +-- ALTER TABLE `practice_reports` ADD UNIQUE INDEX `idx_session_user` (`session_id`, `user_id`); + + +-- ============================================================================ +-- 5. 创建房间消息表(用于实时同步) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `practice_room_messages` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID', + `room_id` INT NOT NULL COMMENT '房间ID', + `user_id` INT COMMENT '发送者用户ID(系统消息为NULL)', + `message_type` VARCHAR(20) NOT NULL COMMENT '消息类型: chat/system/join/leave/start/end', + `content` TEXT COMMENT '消息内容', + `role_name` VARCHAR(50) COMMENT '角色名称', + `sequence` INT NOT NULL COMMENT '消息序号', + `created_at` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间(毫秒精度)', + + INDEX `idx_room_id` (`room_id`), + INDEX `idx_room_sequence` (`room_id`, `sequence`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间实时消息表'; + + +-- ============================================================================ +-- 回滚脚本(如需回滚,执行以下语句) +-- ============================================================================ +/* +-- 删除新增的表 +DROP TABLE IF EXISTS `practice_room_messages`; +DROP TABLE IF EXISTS `practice_rooms`; + +-- 删除 practice_dialogues 新增的列 +ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `user_id`; +ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `role_name`; +ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `room_id`; +ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `message_type`; + +-- 删除 practice_sessions 新增的列 +ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `room_id`; +ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `participant_role`; +ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `session_type`; + +-- 删除 practice_reports 新增的列 +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `room_id`; +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `user_id`; +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `report_type`; +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `partner_feedback`; +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `interaction_score`; +*/ diff --git a/frontend/src/api/duoPractice.ts b/frontend/src/api/duoPractice.ts new file mode 100644 index 0000000..30f2c8d --- /dev/null +++ b/frontend/src/api/duoPractice.ts @@ -0,0 +1,187 @@ +/** + * 双人对练 API + */ +import request from '@/api/request' + +// ==================== 类型定义 ==================== + +export interface CreateRoomRequest { + scene_id?: number + scene_name?: string + scene_type?: string + scene_background?: string + role_a_name?: string + role_b_name?: string + role_a_description?: string + role_b_description?: string + host_role?: 'A' | 'B' + room_name?: string +} + +export interface CreateRoomResponse { + room_code: string + room_id: number + room_name: string + my_role: string + my_role_name: string +} + +export interface JoinRoomResponse { + room_code: string + room_id: number + room_name: string + status: string + my_role: string + my_role_name: string +} + +export interface RoomUser { + id: number + username: string + full_name: string + avatar_url?: string +} + +export interface RoomInfo { + id: number + room_code: string + room_name?: string + scene_id?: number + scene_name?: string + scene_type?: string + scene_background?: string + role_a_name: string + role_b_name: string + role_a_description?: string + role_b_description?: string + host_role: string + status: string + created_at?: string + started_at?: string + ended_at?: string + duration_seconds: number + total_turns: number + role_a_turns: number + role_b_turns: number +} + +export interface RoomDetailResponse { + room: RoomInfo + host_user?: RoomUser + guest_user?: RoomUser + host_role_name?: string + guest_role_name?: string + my_role?: string + my_role_name?: string + is_host: boolean +} + +export interface RoomMessage { + id: number + room_id: number + user_id?: number + message_type: 'chat' | 'system' | 'join' | 'leave' | 'start' | 'end' + content?: string + role_name?: string + sequence: number + created_at: string +} + +export interface MessagesResponse { + messages: RoomMessage[] + room_status: string + last_sequence: number +} + +export interface RoomListItem { + id: number + room_code: string + room_name?: string + scene_name?: string + status: string + is_host: boolean + created_at?: string + duration_seconds: number + total_turns: number +} + +// ==================== API 函数 ==================== + +/** + * 创建房间 + */ +export function createRoom(data: CreateRoomRequest) { + return request.post('/api/v1/practice/rooms', data) +} + +/** + * 加入房间 + */ +export function joinRoom(roomCode: string) { + return request.post('/api/v1/practice/rooms/join', { + room_code: roomCode + }) +} + +/** + * 获取房间详情 + */ +export function getRoomDetail(roomCode: string) { + return request.get(`/api/v1/practice/rooms/${roomCode}`) +} + +/** + * 开始对练 + */ +export function startPractice(roomCode: string) { + return request.post(`/api/v1/practice/rooms/${roomCode}/start`) +} + +/** + * 结束对练 + */ +export function endPractice(roomCode: string) { + return request.post(`/api/v1/practice/rooms/${roomCode}/end`) +} + +/** + * 离开房间 + */ +export function leaveRoom(roomCode: string) { + return request.post(`/api/v1/practice/rooms/${roomCode}/leave`) +} + +/** + * 发送消息 + */ +export function sendMessage(roomCode: string, content: string) { + return request.post(`/api/v1/practice/rooms/${roomCode}/message`, { + content + }) +} + +/** + * 获取消息列表 + */ +export function getMessages(roomCode: string, sinceSequence: number = 0) { + return request.get(`/api/v1/practice/rooms/${roomCode}/messages`, { + params: { since_sequence: sinceSequence } + }) +} + +/** + * 获取我的房间列表 + */ +export function getMyRooms(status?: string, limit: number = 20) { + return request.get<{ rooms: RoomListItem[] }>('/api/v1/practice/rooms', { + params: { status, limit } + }) +} + +/** + * 生成分享链接 + */ +export function generateShareLink(roomCode: string): string { + const baseUrl = window.location.origin + return `${baseUrl}/trainee/duo-practice/join/${roomCode}` +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9a3b0fd..fbfe3c6 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -120,6 +120,30 @@ const routes: RouteRecordRaw[] = [ name: 'AIPracticeCoze', component: () => import('@/views/trainee/ai-practice-coze.vue'), meta: { title: 'AI陪练会话', hidden: true } + }, + { + path: 'duo-practice', + name: 'DuoPractice', + component: () => import('@/views/trainee/duo-practice.vue'), + meta: { title: '双人对练', icon: 'Connection' } + }, + { + path: 'duo-practice/room/:code', + name: 'DuoPracticeRoom', + component: () => import('@/views/trainee/duo-practice-room.vue'), + meta: { title: '对练房间', hidden: true } + }, + { + path: 'duo-practice/join/:code', + name: 'DuoPracticeJoin', + component: () => import('@/views/trainee/duo-practice-room.vue'), + meta: { title: '加入对练', hidden: true } + }, + { + path: 'duo-practice/report/:id', + name: 'DuoPracticeReport', + component: () => import('@/views/trainee/duo-practice-report.vue'), + meta: { title: '对练报告', hidden: true } } ] }, diff --git a/frontend/src/stores/duoPracticeStore.ts b/frontend/src/stores/duoPracticeStore.ts new file mode 100644 index 0000000..c3ce113 --- /dev/null +++ b/frontend/src/stores/duoPracticeStore.ts @@ -0,0 +1,413 @@ +/** + * 双人对练状态管理 + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { ElMessage } from 'element-plus' +import * as duoPracticeApi from '@/api/duoPractice' +import type { + RoomInfo, + RoomUser, + RoomMessage, + CreateRoomRequest +} from '@/api/duoPractice' + +export const useDuoPracticeStore = defineStore('duoPractice', () => { + // ==================== 状态 ==================== + + /** 房间码 */ + const roomCode = ref('') + + /** 房间信息 */ + const roomInfo = ref(null) + + /** 房主信息 */ + const hostUser = ref(null) + + /** 嘉宾信息 */ + const guestUser = ref(null) + + /** 我的角色 */ + const myRole = ref('') + + /** 我的角色名称 */ + const myRoleName = ref('') + + /** 是否是房主 */ + const isHost = ref(false) + + /** 消息列表 */ + const messages = ref([]) + + /** 最后消息序号(用于轮询) */ + const lastSequence = ref(0) + + /** 是否正在加载 */ + const isLoading = ref(false) + + /** 是否已连接(轮询中) */ + const isConnected = ref(false) + + /** 轮询定时器 */ + let pollingTimer: number | null = null + + /** 输入框内容 */ + const inputMessage = ref('') + + // ==================== 计算属性 ==================== + + /** 房间状态 */ + const roomStatus = computed(() => roomInfo.value?.status || 'unknown') + + /** 是否等待中 */ + const isWaiting = computed(() => roomStatus.value === 'waiting') + + /** 是否就绪 */ + const isReady = computed(() => roomStatus.value === 'ready') + + /** 是否对练中 */ + const isPracticing = computed(() => roomStatus.value === 'practicing') + + /** 是否已完成 */ + const isCompleted = computed(() => roomStatus.value === 'completed') + + /** 对方用户 */ + const partnerUser = computed(() => { + if (isHost.value) { + return guestUser.value + } else { + return hostUser.value + } + }) + + /** 对方角色名称 */ + const partnerRoleName = computed(() => { + if (!roomInfo.value) return '' + const partnerRole = myRole.value === 'A' ? 'B' : 'A' + return partnerRole === 'A' ? roomInfo.value.role_a_name : roomInfo.value.role_b_name + }) + + /** 聊天消息(过滤系统消息) */ + const chatMessages = computed(() => { + return messages.value.filter(m => m.message_type === 'chat') + }) + + /** 系统消息 */ + const systemMessages = computed(() => { + return messages.value.filter(m => m.message_type !== 'chat') + }) + + // ==================== 方法 ==================== + + /** + * 创建房间 + */ + const createRoom = async (request: CreateRoomRequest) => { + isLoading.value = true + try { + const res: any = await duoPracticeApi.createRoom(request) + if (res.code === 200) { + roomCode.value = res.data.room_code + myRole.value = res.data.my_role + myRoleName.value = res.data.my_role_name + isHost.value = true + + // 获取房间详情 + await fetchRoomDetail() + + return res.data + } else { + throw new Error(res.message || '创建房间失败') + } + } catch (error: any) { + ElMessage.error(error.message || '创建房间失败') + throw error + } finally { + isLoading.value = false + } + } + + /** + * 加入房间 + */ + const joinRoom = async (code: string) => { + isLoading.value = true + try { + const res: any = await duoPracticeApi.joinRoom(code.toUpperCase()) + if (res.code === 200) { + roomCode.value = res.data.room_code + myRole.value = res.data.my_role + myRoleName.value = res.data.my_role_name + isHost.value = false + + // 获取房间详情 + await fetchRoomDetail() + + return res.data + } else { + throw new Error(res.message || '加入房间失败') + } + } catch (error: any) { + ElMessage.error(error.message || '加入房间失败') + throw error + } finally { + isLoading.value = false + } + } + + /** + * 获取房间详情 + */ + const fetchRoomDetail = async () => { + if (!roomCode.value) return + + try { + const res: any = await duoPracticeApi.getRoomDetail(roomCode.value) + if (res.code === 200) { + roomInfo.value = res.data.room + hostUser.value = res.data.host_user + guestUser.value = res.data.guest_user + myRole.value = res.data.my_role || myRole.value + myRoleName.value = res.data.my_role_name || myRoleName.value + isHost.value = res.data.is_host + } + } catch (error) { + console.error('获取房间详情失败:', error) + } + } + + /** + * 开始对练 + */ + const startPractice = async () => { + if (!roomCode.value) return + + isLoading.value = true + try { + const res: any = await duoPracticeApi.startPractice(roomCode.value) + if (res.code === 200) { + ElMessage.success('对练开始!') + await fetchRoomDetail() + } else { + throw new Error(res.message || '开始失败') + } + } catch (error: any) { + ElMessage.error(error.message || '开始对练失败') + } finally { + isLoading.value = false + } + } + + /** + * 结束对练 + */ + const endPractice = async () => { + if (!roomCode.value) return + + isLoading.value = true + try { + const res: any = await duoPracticeApi.endPractice(roomCode.value) + if (res.code === 200) { + ElMessage.success('对练结束') + await fetchRoomDetail() + stopPolling() + return res.data + } else { + throw new Error(res.message || '结束失败') + } + } catch (error: any) { + ElMessage.error(error.message || '结束对练失败') + throw error + } finally { + isLoading.value = false + } + } + + /** + * 离开房间 + */ + const leaveRoom = async () => { + if (!roomCode.value) return + + try { + await duoPracticeApi.leaveRoom(roomCode.value) + resetState() + } catch (error) { + console.error('离开房间失败:', error) + } + } + + /** + * 发送消息 + */ + const sendMessage = async (content?: string) => { + const msg = content || inputMessage.value.trim() + if (!msg || !roomCode.value) return + + try { + const res: any = await duoPracticeApi.sendMessage(roomCode.value, msg) + if (res.code === 200) { + inputMessage.value = '' + // 消息会通过轮询获取 + } + } catch (error: any) { + ElMessage.error(error.message || '发送失败') + } + } + + /** + * 获取消息 + */ + const fetchMessages = async () => { + if (!roomCode.value) return + + try { + const res: any = await duoPracticeApi.getMessages(roomCode.value, lastSequence.value) + if (res.code === 200) { + const newMessages = res.data.messages + if (newMessages.length > 0) { + messages.value.push(...newMessages) + lastSequence.value = res.data.last_sequence + } + + // 检查房间状态变化 + if (res.data.room_status !== roomInfo.value?.status) { + await fetchRoomDetail() + } + } + } catch (error) { + console.error('获取消息失败:', error) + } + } + + /** + * 开始轮询消息 + */ + const startPolling = () => { + if (pollingTimer) return + + isConnected.value = true + + // 立即获取一次 + fetchMessages() + + // 每500ms轮询一次 + pollingTimer = window.setInterval(() => { + fetchMessages() + }, 500) + + console.log('[DuoPractice] 开始轮询消息') + } + + /** + * 停止轮询 + */ + const stopPolling = () => { + if (pollingTimer) { + clearInterval(pollingTimer) + pollingTimer = null + } + isConnected.value = false + console.log('[DuoPractice] 停止轮询消息') + } + + /** + * 重置状态 + */ + const resetState = () => { + stopPolling() + roomCode.value = '' + roomInfo.value = null + hostUser.value = null + guestUser.value = null + myRole.value = '' + myRoleName.value = '' + isHost.value = false + messages.value = [] + lastSequence.value = 0 + inputMessage.value = '' + isLoading.value = false + } + + /** + * 生成分享链接 + */ + const getShareLink = () => { + if (!roomCode.value) return '' + return duoPracticeApi.generateShareLink(roomCode.value) + } + + /** + * 复制房间码 + */ + const copyRoomCode = async () => { + if (!roomCode.value) return + + try { + await navigator.clipboard.writeText(roomCode.value) + ElMessage.success('房间码已复制') + } catch (error) { + ElMessage.error('复制失败') + } + } + + /** + * 复制分享链接 + */ + const copyShareLink = async () => { + const link = getShareLink() + if (!link) return + + try { + await navigator.clipboard.writeText(link) + ElMessage.success('链接已复制') + } catch (error) { + ElMessage.error('复制失败') + } + } + + // ==================== 返回 ==================== + + return { + // 状态 + roomCode, + roomInfo, + hostUser, + guestUser, + myRole, + myRoleName, + isHost, + messages, + lastSequence, + isLoading, + isConnected, + inputMessage, + + // 计算属性 + roomStatus, + isWaiting, + isReady, + isPracticing, + isCompleted, + partnerUser, + partnerRoleName, + chatMessages, + systemMessages, + + // 方法 + createRoom, + joinRoom, + fetchRoomDetail, + startPractice, + endPractice, + leaveRoom, + sendMessage, + fetchMessages, + startPolling, + stopPolling, + resetState, + getShareLink, + copyRoomCode, + copyShareLink + } +}) diff --git a/frontend/src/views/trainee/duo-practice-report.vue b/frontend/src/views/trainee/duo-practice-report.vue new file mode 100644 index 0000000..42a6c5c --- /dev/null +++ b/frontend/src/views/trainee/duo-practice-report.vue @@ -0,0 +1,544 @@ + + + + + diff --git a/frontend/src/views/trainee/duo-practice-room.vue b/frontend/src/views/trainee/duo-practice-room.vue new file mode 100644 index 0000000..3cf152a --- /dev/null +++ b/frontend/src/views/trainee/duo-practice-room.vue @@ -0,0 +1,572 @@ + + + + + diff --git a/frontend/src/views/trainee/duo-practice.vue b/frontend/src/views/trainee/duo-practice.vue new file mode 100644 index 0000000..ee5b74e --- /dev/null +++ b/frontend/src/views/trainee/duo-practice.vue @@ -0,0 +1,401 @@ + + + + +