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

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