Files
012-kaopeilian/frontend/src/views/trainee/practice-records.vue
yuliang_guo 4c1b70e9d6
All checks were successful
continuous-integration/drone/push Build is passing
fix: 修复陪练记录回放对话功能
- 回放对话时调用 API 获取对话详情
- 添加加载状态显示
- 添加空数据提示

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 16:27:02 +08:00

1067 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="practice-records-container">
<div class="page-header">
<h1 class="page-title">陪练记录</h1>
<div class="header-actions">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
<el-button type="primary" @click="refreshData">
<el-icon class="el-icon--left"><Refresh /></el-icon>
刷新数据
</el-button>
</div>
</div>
<!-- 陪练统计概览 -->
<div class="stats-overview">
<div class="stat-card card" v-for="stat in practiceStats" :key="stat.label">
<div class="stat-icon" :style="{ backgroundColor: stat.bgColor }">
<el-icon :size="32" :color="stat.color">
<component :is="stat.icon" />
</el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-trend" :class="stat.trend > 0 ? 'up' : 'down'" v-if="stat.trend !== 0">
<el-icon><component :is="stat.trend > 0 ? 'Top' : 'Bottom'" /></el-icon>
{{ Math.abs(stat.trend) }}%
</div>
</div>
</div>
</div>
<!-- 筛选区域 -->
<div class="filter-section card">
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="关键词">
<el-input
v-model="filterForm.keyword"
placeholder="搜索陪练内容或场景"
clearable
@input="handleRealTimeSearch"
style="width: 200px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="陪练场景">
<el-select
v-model="filterForm.scene"
placeholder="全部场景"
clearable
@change="handleRealTimeSearch"
style="width: 120px"
>
<el-option label="客户咨询" value="customer_consultation" />
<el-option label="美容护理" value="beauty_care" />
<el-option label="产品介绍" value="product_introduction" />
<el-option label="问题处理" value="problem_handling" />
<el-option label="服务礼仪" value="service_etiquette" />
</el-select>
</el-form-item>
<el-form-item label="陪练结果">
<el-select
v-model="filterForm.result"
placeholder="全部结果"
clearable
@change="handleRealTimeSearch"
style="width: 120px"
>
<el-option label="优秀" value="excellent" />
<el-option label="良好" value="good" />
<el-option label="一般" value="average" />
<el-option label="需改进" value="needs_improvement" />
</el-select>
</el-form-item>
<el-form-item label="时间周期">
<el-select
v-model="filterForm.timePeriod"
placeholder="全部时间"
clearable
@change="handleRealTimeSearch"
style="width: 120px"
>
<el-option label="最近一周" value="week" />
<el-option label="最近一月" value="month" />
<el-option label="最近三月" value="quarter" />
<el-option label="自定义" value="custom" />
</el-select>
</el-form-item>
<el-form-item v-if="filterForm.timePeriod === 'custom'" label="自定义时间">
<el-date-picker
v-model="customDateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleRealTimeSearch"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleReset">
<el-icon class="el-icon--left"><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
<!-- 当前筛选条件显示 -->
<div v-if="hasActiveFilters" class="filter-tags">
<span class="filter-label">当前筛选</span>
<el-tag
v-if="filterForm.keyword"
closable
@close="clearKeyword"
type="primary"
>
关键词{{ filterForm.keyword }}
</el-tag>
<el-tag
v-if="filterForm.scene"
closable
@close="clearScene"
type="success"
>
场景{{ getSceneText(filterForm.scene) }}
</el-tag>
<el-tag
v-if="filterForm.result"
closable
@close="clearResult"
type="warning"
>
结果{{ getResultText(filterForm.result) }}
</el-tag>
<el-tag
v-if="filterForm.timePeriod"
closable
@close="clearTimePeriod"
type="info"
>
时间{{ getTimePeriodText(filterForm.timePeriod) }}
</el-tag>
<el-tag
v-if="customDateRange && customDateRange.length === 2"
closable
@close="clearCustomDate"
type="danger"
>
自定义{{ formatDateRange(customDateRange) }}
</el-tag>
<el-tag
v-if="dateRange && dateRange.length === 2"
closable
@close="clearHeaderDate"
type="danger"
>
日期{{ formatDateRange(dateRange) }}
</el-tag>
<el-button
link
type="danger"
size="small"
@click="handleReset"
class="clear-all-btn"
>
清空全部
</el-button>
</div>
<!-- 搜索结果统计 -->
<div class="search-result-info">
<span class="result-count">
共找到 <strong>{{ total }}</strong> 条陪练记录
<span v-if="hasActiveFilters" class="filter-hint">已筛选</span>
</span>
</div>
</div>
<!-- 陪练记录列表 -->
<div class="records-list card">
<el-table
:data="recordsList"
style="width: 100%"
v-loading="loading"
@row-click="viewPracticeReport"
>
<el-table-column prop="sessionId" label="会话ID" width="120" />
<el-table-column prop="sceneName" label="陪练场景" min-width="150">
<template #default="scope">
<div class="scene-info">
<el-tag :type="getSceneTypeTag(scope.row.sceneType)" size="small">
{{ scope.row.sceneName }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="duration" label="陪练时长" width="120">
<template #default="scope">
<span>{{ formatDuration(scope.row.duration) }}</span>
</template>
</el-table-column>
<el-table-column prop="roundCount" label="对话轮数" width="100" />
<el-table-column prop="score" label="综合评分" width="120" sortable>
<template #default="scope">
<div class="score-display">
<el-progress
:percentage="scope.row.score"
:color="getScoreColor(scope.row.score)"
:stroke-width="8"
/>
<span class="score-text">{{ scope.row.score }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="result" label="陪练结果" width="120">
<template #default="scope">
<el-tag :type="getResultTagType(scope.row.result)">
{{ getResultText(scope.row.result) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="practiceTime" label="陪练时间" width="180" sortable />
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click.stop="viewPracticeReport(scope.row)">
<el-icon><View /></el-icon>
查看报告
</el-button>
<el-button link type="primary" size="small" @click.stop="replayPractice(scope.row)">
<el-icon><VideoPlay /></el-icon>
回放对话
</el-button>
<el-button link type="primary" size="small" @click.stop="exportRecord(scope.row)">
<el-icon><Download /></el-icon>
导出记录
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 对话回放弹窗 -->
<el-dialog
v-model="replayDialogVisible"
:title="`${currentRecord?.sceneName} - 对话回放`"
width="800px"
top="5vh"
>
<div class="replay-content" v-if="currentRecord">
<!-- 陪练信息 -->
<div class="replay-header">
<div class="session-info">
<h3>{{ currentRecord.sceneName }}</h3>
<div class="session-meta">
<span>时长{{ formatDuration(currentRecord.duration) }}</span>
<span>轮数{{ currentRecord.roundCount }}</span>
<span>评分{{ currentRecord.score }}</span>
</div>
</div>
</div>
<!-- 对话记录 -->
<div class="conversation-replay" v-loading="replayLoading">
<!-- 空状态提示 -->
<el-empty
v-if="!replayLoading && (!currentRecord.conversation || currentRecord.conversation.length === 0)"
description="暂无对话记录"
/>
<!-- 对话列表 -->
<div class="conversation-list" v-else>
<div
v-for="(message, index) in currentRecord.conversation"
:key="index"
class="message-item"
:class="message.role"
>
<div class="message-avatar">
<el-avatar v-if="message.role === 'user'" :size="32">
{{ currentRecord.userName?.charAt(0) || 'U' }}
</el-avatar>
<el-avatar v-else :size="32" style="background: #409eff">
AI
</el-avatar>
</div>
<div class="message-content">
<div class="message-header">
<span class="sender-name">
{{ message.role === 'user' ? (currentRecord.userName || '学员') : 'AI陪练师' }}
</span>
<span class="message-time">{{ message.timestamp }}</span>
</div>
<div class="message-text">{{ message.content }}</div>
<div v-if="message.feedback" class="ai-feedback">
<el-tag type="info" size="small">AI反馈</el-tag>
<span>{{ message.feedback }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import { practiceApi } from '@/api/practice'
const router = useRouter()
// 加载状态
const loading = ref(false)
// 日期范围
const dateRange = ref<[Date, Date] | null>(null)
// 分页
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
// 弹窗状态
const replayDialogVisible = ref(false)
const replayLoading = ref(false)
const currentRecord = ref<any>(null)
// 筛选表单
const filterForm = reactive({
keyword: '',
scene: '',
result: '',
timePeriod: ''
})
// 自定义日期范围
const customDateRange = ref<[Date, Date] | null>(null)
// 是否有活跃的筛选条件
const hasActiveFilters = computed(() => {
return !!(filterForm.keyword || filterForm.scene || filterForm.result || filterForm.timePeriod || (customDateRange.value && customDateRange.value.length === 2) || (dateRange.value && dateRange.value.length === 2))
})
// 定义统计数据接口
interface PracticeStat {
label: string
value: string
icon: string
color: string
bgColor: string
trend: number
}
// 陪练统计数据
const practiceStats = ref<PracticeStat[]>([])
// 陪练记录数据(直接使用后端返回的已筛选、已分页的数据)
const recordsList = ref([])
/**
* 获取场景类型标签样式
*/
const getSceneTypeTag = (type: string) => {
const tagMap: Record<string, string> = {
customer_consultation: 'primary',
beauty_care: 'success',
product_introduction: 'warning',
problem_handling: 'danger',
service_etiquette: 'info'
}
return tagMap[type] || ''
}
/**
* 获取评分颜色
*/
const getScoreColor = (score: number) => {
if (score >= 90) return '#67c23a'
if (score >= 80) return '#409eff'
if (score >= 70) return '#e6a23c'
return '#f56c6c'
}
/**
* 获取结果标签类型
*/
const getResultTagType = (result: string) => {
const tagMap: Record<string, string> = {
excellent: 'success',
good: 'primary',
average: 'warning',
needs_improvement: 'danger'
}
return tagMap[result] || ''
}
/**
* 获取结果文本
*/
const getResultText = (result: string) => {
const textMap: Record<string, string> = {
excellent: '优秀',
good: '良好',
average: '一般',
needs_improvement: '需改进'
}
return textMap[result] || result
}
/**
* 格式化时长
*/
const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else if (minutes > 0) {
return `${minutes}分钟${secs}`
} else {
return `${secs}`
}
}
/**
* 日期变化处理
*/
const handleDateChange = () => {
handleSearch()
}
/**
* 刷新数据
*/
const refreshData = async () => {
await loadStats()
await loadRecords()
ElMessage.success('数据已刷新')
}
/**
* 搜索处理
*/
const handleSearch = async () => {
currentPage.value = 1
await loadRecords()
}
/**
* 实时搜索处理
*/
const handleRealTimeSearch = async () => {
currentPage.value = 1
await loadRecords()
}
/**
* 重置筛选
*/
const handleReset = async () => {
filterForm.keyword = ''
filterForm.scene = ''
filterForm.result = ''
filterForm.timePeriod = ''
customDateRange.value = null
dateRange.value = null
currentPage.value = 1
await loadRecords()
ElMessage.success('已重置所有筛选条件')
}
/**
* 清除关键词筛选
*/
const clearKeyword = () => {
filterForm.keyword = ''
currentPage.value = 1
}
/**
* 清除场景筛选
*/
const clearScene = () => {
filterForm.scene = ''
currentPage.value = 1
}
/**
* 清除结果筛选
*/
const clearResult = () => {
filterForm.result = ''
currentPage.value = 1
}
/**
* 清除时间周期筛选
*/
const clearTimePeriod = () => {
filterForm.timePeriod = ''
currentPage.value = 1
}
/**
* 清除自定义日期筛选
*/
const clearCustomDate = () => {
customDateRange.value = null
currentPage.value = 1
}
/**
* 清除头部日期筛选
*/
const clearHeaderDate = () => {
dateRange.value = null
currentPage.value = 1
}
/**
* 获取场景文本
*/
const getSceneText = (scene: string) => {
const map: Record<string, string> = {
customer_consultation: '客户咨询',
beauty_care: '美容护理',
product_introduction: '产品介绍',
problem_handling: '问题处理',
service_etiquette: '服务礼仪'
}
return map[scene] || '未知'
}
/**
* 获取时间周期文本
*/
const getTimePeriodText = (period: string) => {
const map: Record<string, string> = {
week: '最近一周',
month: '最近一月',
quarter: '最近三月',
custom: '自定义时间'
}
return map[period] || '全部时间'
}
/**
* 格式化日期范围
*/
const formatDateRange = (range: Date[]) => {
if (!range || range.length !== 2) return ''
const start = range[0].toLocaleDateString()
const end = range[1].toLocaleDateString()
return start + ' 至 ' + end
}
/**
* 分页大小改变
*/
const handleSizeChange = (val: number) => {
pageSize.value = val
currentPage.value = 1
handleSearch()
}
/**
* 当前页改变
*/
const handleCurrentChange = (val: number) => {
currentPage.value = val
handleSearch()
}
/**
* 查看陪练报告(跳转到详细分析页面)
*/
const viewPracticeReport = (record: any) => {
// 跳转到陪练分析报告页面传递记录ID
router.push({
name: 'PracticeReport',
params: { id: record.id },
query: { sessionId: record.sessionId }
})
}
/**
* 回放陪练对话
*/
const replayPractice = async (record: any) => {
try {
// 先设置基本信息并打开弹窗
currentRecord.value = { ...record, conversation: [] }
replayDialogVisible.value = true
replayLoading.value = true
// 调用 API 获取对话详情
const response: any = await practiceApi.getPracticeReport(record.sessionId)
if (response.code === 200 && response.data?.analysis?.dialogue_review) {
// 转换对话数据格式
const dialogueReview = response.data.analysis.dialogue_review
currentRecord.value.conversation = dialogueReview.map((item: any) => ({
role: item.speaker === 'user' ? 'user' : 'ai',
content: item.content,
timestamp: item.time || '',
feedback: item.comment || ''
}))
}
} catch (error: any) {
console.error('获取对话详情失败:', error)
ElMessage.error('获取对话详情失败,请稍后重试')
} finally {
replayLoading.value = false
}
}
/**
* 导出陪练记录
*/
const exportRecord = (record: any) => {
ElMessage.success(`正在导出陪练记录 ${record.sessionId}...`)
// 这里可以调用导出 API
}
// 组件挂载时初始化数据
onMounted(async () => {
// 加载统计数据
await loadStats()
// 加载陪练记录
await loadRecords()
})
/**
* 加载统计数据
*/
const loadStats = async () => {
try {
const response: any = await practiceApi.getPracticeStats()
if (response.code === 200 && response.data) {
const data = response.data
practiceStats.value = [
{
label: '总陪练次数',
value: data.total_count.toString(),
icon: 'ChatLineRound',
color: '#667eea',
bgColor: 'rgba(102, 126, 234, 0.1)',
trend: 0
},
{
label: '平均评分',
value: data.avg_score.toFixed(1),
icon: 'TrendCharts',
color: '#67c23a',
bgColor: 'rgba(103, 194, 58, 0.1)',
trend: 0
},
{
label: '总陪练时长',
value: data.total_duration_hours + 'h',
icon: 'Timer',
color: '#e6a23c',
bgColor: 'rgba(230, 162, 60, 0.1)',
trend: 0
},
{
label: '本月进步',
value: '+' + data.month_improvement + '%',
icon: 'Top',
color: '#f56c6c',
bgColor: 'rgba(245, 108, 108, 0.1)',
trend: data.month_improvement
}
]
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
/**
* 加载陪练记录
*/
const loadRecords = async () => {
loading.value = true
try {
// 格式化日期参数
let startDate: string | undefined
let endDate: string | undefined
if (dateRange.value && dateRange.value.length === 2) {
startDate = dateRange.value[0].toISOString().split('T')[0]
endDate = dateRange.value[1].toISOString().split('T')[0]
}
const response: any = await practiceApi.getPracticeSessions({
page: currentPage.value,
size: pageSize.value,
keyword: filterForm.keyword || undefined,
scene_type: filterForm.scene || undefined,
start_date: startDate,
end_date: endDate
})
if (response.code === 200 && response.data) {
const data = response.data
// 转换数据格式
recordsList.value = data.items.map((item: any) => ({
id: item.session_id,
sessionId: item.session_id,
sceneName: item.scene_name,
sceneType: item.scene_type || 'customer_consultation',
duration: item.duration_seconds,
roundCount: item.turns,
score: item.total_score || 0,
result: item.result,
practiceTime: item.start_time,
userName: '我',
conversation: [] // 对话详情暂不加载,点击时再加载
}))
total.value = data.total
}
} catch (error) {
console.error('加载陪练记录失败:', error)
ElMessage.error('加载陪练记录失败')
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.practice-records-container {
max-width: 1400px;
margin: 0 auto;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 24px;
.stat-card {
padding: 24px;
display: flex;
align-items: center;
gap: 20px;
.stat-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-content {
flex: 1;
.stat-value {
font-size: 32px;
font-weight: 700;
color: #333;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.stat-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
&.up {
color: #67c23a;
}
&.down {
color: #f56c6c;
}
}
}
}
}
.filter-section {
padding: 20px;
margin-bottom: 20px;
.filter-form {
.el-form-item {
margin-bottom: 0;
}
}
.filter-tags {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
.filter-label {
color: #666;
font-size: 14px;
margin-right: 8px;
}
.clear-all-btn {
margin-left: 8px;
}
}
.search-result-info {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
.result-count {
color: #666;
font-size: 14px;
strong {
color: #409eff;
font-weight: 600;
}
}
.filter-hint {
color: #e6a23c;
font-size: 12px;
}
}
}
.records-list {
padding: 24px;
.scene-info {
.el-tag {
font-weight: 500;
}
}
.score-display {
display: flex;
align-items: center;
gap: 12px;
.score-text {
font-weight: 600;
min-width: 50px;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 24px;
}
}
.replay-content {
.replay-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #eee;
.session-info {
h3 {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.session-meta {
display: flex;
gap: 16px;
font-size: 14px;
color: #666;
span {
padding: 4px 8px;
background: #f5f7fa;
border-radius: 4px;
}
}
}
}
.conversation-replay {
max-height: 400px;
overflow-y: auto;
.conversation-list {
.message-item {
display: flex;
gap: 12px;
margin-bottom: 16px;
&.user {
flex-direction: row-reverse;
.message-content {
background: #409eff;
color: white;
border-radius: 18px 18px 4px 18px;
}
}
&.ai {
.message-content {
background: #f5f7fa;
color: #333;
border-radius: 18px 18px 18px 4px;
}
}
.message-avatar {
flex-shrink: 0;
}
.message-content {
max-width: 70%;
padding: 12px 16px;
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-size: 12px;
opacity: 0.8;
.sender-name {
font-weight: 500;
}
.message-time {
font-size: 11px;
}
}
.message-text {
line-height: 1.4;
word-break: break-word;
}
.ai-feedback {
margin-top: 8px;
padding: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-size: 12px;
.el-tag {
margin-right: 8px;
}
}
}
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.practice-records-container {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.header-actions {
width: 100%;
justify-content: flex-end;
}
}
.stats-overview {
grid-template-columns: 1fr;
}
.filter-form {
.el-form-item {
display: block;
margin-bottom: 16px !important;
}
}
.conversation-replay {
.message-item {
.message-content {
max-width: 85%;
}
}
}
}
}
</style>