feat: 添加双人对练功能
Some checks failed
continuous-integration/drone/push Build is failing

- 新增数据库迁移脚本 (practice_rooms, practice_room_messages)
- 新增后端 API: 房间创建/加入/消息同步/报告生成
- 新增前端页面: 入口页/对练房间/报告页
- 新增 AI 双人评估服务和提示词
This commit is contained in:
yuliang_guo
2026-01-28 15:20:03 +08:00
parent fc299ed7b7
commit b6aea2e23d
14 changed files with 4195 additions and 0 deletions

View File

@@ -29,6 +29,7 @@ from .sql_executor import router as sql_executor_router
from .exam import router as exam_router from .exam import router as exam_router
from .practice import router as practice_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 .course_chat import router as course_chat_router
from .broadcast import router as broadcast_router from .broadcast import router as broadcast_router
from .preview import router as preview_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"]) api_router.include_router(exam_router, tags=["exams"])
# practice_router 陪练功能路由 # practice_router 陪练功能路由
api_router.include_router(practice_router, prefix="/practice", tags=["practice"]) 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 与课程对话路由 # course_chat_router 与课程对话路由
api_router.include_router(course_chat_router, prefix="/course", tags=["course-chat"]) api_router.include_router(course_chat_router, prefix="/course", tags=["course-chat"])
# broadcast_router 播课功能路由不添加prefix路径在router内部定义 # broadcast_router 播课功能路由不添加prefix路径在router内部定义

View File

@@ -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
]
}
}

View File

@@ -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"<PracticeRoom(code='{self.room_code}', status='{self.status}')>"
@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"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>"
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
}

View File

@@ -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
)

View File

@@ -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": "表达不清"
}

View File

@@ -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)

View File

@@ -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. **索引优化**:已为常用查询字段创建索引,如需调整请根据实际查询模式优化

View File

@@ -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`;
*/

View File

@@ -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<CreateRoomResponse>('/api/v1/practice/rooms', data)
}
/**
* 加入房间
*/
export function joinRoom(roomCode: string) {
return request.post<JoinRoomResponse>('/api/v1/practice/rooms/join', {
room_code: roomCode
})
}
/**
* 获取房间详情
*/
export function getRoomDetail(roomCode: string) {
return request.get<RoomDetailResponse>(`/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<RoomMessage>(`/api/v1/practice/rooms/${roomCode}/message`, {
content
})
}
/**
* 获取消息列表
*/
export function getMessages(roomCode: string, sinceSequence: number = 0) {
return request.get<MessagesResponse>(`/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}`
}

View File

@@ -120,6 +120,30 @@ const routes: RouteRecordRaw[] = [
name: 'AIPracticeCoze', name: 'AIPracticeCoze',
component: () => import('@/views/trainee/ai-practice-coze.vue'), component: () => import('@/views/trainee/ai-practice-coze.vue'),
meta: { title: 'AI陪练会话', hidden: true } 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 }
} }
] ]
}, },

View File

@@ -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<string>('')
/** 房间信息 */
const roomInfo = ref<RoomInfo | null>(null)
/** 房主信息 */
const hostUser = ref<RoomUser | null>(null)
/** 嘉宾信息 */
const guestUser = ref<RoomUser | null>(null)
/** 我的角色 */
const myRole = ref<string>('')
/** 我的角色名称 */
const myRoleName = ref<string>('')
/** 是否是房主 */
const isHost = ref<boolean>(false)
/** 消息列表 */
const messages = ref<RoomMessage[]>([])
/** 最后消息序号(用于轮询) */
const lastSequence = ref<number>(0)
/** 是否正在加载 */
const isLoading = ref<boolean>(false)
/** 是否已连接(轮询中) */
const isConnected = ref<boolean>(false)
/** 轮询定时器 */
let pollingTimer: number | null = null
/** 输入框内容 */
const inputMessage = ref<string>('')
// ==================== 计算属性 ====================
/** 房间状态 */
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
}
})

