- 新增数据库迁移脚本 (practice_rooms, practice_room_messages) - 新增后端 API: 房间创建/加入/消息同步/报告生成 - 新增前端页面: 入口页/对练房间/报告页 - 新增 AI 双人评估服务和提示词
This commit is contained in:
544
frontend/src/views/trainee/duo-practice-report.vue
Normal file
544
frontend/src/views/trainee/duo-practice-report.vue
Normal 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>
|
||||
Reference in New Issue
Block a user