View File

@@ -0,0 +1,544 @@
<template>
<div class="duo-practice-report">
<!-- 页头 -->
<div class="report-header">
<el-button text @click="handleBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h1>对练报告</h1>
</div>
<div class="report-content" v-loading="isLoading">
<!-- 概览卡片 -->
<div class="overview-section">
<div class="overview-card">
<div class="overview-item">
<div class="label">场景</div>
<div class="value">{{ roomInfo?.scene_name || '双人对练' }}</div>
</div>
<div class="overview-item">
<div class="label">时长</div>
<div class="value">{{ formatDuration(roomInfo?.duration_seconds || 0) }}</div>
</div>
<div class="overview-item">
<div class="label">对话轮次</div>
<div class="value">{{ roomInfo?.total_turns || 0 }} </div>
</div>
<div class="overview-item">
<div class="label">互动质量</div>
<div class="value score">{{ analysisResult?.overall_evaluation?.interaction_quality || '--' }}</div>
</div>
</div>
</div>
<!-- 双人评估对比 -->
<div class="evaluation-section">
<h2>双方表现</h2>
<div class="evaluation-cards">
<!-- 用户A评估 -->
<div class="evaluation-card" v-if="analysisResult?.user_a_evaluation">
<div class="card-header">
<div class="user-info">
<el-avatar :size="48">{{ analysisResult.user_a_evaluation.user_name?.[0] }}</el-avatar>
<div>
<div class="user-name">{{ analysisResult.user_a_evaluation.user_name }}</div>
<div class="role-name">{{ analysisResult.user_a_evaluation.role_name }}</div>
</div>
</div>
<div class="total-score">
<div class="score-value">{{ analysisResult.user_a_evaluation.total_score }}</div>
<div class="score-label">综合评分</div>
</div>
</div>
<div class="card-body">
<!-- 维度评分 -->
<div class="dimensions">
<div
class="dimension-item"
v-for="(dim, key) in analysisResult.user_a_evaluation.dimensions"
:key="key"
>
<div class="dim-header">
<span class="dim-name">{{ getDimensionName(key) }}</span>
<span class="dim-score">{{ dim.score }}</span>
</div>
<el-progress
:percentage="dim.score"
:stroke-width="8"
:show-text="false"
:color="getScoreColor(dim.score)"
/>
<div class="dim-comment">{{ dim.comment }}</div>
</div>
</div>
<!-- 亮点 -->
<div class="highlights" v-if="analysisResult.user_a_evaluation.highlights?.length">
<h4>亮点</h4>
<ul>
<li v-for="(h, i) in analysisResult.user_a_evaluation.highlights" :key="i">
{{ h }}
</li>
</ul>
</div>
<!-- 改进建议 -->
<div class="improvements" v-if="analysisResult.user_a_evaluation.improvements?.length">
<h4>改进建议</h4>
<div
class="improvement-item"
v-for="(imp, i) in analysisResult.user_a_evaluation.improvements"
:key="i"
>
<div class="issue">{{ imp.issue }}</div>
<div class="suggestion">{{ imp.suggestion }}</div>
<div class="example" v-if="imp.example">示例{{ imp.example }}</div>
</div>
</div>
</div>
</div>
<!-- 用户B评估 -->
<div class="evaluation-card" v-if="analysisResult?.user_b_evaluation">
<div class="card-header">
<div class="user-info">
<el-avatar :size="48">{{ analysisResult.user_b_evaluation.user_name?.[0] }}</el-avatar>
<div>
<div class="user-name">{{ analysisResult.user_b_evaluation.user_name }}</div>
<div class="role-name">{{ analysisResult.user_b_evaluation.role_name }}</div>
</div>
</div>
<div class="total-score">
<div class="score-value">{{ analysisResult.user_b_evaluation.total_score }}</div>
<div class="score-label">综合评分</div>
</div>
</div>
<div class="card-body">
<!-- 维度评分 -->
<div class="dimensions">
<div
class="dimension-item"
v-for="(dim, key) in analysisResult.user_b_evaluation.dimensions"
:key="key"
>
<div class="dim-header">
<span class="dim-name">{{ getDimensionName(key) }}</span>
<span class="dim-score">{{ dim.score }}</span>
</div>
<el-progress
:percentage="dim.score"
:stroke-width="8"
:show-text="false"
:color="getScoreColor(dim.score)"
/>
<div class="dim-comment">{{ dim.comment }}</div>
</div>
</div>
<!-- 亮点 -->
<div class="highlights" v-if="analysisResult.user_b_evaluation.highlights?.length">
<h4>亮点</h4>
<ul>
<li v-for="(h, i) in analysisResult.user_b_evaluation.highlights" :key="i">
{{ h }}
</li>
</ul>
</div>
<!-- 改进建议 -->
<div class="improvements" v-if="analysisResult.user_b_evaluation.improvements?.length">
<h4>改进建议</h4>
<div
class="improvement-item"
v-for="(imp, i) in analysisResult.user_b_evaluation.improvements"
:key="i"
>
<div class="issue">{{ imp.issue }}</div>
<div class="suggestion">{{ imp.suggestion }}</div>
<div class="example" v-if="imp.example">示例{{ imp.example }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 整体评价 -->
<div class="overall-section" v-if="analysisResult?.overall_evaluation?.overall_comment">
<h2>整体评价</h2>
<div class="overall-comment">
{{ analysisResult.overall_evaluation.overall_comment }}
</div>
</div>
<!-- 加载中或无数据 -->
<el-empty v-if="!isLoading && !analysisResult" description="暂无报告数据" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
// 状态
const isLoading = ref(false)
const roomInfo = ref<any>(null)
const analysisResult = ref<any>(null)
// 方法
const handleBack = () => {
router.push('/trainee/duo-practice')
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}${secs}`
}
const getDimensionName = (key: string) => {
const map: Record<string, string> = {
'role_immersion': '角色代入',
'communication': '沟通表达',
'professional_knowledge': '专业知识',
'response_quality': '回应质量',
'goal_achievement': '目标达成'
}
return map[key] || key
}
const getScoreColor = (score: number) => {
if (score >= 80) return '#67c23a'
if (score >= 60) return '#e6a23c'
return '#f56c6c'
}
// 加载报告数据
const loadReport = async () => {
const roomId = route.params.id
if (!roomId) return
isLoading.value = true
try {
// TODO: 调用 API 获取报告
// const res = await getDuoPracticeReport(roomId)
// roomInfo.value = res.data.room
// analysisResult.value = res.data.analysis
// 模拟数据
roomInfo.value = {
scene_name: '销售场景对练',
duration_seconds: 300,
total_turns: 15
}
analysisResult.value = {
overall_evaluation: {
interaction_quality: 85,
scene_restoration: 82,
overall_comment: '本次双人对练整体表现良好,双方都能够较好地代入角色,对话流畅自然。销售顾问在产品介绍和需求挖掘方面表现出色,顾客也能够提出合理的疑问和需求。建议在处理异议时可以更加灵活,增加更多情感共鸣的表达。'
},
user_a_evaluation: {
user_name: '张三',
role_name: '销售顾问',
total_score: 86,
dimensions: {
role_immersion: { score: 88, comment: '完全进入角色状态,语言风格符合销售顾问身份' },
communication: { score: 85, comment: '表达清晰,逻辑通顺,用词专业' },
professional_knowledge: { score: 82, comment: '产品知识展示较为全面' },
response_quality: { score: 88, comment: '回应及时准确,针对性强' },
goal_achievement: { score: 85, comment: '有效推进了销售进程' }
},
highlights: [
'开场白自然得体,快速建立信任',
'善于使用提问技巧挖掘客户需求',
'产品利益点阐述清晰有力'
],
improvements: [
{
issue: '处理价格异议时略显被动',
suggestion: '可以先肯定客户的关注点,再引导关注价值',
example: '您说得对,预算确实是重要的考虑因素。不过您有没有想过...'
}
]
},
user_b_evaluation: {
user_name: '李四',
role_name: '顾客',
total_score: 83,
dimensions: {
role_immersion: { score: 80, comment: '基本符合顾客角色设定' },
communication: { score: 85, comment: '表达自己的需求和疑虑' },
professional_knowledge: { score: 78, comment: '对产品有基本了解' },
response_quality: { score: 82, comment: '能够合理回应销售话术' },
goal_achievement: { score: 80, comment: '配合完成了对练场景' }
},
highlights: [
'提出的问题具有代表性',
'表现出真实顾客的犹豫和考虑'
],
improvements: [
{
issue: '可以增加更多挑战性的问题',
suggestion: '尝试提出一些竞品对比、售后保障等深度问题',
example: '我听说XX品牌的产品价格更低你们有什么优势'
}
]
}
}
} catch (error) {
console.error('加载报告失败:', error)
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadReport()
})
</script>
<style scoped lang="scss">
.duo-practice-report {
min-height: 100vh;
background: #f5f7fa;
}
.report-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #eee;
h1 {
font-size: 20px;
font-weight: 600;
margin: 0;
}
}
.report-content {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.overview-section {
margin-bottom: 32px;
.overview-card {
display: flex;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
color: #fff;
.overview-item {
flex: 1;
text-align: center;
&:not(:last-child) {
border-right: 1px solid rgba(255, 255, 255, 0.2);
}
.label {
font-size: 13px;
opacity: 0.8;
margin-bottom: 8px;
}
.value {
font-size: 20px;
font-weight: 600;
&.score {
font-size: 32px;
}
}
}
}
}
.evaluation-section {
margin-bottom: 32px;
h2 {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.evaluation-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.evaluation-card {
background: #fff;
border-radius: 16px;
overflow: hidden;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #f8f9fc;
.user-info {
display: flex;
align-items: center;
gap: 12px;
.user-name {
font-weight: 600;
font-size: 16px;
}
.role-name {
font-size: 13px;
color: #666;
}
}
.total-score {
text-align: center;
.score-value {
font-size: 36px;
font-weight: 700;
color: #667eea;
line-height: 1;
}
.score-label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
}
.card-body {
padding: 20px;
.dimensions {
.dimension-item {
margin-bottom: 16px;
.dim-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
.dim-name {
font-size: 14px;
font-weight: 500;
}
.dim-score {
font-size: 14px;
font-weight: 600;
color: #667eea;
}
}
.dim-comment {
font-size: 13px;
color: #666;
margin-top: 6px;
}
}
}
.highlights, .improvements {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
h4 {
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
}
}
.highlights {
ul {
margin: 0;
padding-left: 20px;
li {
font-size: 14px;
color: #67c23a;
margin-bottom: 6px;
}
}
}
.improvements {
.improvement-item {
background: #fdf6ec;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
.issue {
font-size: 14px;
font-weight: 500;
color: #e6a23c;
margin-bottom: 8px;
}
.suggestion {
font-size: 14px;
color: #333;
margin-bottom: 6px;
}
.example {
font-size: 13px;
color: #666;
font-style: italic;
background: #fff;
padding: 8px;
border-radius: 4px;
}
}
}
}
}
}
.overall-section {
h2 {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.overall-comment {
background: #fff;
border-radius: 16px;
padding: 24px;
font-size: 15px;
line-height: 1.8;
color: #333;
}
}
</style>

View File

@@ -0,0 +1,572 @@
<template>
<div class="duo-practice-room">
<!-- 顶部信息栏 -->
<div class="room-header">
<div class="header-left">
<el-button text @click="handleBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<div class="room-title">
<h2>{{ store.roomInfo?.room_name || '双人对练' }}</h2>
<div class="room-code-display">
房间码<span class="code">{{ store.roomCode }}</span>
<el-button text size="small" @click="store.copyRoomCode">
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
</div>
</div>
<div class="header-right">
<el-tag :type="statusType" size="large">{{ statusText }}</el-tag>
</div>
</div>
<!-- 主体区域 -->
<div class="room-body">
<!-- 左侧参与者和操作 -->
<div class="participants-panel">
<h3>参与者</h3>
<!-- 房主 -->
<div class="participant-card host">
<div class="avatar">
<el-avatar :size="48" :src="store.hostUser?.avatar_url">
{{ store.hostUser?.full_name?.[0] || 'H' }}
</el-avatar>
<el-tag class="role-tag" size="small" type="warning">房主</el-tag>
</div>
<div class="info">
<div class="name">{{ store.hostUser?.full_name || '等待中...' }}</div>
<div class="role">扮演{{ hostRoleName }}</div>
</div>
<div class="badge" v-if="store.isHost">
<el-tag type="primary" size="small"></el-tag>
</div>
</div>
<!-- 嘉宾 -->
<div class="participant-card guest" :class="{ empty: !store.guestUser }">
<template v-if="store.guestUser">
<div class="avatar">
<el-avatar :size="48" :src="store.guestUser?.avatar_url">
{{ store.guestUser?.full_name?.[0] || 'G' }}
</el-avatar>
</div>
<div class="info">
<div class="name">{{ store.guestUser?.full_name }}</div>
<div class="role">扮演{{ guestRoleName }}</div>
</div>
<div class="badge" v-if="!store.isHost">
<el-tag type="primary" size="small"></el-tag>
</div>
</template>
<template v-else>
<div class="empty-placeholder">
<el-icon :size="32"><Plus /></el-icon>
<span>等待对方加入</span>
</div>
</template>
</div>
<!-- 分享区域 -->
<div class="share-section" v-if="store.isWaiting">
<h4>邀请伙伴加入</h4>
<div class="share-code">
<span class="big-code">{{ store.roomCode }}</span>
</div>
<el-button type="primary" @click="store.copyShareLink" block>
<el-icon><Share /></el-icon>
复制邀请链接
</el-button>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<template v-if="store.isHost && store.isReady">
<el-button type="primary" size="large" @click="store.startPractice" :loading="store.isLoading">
开始对练
</el-button>
</template>
<template v-if="store.isPracticing">
<el-button type="danger" size="large" @click="handleEndPractice">
结束对练
</el-button>
</template>
<template v-if="store.isCompleted">
<el-button type="primary" size="large" @click="viewReport">
查看报告
</el-button>
</template>
<el-button @click="handleLeave" v-if="!store.isPracticing">
离开房间
</el-button>
</div>
</div>
<!-- 右侧对话区域 -->
<div class="chat-panel">
<div class="chat-header">
<span>对话记录</span>
<span class="turn-count" v-if="store.roomInfo">
{{ store.roomInfo.total_turns }} 轮对话
</span>
</div>
<div class="chat-messages" ref="messagesContainer">
<!-- 系统消息 -->
<div
v-for="msg in store.messages"
:key="msg.id"
class="message"
:class="getMessageClass(msg)"
>
<template v-if="msg.message_type === 'chat'">
<div class="message-avatar">
<el-avatar :size="36">
{{ msg.role_name?.[0] || '?' }}
</el-avatar>
</div>
<div class="message-content">
<div class="message-header">
<span class="sender-name">{{ msg.role_name }}</span>
<span class="message-time">{{ formatMessageTime(msg.created_at) }}</span>
</div>
<div class="message-body">{{ msg.content }}</div>
</div>
</template>
<template v-else>
<div class="system-message">
{{ msg.content }}
</div>
</template>
</div>
<!-- 空状态 -->
<div v-if="store.messages.length === 0" class="empty-chat">
<el-empty description="对练开始后,对话将显示在这里" />
</div>
</div>
<!-- 输入区域 -->
<div class="chat-input" v-if="store.isPracticing">
<el-input
v-model="store.inputMessage"
type="textarea"
:rows="2"
placeholder="输入消息..."
@keydown.enter.ctrl="store.sendMessage()"
/>
<el-button type="primary" @click="store.sendMessage()" :disabled="!store.inputMessage.trim()">
发送
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { ArrowLeft, CopyDocument, Plus, Share } from '@element-plus/icons-vue'
import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
import type { RoomMessage } from '@/api/duoPractice'
const route = useRoute()
const router = useRouter()
const store = useDuoPracticeStore()
const messagesContainer = ref<HTMLElement | null>(null)
// 计算属性
const statusType = computed(() => {
const map: Record<string, string> = {
'waiting': 'warning',
'ready': 'info',
'practicing': 'success',
'completed': '',
'canceled': 'danger'
}
return map[store.roomStatus] || ''
})
const statusText = computed(() => {
const map: Record<string, string> = {
'waiting': '等待加入',
'ready': '准备就绪',
'practicing': '对练中',
'completed': '已完成',
'canceled': '已取消'
}
return map[store.roomStatus] || store.roomStatus
})
const hostRoleName = computed(() => {
if (!store.roomInfo) return ''
return store.roomInfo.host_role === 'A'
? store.roomInfo.role_a_name
: store.roomInfo.role_b_name
})
const guestRoleName = computed(() => {
if (!store.roomInfo) return ''
return store.roomInfo.host_role === 'A'
? store.roomInfo.role_b_name
: store.roomInfo.role_a_name
})
// 方法
const handleBack = () => {
router.push('/trainee/duo-practice')
}
const handleLeave = async () => {
try {
await ElMessageBox.confirm('确定要离开房间吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await store.leaveRoom()
router.push('/trainee/duo-practice')
} catch {
// 用户取消
}
}
const handleEndPractice = async () => {
try {
await ElMessageBox.confirm('确定要结束对练吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await store.endPractice()
} catch {
// 用户取消
}
}
const viewReport = () => {
router.push(`/trainee/duo-practice/report/${store.roomInfo?.id}`)
}
const getMessageClass = (msg: RoomMessage) => {
if (msg.message_type !== 'chat') return 'system'
const isMe = msg.user_id && (
(store.isHost && msg.user_id === store.hostUser?.id) ||
(!store.isHost && msg.user_id === store.guestUser?.id)
)
return isMe ? 'mine' : 'other'
}
const formatMessageTime = (time: string) => {
const date = new Date(time)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// 监听消息变化,自动滚动到底部
watch(() => store.messages.length, () => {
scrollToBottom()
})
// 初始化
onMounted(async () => {
const roomCode = route.params.code as string
if (roomCode) {
store.roomCode = roomCode
await store.fetchRoomDetail()
// 开始轮询消息
store.startPolling()
}
})
onUnmounted(() => {
store.stopPolling()
})
</script>
<style scoped lang="scss">
.duo-practice-room {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
.room-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #eee;
.header-left {
display: flex;
align-items: center;
gap: 16px;
.room-title {
h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
}
.room-code-display {
font-size: 13px;
color: #666;
.code {
font-family: monospace;
color: #667eea;
font-weight: 500;
letter-spacing: 2px;
}
}
}
}
}
.room-body {
flex: 1;
display: flex;
overflow: hidden;
}
.participants-panel {
width: 320px;
background: #fff;
border-right: 1px solid #eee;
padding: 24px;
display: flex;
flex-direction: column;
h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.participant-card {
display: flex;
align-items: center;
padding: 16px;
border-radius: 12px;
background: #f8f9fc;
margin-bottom: 12px;
.avatar {
position: relative;
margin-right: 12px;
.role-tag {
position: absolute;
bottom: -4px;
right: -4px;
transform: scale(0.8);
}
}
.info {
flex: 1;
.name {
font-weight: 500;
font-size: 15px;
}
.role {
font-size: 13px;
color: #666;
margin-top: 2px;
}
}
&.empty {
border: 2px dashed #ddd;
background: transparent;
justify-content: center;
min-height: 80px;
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
color: #999;
span {
margin-top: 8px;
font-size: 13px;
}
}
}
}
.share-section {
margin-top: 24px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: #fff;
h4 {
margin: 0 0 12px 0;
font-size: 14px;
}
.share-code {
text-align: center;
margin-bottom: 16px;
.big-code {
font-size: 32px;
font-family: monospace;
font-weight: 700;
letter-spacing: 6px;
}
}
}
.action-buttons {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
}
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #eee;
font-weight: 500;
.turn-count {
font-size: 13px;
color: #666;
font-weight: normal;
}
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
.message {
margin-bottom: 16px;
&.mine {
display: flex;
flex-direction: row-reverse;
.message-content {
align-items: flex-end;
.message-body {
background: #667eea;
color: #fff;
}
}
}
&.other {
display: flex;
.message-content {
.message-body {
background: #f0f2f5;
}
}
}
&.system {
.system-message {
text-align: center;
font-size: 13px;
color: #999;
padding: 8px 0;
}
}
.message-avatar {
margin: 0 12px;
}
.message-content {
display: flex;
flex-direction: column;
max-width: 60%;
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.sender-name {
font-size: 13px;
font-weight: 500;
}
.message-time {
font-size: 12px;
color: #999;
}
}
.message-body {
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
}
}
}
.empty-chat {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
.chat-input {
display: flex;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #eee;
.el-textarea {
flex: 1;
}
.el-button {
align-self: flex-end;
}
}
}
</style>

View File

@@ -0,0 +1,401 @@
<template>
<div class="duo-practice-page">
<div class="page-header">
<h1>双人对练</h1>
<p class="subtitle">与伙伴一起进行角色扮演对练提升实战能力</p>
</div>
<div class="main-content">
<!-- 创建/加入房间卡片 -->
<div class="action-cards">
<!-- 创建房间 -->
<div class="action-card create-card" @click="showCreateDialog = true">
<div class="card-icon">
<el-icon :size="48"><Plus /></el-icon>
</div>
<h3>创建房间</h3>
<p>创建对练房间邀请伙伴加入</p>
</div>
<!-- 加入房间 -->
<div class="action-card join-card" @click="showJoinDialog = true">
<div class="card-icon">
<el-icon :size="48"><Connection /></el-icon>
</div>
<h3>加入房间</h3>
<p>输入房间码加入对练</p>
</div>
</div>
<!-- 我的房间列表 -->
<div class="my-rooms" v-if="myRooms.length > 0">
<h2>我的对练记录</h2>
<div class="room-list">
<div
v-for="room in myRooms"
:key="room.id"
class="room-item"
@click="enterRoom(room)"
>
<div class="room-info">
<span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span>
<span class="room-code">{{ room.room_code }}</span>
</div>
<div class="room-meta">
<el-tag :type="getStatusType(room.status)" size="small">
{{ getStatusText(room.status) }}
</el-tag>
<span class="room-time">{{ formatTime(room.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 创建房间对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建对练房间"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="createForm" label-width="100px">
<el-form-item label="场景名称">
<el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" />
</el-form-item>
<el-form-item label="角色A名称">
<el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" />
</el-form-item>
<el-form-item label="角色B名称">
<el-input v-model="createForm.role_b_name" placeholder="如:顾客" />
</el-form-item>
<el-form-item label="我扮演">
<el-radio-group v-model="createForm.host_role">
<el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio>
<el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="场景背景">
<el-input
v-model="createForm.scene_background"
type="textarea"
:rows="3"
placeholder="描述对练场景的背景信息(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateRoom" :loading="isCreating">
创建房间
</el-button>
</template>
</el-dialog>
<!-- 加入房间对话框 -->
<el-dialog
v-model="showJoinDialog"
title="加入对练房间"
width="400px"
:close-on-click-modal="false"
>
<el-form>
<el-form-item label="房间码">
<el-input
v-model="joinRoomCode"
placeholder="请输入6位房间码"
maxlength="6"
style="font-size: 24px; text-align: center; letter-spacing: 8px;"
@keyup.enter="handleJoinRoom"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showJoinDialog = false">取消</el-button>
<el-button type="primary" @click="handleJoinRoom" :loading="isJoining">
加入房间
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Connection } from '@element-plus/icons-vue'
import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
import { getMyRooms, type RoomListItem } from '@/api/duoPractice'
const router = useRouter()
const store = useDuoPracticeStore()
// 状态
const showCreateDialog = ref(false)
const showJoinDialog = ref(false)
const isCreating = ref(false)
const isJoining = ref(false)
const joinRoomCode = ref('')
const myRooms = ref<RoomListItem[]>([])
// 创建表单
const createForm = ref({
scene_name: '',
role_a_name: '销售顾问',
role_b_name: '顾客',
host_role: 'A' as 'A' | 'B',
scene_background: ''
})
// 加载我的房间列表
const loadMyRooms = async () => {
try {
const res: any = await getMyRooms()
if (res.code === 200) {
myRooms.value = res.data.rooms
}
} catch (error) {
console.error('加载房间列表失败:', error)
}
}
// 创建房间
const handleCreateRoom = async () => {
if (!createForm.value.scene_name) {
ElMessage.warning('请输入场景名称')
return
}
isCreating.value = true
try {
await store.createRoom(createForm.value)
showCreateDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isCreating.value = false
}
}
// 加入房间
const handleJoinRoom = async () => {
if (!joinRoomCode.value || joinRoomCode.value.length < 6) {
ElMessage.warning('请输入6位房间码')
return
}
isJoining.value = true
try {
await store.joinRoom(joinRoomCode.value)
showJoinDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isJoining.value = false
}
}
// 进入房间
const enterRoom = (room: RoomListItem) => {
router.push(`/trainee/duo-practice/room/${room.room_code}`)
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'waiting': 'warning',
'ready': 'info',
'practicing': 'success',
'completed': '',
'canceled': 'danger'
}
return map[status] || ''
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'waiting': '等待加入',
'ready': '准备就绪',
'practicing': '对练中',
'completed': '已完成',
'canceled': '已取消'
}
return map[status] || status
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return ''
const date = new Date(time)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadMyRooms()
})
</script>
<style scoped lang="scss">
.duo-practice-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
text-align: center;
margin-bottom: 40px;
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 16px;
}
}
.action-cards {
display: flex;
gap: 24px;
justify-content: center;
margin-bottom: 48px;
.action-card {
width: 280px;
padding: 40px 24px;
background: #fff;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
&.create-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
p {
color: rgba(255, 255, 255, 0.8);
}
}
&.join-card {
border-color: #667eea;
.card-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&:hover {
border-color: #764ba2;
}
}
.card-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
}
}
}
.my-rooms {
h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.room-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.room-item {
background: #fff;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.room-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.room-name {
font-weight: 500;
font-size: 16px;
}
.room-code {
font-family: monospace;
font-size: 14px;
color: #667eea;
background: #f0f2ff;
padding: 2px 8px;
border-radius: 4px;
}
}
.room-meta {
display: flex;
justify-content: space-between;
align-items: center;
.room-time {
font-size: 12px;
color: #999;
}
}
}
}
</style>