feat: 初始化考培练系统项目

- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
111
2026-01-24 19:33:28 +08:00
commit 998211c483
1197 changed files with 228429 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,378 @@
<template>
<div class="logs-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="refreshLogs">
<el-icon class="el-icon--left"><Refresh /></el-icon>
刷新日志
</el-button>
<el-button @click="exportLogs">
<el-icon class="el-icon--left"><Download /></el-icon>
导出日志
</el-button>
</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
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.level" placeholder="全部级别" clearable style="width: 120px">
<el-option label="DEBUG" value="debug" />
<el-option label="INFO" value="info" />
<el-option label="WARNING" value="warning" />
<el-option label="ERROR" value="error" />
</el-select>
</el-form-item>
<el-form-item label="日志类型">
<el-select v-model="filterForm.type" placeholder="全部类型" clearable style="width: 120px">
<el-option label="系统日志" value="system" />
<el-option label="用户操作" value="user" />
<el-option label="API调用" value="api" />
<el-option label="错误日志" value="error" />
<el-option label="安全日志" value="security" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon class="el-icon--left"><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 日志列表 -->
<div class="logs-list card">
<el-table
:data="filteredLogList"
style="width: 100%"
v-loading="loading"
row-class-name="log-row"
>
<el-table-column prop="timestamp" label="时间" width="180" sortable />
<el-table-column prop="level" label="级别" width="100">
<template #default="scope">
<el-tag :type="getLevelTagType(scope.row.level)" size="small">
{{ scope.row.level.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="120">
<template #default="scope">
<el-tag :type="getTypeTagType(scope.row.type)" size="small">
{{ getTypeText(scope.row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="user" label="用户" width="120" />
<el-table-column prop="ip" label="IP地址" width="140" />
<el-table-column prop="message" label="日志内容" min-width="300" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="viewLogDetail(scope.row)">
查看详情
</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="[20, 50, 100, 200]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Download, Search } from '@element-plus/icons-vue'
import { getSystemLogs, type SystemLog, type SystemLogQuery } from '@/api/systemLogs'
// 加载状态
const loading = ref(false)
// 日期范围
const dateRange = ref<[Date, Date]>([
new Date(new Date().setDate(new Date().getDate() - 7)),
new Date()
])
// 分页
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const totalPages = ref(0)
// 筛选表单
const filterForm = reactive({
level: '',
type: '',
keyword: ''
})
// 日志列表数据
const logList = ref<SystemLog[]>([])
// 格式化后的日志列表(用于展示)
const filteredLogList = computed(() => {
return logList.value.map(log => ({
id: log.id,
timestamp: new Date(log.created_at).toLocaleString('zh-CN'),
level: log.level,
type: log.type,
user: log.user || '系统',
ip: log.ip || '-',
message: log.message,
userAgent: log.user_agent || '',
path: log.path || '',
method: log.method || ''
}))
})
/**
* 加载系统日志数据
*/
const loadLogs = async () => {
loading.value = true
try {
const params: SystemLogQuery = {
level: filterForm.level || undefined,
type: filterForm.type || undefined,
keyword: filterForm.keyword || undefined,
start_date: dateRange.value?.[0]?.toISOString(),
end_date: dateRange.value?.[1]?.toISOString(),
page: currentPage.value,
page_size: pageSize.value
}
const response = await getSystemLogs(params)
if (response.code === 200 && response.data) {
logList.value = response.data.items
total.value = response.data.total
totalPages.value = response.data.total_pages
} else {
ElMessage.error(response.message || '获取日志数据失败')
}
} catch (error: any) {
console.error('加载日志失败:', error)
ElMessage.error(error.message || '获取日志数据失败,请稍后重试')
} finally {
loading.value = false
}
}
/**
* 日期变化处理
*/
const handleDateChange = () => {
currentPage.value = 1
loadLogs()
}
/**
* 刷新日志
*/
const refreshLogs = () => {
currentPage.value = 1
loadLogs()
}
/**
* 导出日志
*/
const exportLogs = () => {
ElMessage.info('导出功能开发中...')
}
/**
* 搜索处理
*/
const handleSearch = () => {
currentPage.value = 1
loadLogs()
}
/**
* 重置筛选
*/
const handleReset = () => {
filterForm.level = ''
filterForm.type = ''
filterForm.keyword = ''
currentPage.value = 1
dateRange.value = [
new Date(new Date().setDate(new Date().getDate() - 7)),
new Date()
]
loadLogs()
}
/**
* 分页大小改变
*/
const handleSizeChange = (val: number) => {
pageSize.value = val
currentPage.value = 1
loadLogs()
}
/**
* 当前页改变
*/
const handleCurrentChange = (val: number) => {
currentPage.value = val
loadLogs()
}
/**
* 查看日志详情
*/
const viewLogDetail = (log: any) => {
ElMessage.info({
message: `日志详情:\n级别${log.level}\n类型${log.type}\n用户${log.user}\nIP${log.ip}\n消息${log.message}`,
duration: 5000
})
}
/**
* 获取级别标签类型
*/
const getLevelTagType = (level: string) => {
const map: Record<string, string> = {
debug: 'info',
info: 'success',
warning: 'warning',
error: 'danger'
}
return map[level] || 'info'
}
/**
* 获取类型标签类型
*/
const getTypeTagType = (type: string) => {
const map: Record<string, string> = {
system: 'primary',
user: 'success',
api: 'info',
error: 'danger',
security: 'warning'
}
return map[type] || 'info'
}
/**
* 获取类型文本
*/
const getTypeText = (type: string) => {
const map: Record<string, string> = {
system: '系统日志',
user: '用户操作',
api: 'API调用',
error: '错误日志',
security: '安全日志'
}
return map[type] || type
}
// 初始化加载数据
onMounted(() => {
loadLogs()
})
console.log('系统日志页面已加载')
</script>
<style lang="scss" scoped>
.logs-container {
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.filter-section {
.filter-form {
.el-form-item {
margin-bottom: 0;
}
}
}
.logs-list {
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
}
}
// 日志行样式
:deep(.log-row) {
&.el-table__row--level-error {
background-color: #fef0f0;
}
&.el-table__row--level-warning {
background-color: #fdf6ec;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,692 @@
<template>
<div class="report-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="exportReport">
<el-icon class="el-icon--left"><Download /></el-icon>
导出报告
</el-button>
</div>
</div>
<!-- 成绩概览 -->
<div class="overview-section">
<h2 class="section-title">成绩概览</h2>
<div class="overview-cards">
<div class="overview-card card" v-for="item in overviewData" :key="item.label">
<div class="card-icon" :style="{ backgroundColor: item.bgColor }">
<el-icon :size="32" :color="item.color">
<component :is="item.icon" />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ item.value }}</div>
<div class="card-label">{{ item.label }}</div>
<div class="card-trend" :class="item.trend > 0 ? 'up' : 'down'">
<el-icon><component :is="item.trend > 0 ? 'Top' : 'Bottom'" /></el-icon>
{{ Math.abs(item.trend) }}%
</div>
</div>
</div>
</div>
</div>
<!-- 成绩趋势图 -->
<div class="trend-section card">
<h2 class="section-title">成绩趋势</h2>
<div class="chart-container" ref="trendChartRef"></div>
</div>
<!-- 科目分析 -->
<div class="subject-section">
<h2 class="section-title">科目分析</h2>
<div class="subject-grid">
<div class="subject-card card" v-for="subject in subjectData" :key="subject.name">
<div class="subject-header">
<h3>{{ subject.name }}</h3>
<el-tag :type="getScoreTagType(subject.avgScore)">
平均分{{ subject.avgScore }}
</el-tag>
</div>
<div class="subject-stats">
<div class="stat-item">
<span class="stat-label">考试次数</span>
<span class="stat-value">{{ subject.examCount }}</span>
</div>
<div class="stat-item">
<span class="stat-label">最高分</span>
<span class="stat-value">{{ subject.maxScore }}</span>
</div>
<div class="stat-item">
<span class="stat-label">最低分</span>
<span class="stat-value">{{ subject.minScore }}</span>
</div>
<div class="stat-item">
<span class="stat-label">及格率</span>
<span class="stat-value">{{ subject.passRate }}%</span>
</div>
</div>
<div class="progress-wrapper">
<el-progress :percentage="subject.progress" :color="getProgressColor(subject.progress)" />
</div>
</div>
</div>
</div>
<!-- 最近考试记录 -->
<div class="recent-section card">
<h2 class="section-title">最近考试记录</h2>
<el-table :data="recentExams" style="width: 100%">
<el-table-column prop="date" label="考试时间" width="180" />
<el-table-column prop="name" label="考试名称" />
<el-table-column prop="subject" label="科目" width="120" />
<el-table-column prop="score" label="总分" width="90">
<template #default="scope">
<span :class="getScoreClass(scope.row.score, scope.row.totalScore)">
{{ scope.row.score }} / {{ scope.row.totalScore }}
</span>
</template>
</el-table-column>
<el-table-column label="各轮得分" width="200">
<template #default="scope">
<div class="round-scores-inline">
<span v-for="round in scope.row.roundScores" :key="round.round" class="round-score-tag">
{{ round.round }}:
<strong :class="getRoundScoreClass(round.score, scope.row.totalScore)">{{ round.score }}</strong>
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="rank" label="排名" width="90">
<template #default="scope">
<span>{{ scope.row.rank }} / {{ scope.row.totalPeople }}</span>
</template>
</el-table-column>
<el-table-column prop="duration" label="用时" width="100">
<template #default="scope">
{{ formatDuration(scope.row.duration) }}
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { getExamReport } from '@/api/exam'
import type { ExamReportResponse } from '@/api/exam'
// 日期范围
const dateRange = ref<[Date, Date]>([
new Date(new Date().setMonth(new Date().getMonth() - 3)),
new Date()
])
// 概览数据从API加载
const overviewData = ref<any[]>([])
// 科目数据从API加载
const subjectData = ref<any[]>([])
// 最近考试记录从API加载
const recentExams = ref<any[]>([])
// 图表引用
const trendChartRef = ref()
let chartInstance: any = null
/**
* 初始化图表
*/
const initChart = () => {
if (!trendChartRef.value) return
chartInstance = echarts.init(trendChartRef.value)
// 初始化空图表实际数据由loadReportData加载
const option = {
title: {
text: '成绩趋势',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 600,
color: '#333'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
xAxis: {
type: 'category',
data: []
},
yAxis: {
type: 'value',
name: '分数',
min: 0,
max: 100
},
series: []
}
chartInstance.setOption(option)
}
/**
* 日期变化
*/
const handleDateChange = () => {
if (dateRange.value && dateRange.value.length === 2) {
// 重新加载报告数据
loadReportData()
}
}
/**
* 导出报告
*/
const exportReport = () => {
ElMessage.success('报告导出成功')
}
/**
* 获取分数标签类型
*/
const getScoreTagType = (score: number) => {
if (score >= 90) return 'success'
if (score >= 80) return ''
if (score >= 60) return 'warning'
return 'danger'
}
/**
* 获取进度条颜色
*/
const getProgressColor = (percentage: number) => {
if (percentage >= 90) return '#67c23a'
if (percentage >= 80) return '#409eff'
if (percentage >= 60) return '#e6a23c'
return '#f56c6c'
}
/**
* 获取分数样式类
*/
const getScoreClass = (score: number, total: number) => {
const percentage = (score / total) * 100
if (percentage >= 90) return 'score-excellent'
if (percentage >= 80) return 'score-good'
if (percentage >= 60) return 'score-pass'
return 'score-fail'
}
/**
* 获取轮次分数样式类
*/
const getRoundScoreClass = (score: number, total: number) => {
const percentage = (score / total) * 100
if (percentage >= 90) return 'score-excellent'
if (percentage >= 80) return 'score-good'
if (percentage >= 60) return 'score-pass'
return 'score-fail'
}
/**
* 格式化时长
*/
const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return hours > 0 ? `${hours}小时${minutes}` : `${minutes}分钟`
}
/**
* 加载成绩报告数据
*/
const loadReportData = async () => {
try {
const params: any = {}
// 添加时间范围参数
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = dateRange.value[0].toISOString().split('T')[0]
params.end_date = dateRange.value[1].toISOString().split('T')[0]
}
const response = await getExamReport(params)
// request.ts返回的是 {code, message, data}直接访问response.data
const reportData = response.data as ExamReportResponse
if (!reportData || !reportData.overview) {
console.error('成绩报告数据为空:', response)
ElMessage.error('成绩报告数据格式错误')
return
}
// 适配概览数据
overviewData.value = [
{
label: '平均成绩',
value: reportData.overview.avg_score.toFixed(1),
icon: 'TrendCharts',
color: '#667eea',
bgColor: 'rgba(102, 126, 234, 0.1)',
trend: 5.2 // TODO: 计算趋势
},
{
label: '考试总数',
value: reportData.overview.total_exams.toString(),
icon: 'Document',
color: '#f56c6c',
bgColor: 'rgba(245, 108, 108, 0.1)',
trend: 12.8
},
{
label: '及格率',
value: reportData.overview.pass_rate.toFixed(1) + '%',
icon: 'CircleCheck',
color: '#67c23a',
bgColor: 'rgba(103, 194, 58, 0.1)',
trend: 3.4
},
{
label: '答题总数',
value: reportData.overview.total_questions.toString(),
icon: 'Trophy',
color: '#e6a23c',
bgColor: 'rgba(230, 162, 60, 0.1)',
trend: -2.1
}
]
// 适配科目分析数据
subjectData.value = reportData.subjects.map((s: any) => ({
name: s.course_name,
avgScore: s.avg_score,
examCount: s.exam_count,
maxScore: s.max_score,
minScore: s.min_score,
passRate: s.pass_rate,
progress: s.avg_score
}))
// 适配最近考试记录
recentExams.value = reportData.recent_exams.map((e: any) => {
// 计算用时
const duration = e.duration_seconds || 0
// 构建三轮得分数组过滤null值
const roundScores: Array<{round: number, score: number}> = []
if (e.round_scores.round1 !== null) {
roundScores.push({ round: 1, score: e.round_scores.round1 })
}
if (e.round_scores.round2 !== null) {
roundScores.push({ round: 2, score: e.round_scores.round2 })
}
if (e.round_scores.round3 !== null) {
roundScores.push({ round: 3, score: e.round_scores.round3 })
}
return {
date: e.start_time,
name: e.course_name + ' 考试',
subject: e.course_name,
score: e.score || 0,
totalScore: e.total_score,
rank: 0, // TODO: 后续可以添加排名功能
totalPeople: 0,
duration: duration,
roundScores: roundScores
}
})
// 更新趋势图
updateTrendChart(reportData.trends)
ElMessage.success('成绩报告加载成功')
} catch (error: any) {
console.error('加载成绩报告失败:', error)
ElMessage.error('加载成绩报告失败')
}
}
/**
* 更新趋势图数据
*/
const updateTrendChart = (trends: any[]) => {
if (!chartInstance || trends.length === 0) return
const dates = trends.map(t => t.date.split('-').slice(1).join('-')) // MM-DD格式
const scores = trends.map(t => t.avg_score)
const option = {
title: {
text: '成绩趋势',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 600,
color: '#333'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['平均分'],
bottom: 10
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
name: '分数',
min: 0,
max: 100
},
series: [
{
name: '平均分',
type: 'line',
smooth: true,
data: scores,
itemStyle: { color: '#667eea' },
lineStyle: { width: 2 }
}
]
}
chartInstance.setOption(option)
}
onMounted(() => {
nextTick(() => {
initChart()
loadReportData() // 加载真实数据
})
// 响应式处理
window.addEventListener('resize', () => {
chartInstance?.resize()
})
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
}
})
</script>
<style lang="scss" scoped>
.report-container {
.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;
.el-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
.overview-section {
margin-bottom: 32px;
.overview-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
.overview-card {
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
.card-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.card-content {
flex: 1;
.card-value {
font-size: 32px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.card-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.card-trend {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
&.up {
color: #67c23a;
background-color: rgba(103, 194, 58, 0.1);
}
&.down {
color: #f56c6c;
background-color: rgba(245, 108, 108, 0.1);
}
}
}
}
}
}
.trend-section {
margin-bottom: 32px;
padding: 24px;
.chart-container {
height: 400px;
width: 100%;
}
}
.subject-section {
margin-bottom: 32px;
.subject-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
.subject-card {
padding: 20px;
.subject-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
}
}
.subject-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
.stat-item {
display: flex;
justify-content: space-between;
font-size: 14px;
.stat-label {
color: #666;
}
.stat-value {
font-weight: 600;
color: #333;
}
}
}
.progress-wrapper {
margin-top: 12px;
}
}
}
}
.recent-section {
padding: 24px;
.score-excellent {
color: #67c23a;
font-weight: 600;
}
.score-good {
color: #409eff;
font-weight: 600;
}
.score-pass {
color: #e6a23c;
}
.score-fail {
color: #f56c6c;
}
.round-scores-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
.round-score-tag {
font-size: 12px;
color: #666;
strong {
font-weight: 600;
margin-left: 4px;
&.score-excellent {
color: #67c23a;
}
&.score-good {
color: #409eff;
}
&.score-pass {
color: #e6a23c;
}
&.score-fail {
color: #f56c6c;
}
}
}
}
}
}
// 响应式
@media (max-width: 768px) {
.report-container {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.header-actions {
width: 100%;
flex-direction: column;
.el-date-picker,
.el-button {
width: 100%;
}
}
}
.overview-cards {
grid-template-columns: 1fr !important;
}
.subject-grid {
grid-template-columns: 1fr !important;
}
}
}
</style>

View File

@@ -0,0 +1,984 @@
<template>
<div class="statistics-container">
<div class="page-header">
<h1 class="page-title">统计分析</h1>
<div class="header-filters">
<el-select
v-model="selectedSubject"
placeholder="选择科目"
@change="handleSubjectChange"
:loading="coursesLoading"
>
<el-option label="全部科目" value="" />
<el-option
v-for="course in courseList"
:key="course.id"
:label="course.name"
:value="course.id"
/>
</el-select>
<el-select v-model="selectedPeriod" placeholder="时间范围" @change="handlePeriodChange">
<el-option label="最近一周" value="week" />
<el-option label="最近一月" value="month" />
<el-option label="最近三月" value="quarter" />
<el-option label="最近半年" value="halfYear" />
<el-option label="最近一年" value="year" />
</el-select>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-icon class="is-loading" :size="50"><Loading /></el-icon>
<p>数据加载中...</p>
</div>
<!-- 数据内容 -->
<div v-else-if="!hasError">
<!-- 关键指标 -->
<div class="metrics-section">
<div class="metric-card card" v-for="metric in keyMetrics" :key="metric.name">
<div class="metric-header">
<span class="metric-name">{{ metric.name }}</span>
<el-tooltip :content="metric.description" placement="top">
<el-icon class="info-icon"><InfoFilled /></el-icon>
</el-tooltip>
</div>
<div class="metric-value" :style="{ color: metric.color }">
{{ metric.value }}{{ metric.unit }}
</div>
<div class="metric-compare">
<span>环比</span>
<span class="compare-value" :class="metric.change > 0 ? 'up' : 'down'">
{{ metric.change > 0 ? '+' : '' }}{{ metric.change }}%
</span>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="charts-grid">
<!-- 成绩分布图 -->
<div class="chart-card card">
<h3 class="chart-title">成绩分布</h3>
<div class="chart-container" ref="distributionChartRef"></div>
</div>
<!-- 难度分析图 -->
<div class="chart-card card">
<h3 class="chart-title">题目难度分析</h3>
<div class="chart-container" ref="difficultyChartRef"></div>
</div>
<!-- 知识点掌握度 -->
<div class="chart-card card">
<h3 class="chart-title">知识点掌握度</h3>
<div class="chart-container" ref="knowledgeChartRef"></div>
</div>
<!-- 学习时长统计 -->
<div class="chart-card card">
<h3 class="chart-title">学习时长统计</h3>
<div class="chart-container" ref="timeChartRef"></div>
</div>
</div>
<!-- 详细数据表格 -->
<div class="detail-section card">
<div class="section-header">
<h3 class="section-title">详细数据</h3>
<el-button link type="primary" @click="exportData">
<el-icon><Download /></el-icon>
导出数据
</el-button>
</div>
<el-table :data="detailData" style="width: 100%">
<el-table-column prop="date" label="日期" width="120" />
<el-table-column prop="examCount" label="考试次数" width="100" />
<el-table-column prop="avgScore" label="平均分" width="100">
<template #default="scope">
{{ scope.row.avgScore.toFixed(1) }}
</template>
</el-table-column>
<el-table-column prop="studyTime" label="学习时长" width="120">
<template #default="scope">
{{ scope.row.studyTime }} 小时
</template>
</el-table-column>
<el-table-column prop="practiceCount" label="练习题数" width="100">
<template #default="scope">
{{ scope.row.questionCount || 0 }}
</template>
</el-table-column>
<el-table-column prop="accuracy" label="正确率" width="100">
<template #default="scope">
{{ typeof scope.row.accuracy === 'number' ? scope.row.accuracy.toFixed(1) : '0.0' }}%
</template>
</el-table-column>
<el-table-column prop="improvement" label="进步指数" width="150">
<template #default="scope">
<el-progress
:percentage="scope.row.improvement || 0"
:color="getProgressColor(scope.row.improvement || 0)"
/>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 错误状态 -->
<div v-else class="error-container">
<el-result icon="error" title="数据加载失败" :sub-title="errorMessage">
<template #extra>
<el-button type="primary" @click="loadAllData">重试</el-button>
</template>
</el-result>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading, InfoFilled, Download } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import { courseApi, type Course } from '@/api/course'
import {
getKeyMetrics,
getScoreDistribution,
getDifficultyAnalysis,
getKnowledgeMastery,
getStudyTimeStats,
getDetailData
} from '@/api/statistics'
import type {
ScoreDistributionResponse,
DifficultyAnalysisResponse,
KnowledgeMasteryItem,
StudyTimeStatsResponse,
DetailDataItem
} from '@/api/statistics'
// 选择的科目和时间范围
const selectedSubject = ref<number | string>('')
const selectedPeriod = ref('month')
// 加载状态
const loading = ref(false)
const coursesLoading = ref(false)
const hasError = ref(false)
const errorMessage = ref('')
// 课程列表
const courseList = ref<Course[]>([])
// 图表引用
const distributionChartRef = ref()
const difficultyChartRef = ref()
const knowledgeChartRef = ref()
const timeChartRef = ref()
let distributionChart: any = null
let difficultyChart: any = null
let knowledgeChart: any = null
let timeChart: any = null
// 图表数据缓存(用于延迟初始化)
const chartDataCache = ref<{
distribution: ScoreDistributionResponse | null
difficulty: DifficultyAnalysisResponse | null
knowledge: KnowledgeMasteryItem[] | null
time: StudyTimeStatsResponse | null
}>({
distribution: null,
difficulty: null,
knowledge: null,
time: null
})
// 关键指标
const keyMetrics = ref([
{
name: '学习效率',
value: 0,
unit: '%',
change: 0,
color: '#667eea',
description: '正确题数/总练习题数'
},
{
name: '知识覆盖率',
value: 0,
unit: '%',
change: 0,
color: '#764ba2',
description: '已学知识点/总知识点数'
},
{
name: '平均用时',
value: 0,
unit: '分/题',
change: 0,
color: '#f093fb',
description: '每道题平均用时'
},
{
name: '进步速度',
value: 0,
unit: '%',
change: 0,
color: '#4facfe',
description: '近期分数提升幅度'
}
])
// 详细数据
const detailData = ref<DetailDataItem[]>([])
/**
* 加载课程列表
*/
const loadCourses = async () => {
try {
coursesLoading.value = true
const response = await courseApi.list({
page: 1,
size: 100,
})
if (response.code === 200 && response.data) {
courseList.value = response.data.items || []
}
} catch (error) {
console.error('加载课程列表失败:', error)
ElMessage.error('加载课程列表失败')
} finally {
coursesLoading.value = false
}
}
/**
* 加载关键指标
*/
const loadKeyMetrics = async () => {
try {
const params: any = { period: selectedPeriod.value }
if (selectedSubject.value && selectedSubject.value !== '') {
params.course_id = selectedSubject.value
}
const response = await getKeyMetrics(params)
if (response.code === 200 && response.data) {
const data = response.data as any
// 后端返回的是嵌套对象结构
keyMetrics.value[0].value = data.learningEfficiency?.value || 0
keyMetrics.value[0].change = data.learningEfficiency?.change || 0
keyMetrics.value[1].value = data.knowledgeCoverage?.value || 0
keyMetrics.value[1].change = data.knowledgeCoverage?.change || 0
keyMetrics.value[2].value = data.avgTimePerQuestion?.value || 0
keyMetrics.value[2].change = data.avgTimePerQuestion?.change || 0
keyMetrics.value[3].value = data.progressSpeed?.value || 0
keyMetrics.value[3].change = data.progressSpeed?.change || 0
}
} catch (error: any) {
console.error('加载关键指标失败:', error)
// 不再throw error让其他模块能够独立加载
}
}
/**
* 加载成绩分布数据(仅获取数据,不初始化图表)
*/
const loadScoreDistribution = async () => {
console.log('📊 loadScoreDistribution 开始')
try {
const params: any = { period: selectedPeriod.value }
if (selectedSubject.value && selectedSubject.value !== '') {
params.course_id = selectedSubject.value
}
console.log('📊 调用 getScoreDistribution API', params)
const response = await getScoreDistribution(params)
console.log('📊 getScoreDistribution 响应:', response)
if (response.code === 200 && response.data) {
const data = response.data as ScoreDistributionResponse
console.log('✅ 成绩分布数据已缓存', data)
chartDataCache.value.distribution = data
} else {
console.warn('⚠️ API 返回数据无效', response)
}
} catch (error: any) {
console.error('❌ 加载成绩分布失败:', error)
// 不再throw error让其他模块能够独立加载
}
}
/**
* 加载难度分析数据(仅获取数据,不初始化图表)
*/
const loadDifficultyAnalysis = async () => {
try {
const params: any = { period: selectedPeriod.value }
if (selectedSubject.value && selectedSubject.value !== '') {
params.course_id = selectedSubject.value
}
const response = await getDifficultyAnalysis(params)
if (response.code === 200 && response.data) {
const data = response.data as DifficultyAnalysisResponse
console.log('✅ 难度分析数据已缓存', data)
chartDataCache.value.difficulty = data
}
} catch (error: any) {
console.error('加载难度分析失败:', error)
// 不再throw error让其他模块能够独立加载
}
}
/**
* 加载知识点掌握度数据(仅获取数据,不初始化图表)
*/
const loadKnowledgeMastery = async () => {
try {
const params: any = {}
if (selectedSubject.value && selectedSubject.value !== '') {
params.course_id = selectedSubject.value
}
const response = await getKnowledgeMastery(params)
if (response.code === 200 && response.data) {
const data = response.data as KnowledgeMasteryItem[]
console.log('✅ 知识点掌握度数据已缓存', data)
chartDataCache.value.knowledge = data as any
}
} catch (error: any) {
console.error('加载知识点掌握度失败:', error)
// 不再throw error让其他模块能够独立加载
}
}
/**
* 加载学习时长数据(仅获取数据,不初始化图表)
*/
const loadStudyTimeStats = async () => {
try {
const params: any = { period: selectedPeriod.value }
if (selectedSubject.value && selectedSubject.value !== '') {
params.course_id = selectedSubject.value
}
const response = await getStudyTimeStats(params)
if (response.code === 200 && response.data) {
const data = response.data as StudyTimeStatsResponse
console.log('✅ 学习时长数据已缓存', data)
chartDataCache.value.time = data
}
} catch (error: any) {
console.error('加载学习时长失败:', error)
// 不再throw error让其他模块能够独立加载
}
}
/**
* 加载详细数据
*/
const loadDetailData = async () => {
try {
const params: any = { period: selectedPeriod.value }
if (selectedSubject.value && selectedSubject.value !== '') {
params.course_id = selectedSubject.value
}
const response = await getDetailData(params)
if (response.code === 200 && response.data) {
detailData.value = response.data as DetailDataItem[]
}
} catch (error: any) {
console.error('加载详细数据失败:', error)
// 不再throw error让其他模块能够独立加载
}
}
/**
* 加载所有数据
*/
const loadAllData = async () => {
loading.value = true
hasError.value = false
errorMessage.value = ''
try {
await Promise.all([
loadKeyMetrics(),
loadScoreDistribution(),
loadDifficultyAnalysis(),
loadKnowledgeMastery(),
loadStudyTimeStats(),
loadDetailData()
])
} catch (error: any) {
hasError.value = true
errorMessage.value = error.message || '数据加载失败,请稍后重试'
ElMessage.error(errorMessage.value)
} finally {
loading.value = false
// 等待DOM渲染完成后再初始化所有图表
await nextTick()
// 使用 requestAnimationFrame 确保 DOM 完全渲染
requestAnimationFrame(() => {
setTimeout(() => {
initAllCharts()
}, 200)
})
}
}
/**
* 统一初始化所有图表确保DOM已渲染
*/
const initAllCharts = () => {
console.log('🎨 initAllCharts 开始,检查缓存数据')
// 检查关键 DOM 元素是否已渲染
const refsReady = distributionChartRef.value && difficultyChartRef.value &&
knowledgeChartRef.value && timeChartRef.value
if (!refsReady) {
console.warn('⚠️ 图表容器 DOM 未完全准备好,延迟 300ms 后重试')
setTimeout(() => {
initAllCharts()
}, 300)
return
}
if (chartDataCache.value.distribution) {
console.log('📊 初始化成绩分布图表')
initDistributionChart(chartDataCache.value.distribution)
}
if (chartDataCache.value.difficulty) {
console.log('📊 初始化难度分析图表')
initDifficultyChart(chartDataCache.value.difficulty)
}
if (chartDataCache.value.knowledge) {
console.log('📊 初始化知识点掌握度图表')
initKnowledgeChart(chartDataCache.value.knowledge)
}
if (chartDataCache.value.time) {
console.log('📊 初始化学习时长图表')
initTimeChart(chartDataCache.value.time)
}
console.log('✅ initAllCharts 完成')
}
/**
* 科目变化
*/
const handleSubjectChange = () => {
loadAllData()
}
/**
* 时间范围变化
*/
const handlePeriodChange = () => {
loadAllData()
}
/**
* 导出数据
*/
const exportData = () => {
ElMessage.info('导出功能开发中...')
}
/**
* 获取进度颜色
*/
const getProgressColor = (percentage: number) => {
if (percentage >= 80) return '#67c23a'
if (percentage >= 60) return '#409eff'
if (percentage >= 40) return '#e6a23c'
return '#f56c6c'
}
/**
* 初始化成绩分布图
*/
const initDistributionChart = (data: ScoreDistributionResponse) => {
console.log('🎨 initDistributionChart 被调用', {
hasRef: !!distributionChartRef.value,
data
})
if (!distributionChartRef.value) {
console.warn('⚠️ distributionChartRef.value 不存在,跳过初始化')
return
}
if (!distributionChart) {
console.log('📊 创建新的 distribution 图表实例')
try {
distributionChart = echarts.init(distributionChartRef.value)
console.log('✅ distribution 图表创建成功')
} catch (error) {
console.error('❌ 创建 distribution 图表失败:', error)
return
}
}
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
bottom: 10,
left: 'center'
},
series: [
{
name: '成绩分布',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{ value: data.excellent, name: '优秀(90-100)', itemStyle: { color: '#67c23a' } },
{ value: data.good, name: '良好(80-89)', itemStyle: { color: '#409eff' } },
{ value: data.medium, name: '中等(70-79)', itemStyle: { color: '#e6a23c' } },
{ value: data.pass, name: '及格(60-69)', itemStyle: { color: '#f56c6c' } },
{ value: data.fail, name: '不及格(<60)', itemStyle: { color: '#909399' } }
]
}
]
}
distributionChart.setOption(option)
console.log('✅ 成绩分布图表已更新')
}
/**
* 初始化难度分析图
*/
const initDifficultyChart = (data: DifficultyAnalysisResponse) => {
if (!difficultyChartRef.value) {
console.warn('⚠️ difficultyChartRef.value 不存在,跳过初始化')
return
}
if (!difficultyChart) {
try {
difficultyChart = echarts.init(difficultyChartRef.value)
} catch (error) {
console.error('❌ 创建 difficulty 图表失败:', error)
return
}
}
const keys = Object.keys(data)
const values = Object.values(data)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
radar: {
indicator: keys.map(key => ({
name: key,
max: 100
})),
radius: '65%'
},
series: [
{
name: '正确率',
type: 'radar',
data: [
{
value: values,
name: '题目难度正确率',
areaStyle: {
color: 'rgba(102, 126, 234, 0.2)'
},
itemStyle: {
color: '#667eea'
}
}
]
}
]
}
difficultyChart.setOption(option)
}
/**
* 初始化知识点掌握度图
*/
const initKnowledgeChart = (data: KnowledgeMasteryItem[]) => {
if (!knowledgeChartRef.value) {
console.warn('⚠️ knowledgeChartRef.value 不存在,跳过初始化')
return
}
if (!knowledgeChart) {
try {
knowledgeChart = echarts.init(knowledgeChartRef.value)
} catch (error) {
console.error('❌ 创建 knowledge 图表失败:', error)
return
}
}
const names = data.map(item => item.name)
const masteries = data.map(item => item.mastery)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: names,
axisLabel: {
rotate: 45,
interval: 0
}
},
yAxis: {
type: 'value',
max: 100,
axisLabel: {
formatter: '{value}%'
}
},
series: [
{
name: '掌握度',
type: 'bar',
data: masteries,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#667eea' },
{ offset: 1, color: '#764ba2' }
])
},
label: {
show: true,
position: 'top',
formatter: '{c}%'
}
}
]
}
knowledgeChart.setOption(option)
}
/**
* 初始化学习时长图
*/
const initTimeChart = (data: StudyTimeStatsResponse) => {
if (!timeChartRef.value) {
console.warn('⚠️ timeChartRef.value 不存在,跳过初始化')
return
}
if (!timeChart) {
try {
timeChart = echarts.init(timeChartRef.value)
} catch (error) {
console.error('❌ 创建 time 图表失败:', error)
return
}
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['学习时长', '练习时长'],
bottom: 10
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: data.labels
},
yAxis: {
type: 'value',
axisLabel: {
formatter: '{value}h'
}
},
series: [
{
name: '学习时长',
type: 'line',
smooth: true,
data: data.studyTime,
itemStyle: {
color: '#667eea'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(102, 126, 234, 0.5)' },
{ offset: 1, color: 'rgba(102, 126, 234, 0.1)' }
])
}
},
{
name: '练习时长',
type: 'line',
smooth: true,
data: data.practiceTime,
itemStyle: {
color: '#f093fb'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(240, 147, 251, 0.5)' },
{ offset: 1, color: 'rgba(240, 147, 251, 0.1)' }
])
}
}
]
}
timeChart.setOption(option)
}
/**
* 组件挂载时初始化
*/
onMounted(() => {
console.log('🚀 统计分析页面已挂载')
// 加载课程列表
loadCourses()
// 加载所有统计数据
nextTick(() => {
loadAllData()
})
// 响应式处理
window.addEventListener('resize', handleResize)
})
/**
* 组件卸载时清理资源
*/
onUnmounted(() => {
// 销毁图表实例
distributionChart?.dispose()
difficultyChart?.dispose()
knowledgeChart?.dispose()
timeChart?.dispose()
// 移除事件监听
window.removeEventListener('resize', handleResize)
})
/**
* 窗口大小改变处理
*/
const handleResize = () => {
distributionChart?.resize()
difficultyChart?.resize()
knowledgeChart?.resize()
timeChart?.resize()
}
</script>
<style lang="scss" scoped>
.statistics-container {
padding: 24px;
background: #f5f7fa;
min-height: calc(100vh - 60px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0;
}
.header-filters {
display: flex;
gap: 12px;
}
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.loading-container,
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
p {
margin-top: 16px;
color: #909399;
font-size: 14px;
}
}
.metrics-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 24px;
.metric-card {
.metric-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.metric-name {
font-size: 14px;
color: #606266;
}
.info-icon {
color: #909399;
cursor: pointer;
&:hover {
color: #409eff;
}
}
}
.metric-value {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.metric-compare {
font-size: 12px;
color: #909399;
.compare-value {
margin-left: 4px;
&.up {
color: #67c23a;
}
&.down {
color: #f56c6c;
}
}
}
}
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 16px;
margin-bottom: 24px;
.chart-card {
.chart-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0 0 16px 0;
}
.chart-container {
width: 100%;
height: 300px;
}
}
}
.detail-section {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.section-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0;
}
}
}
</style>

View File

@@ -0,0 +1,478 @@
<template>
<div class="dashboard-container">
<!-- 欢迎卡片 -->
<div class="welcome-card card">
<div class="welcome-content">
<h1 class="welcome-title">欢迎回来{{ userName }}</h1>
<p class="welcome-desc">今天是您学习的第 <span class="highlight">{{ learningDays }}</span> 继续加油</p>
</div>
<div class="welcome-image">
<el-icon :size="120" color="#667eea">
<TrophyBase />
</el-icon>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card card" v-for="stat in stats" :key="stat.title">
<div class="stat-icon" :style="{ backgroundColor: stat.bgColor }">
<el-icon :size="24" :color="stat.color">
<component :is="stat.icon" />
</el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-title">{{ stat.title }}</div>
<div class="stat-trend" :class="stat.trend > 0 ? 'up' : 'down'">
<el-icon :size="12">
<component :is="stat.trend > 0 ? 'Top' : 'Bottom'" />
</el-icon>
{{ Math.abs(stat.trend) }}%
</div>
</div>
</div>
</div>
<!-- 快捷操作 -->
<div class="quick-actions">
<h2 class="section-title">快捷操作</h2>
<div class="action-grid">
<div class="action-card card" v-for="action in quickActions" :key="action.title" @click="handleAction(action)">
<el-icon :size="32" :color="action.color">
<component :is="action.icon" />
</el-icon>
<div class="action-title">{{ action.title }}</div>
<div class="action-desc">{{ action.desc }}</div>
</div>
</div>
</div>
<!-- 最近考试 -->
<div class="recent-exams">
<h2 class="section-title">最近考试</h2>
<div v-if="recentExams.length > 0" class="exam-list">
<div class="exam-item card" v-for="exam in recentExams" :key="exam.id">
<div class="exam-info">
<h3 class="exam-title">{{ exam.title }}</h3>
<div class="exam-meta">
<span class="meta-item">
<el-icon><Clock /></el-icon>
{{ exam.time }}
</span>
<span class="meta-item">
<el-icon><Document /></el-icon>
{{ exam.questions }}
</span>
<span class="meta-item">
<el-icon><Reading /></el-icon>
{{ exam.courseName }}
</span>
<span v-if="exam.score != null" class="meta-item">
<el-icon><TrendCharts /></el-icon>
得分: {{ exam.score }}
</span>
</div>
</div>
<div class="exam-actions">
<el-button type="primary" size="small" @click="startExam(exam)">
开始考试
</el-button>
</div>
</div>
</div>
<div v-else class="empty-exams">
<el-empty description="暂无考试记录" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { authManager } from '@/utils/auth'
import { getUserStatistics, getRecentExams } from '@/api/dashboard'
const router = useRouter()
// 获取当前用户信息
const currentUser = computed(() => authManager.getCurrentUser())
const userName = computed(() => currentUser.value?.full_name || currentUser.value?.username || '用户')
const learningDays = ref(0)
// 统计数据
const stats = ref([
{
title: '完成考试',
value: '0',
icon: 'Document',
color: '#667eea',
bgColor: 'rgba(102, 126, 234, 0.1)',
trend: 0
},
{
title: '练习题数',
value: '0',
icon: 'Edit',
color: '#f56c6c',
bgColor: 'rgba(245, 108, 108, 0.1)',
trend: 0
},
{
title: '平均得分',
value: '0',
icon: 'TrendCharts',
color: '#e6a23c',
bgColor: 'rgba(230, 162, 60, 0.1)',
trend: 0
},
{
title: '学习时长',
value: '0h',
icon: 'Timer',
color: '#67c23a',
bgColor: 'rgba(103, 194, 58, 0.1)',
trend: 0
}
])
// 加载统计数据
const loadStatistics = async () => {
try {
const res = await getUserStatistics()
if (res.code === 200 && res.data) {
// 更新统计卡片
stats.value[0].value = String(res.data.examsCompleted || 0)
stats.value[1].value = String(res.data.practiceQuestions || 0)
stats.value[2].value = String(res.data.averageScore || 0)
stats.value[3].value = `${res.data.totalHours || 0}h`
// 更新学习天数
learningDays.value = res.data.learningDays || 0
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
// 快捷操作
const quickActions = ref([
{
title: '智能工牌分析',
desc: 'AI能力评估与成长路径规划',
icon: 'TrendCharts',
color: '#e6a23c',
path: '/trainee/growth-path'
},
{
title: '课程中心',
desc: '查看可用课程',
icon: 'Collection',
color: '#67c23a',
path: '/trainee/course-center'
},
{
title: '查分中心',
desc: '查看成绩和分析报告',
icon: 'DataAnalysis',
color: '#409eff',
path: '/trainee/score-report'
},
{
title: 'AI陪练',
desc: '智能陪练系统',
icon: 'ChatLineRound',
color: '#f56c6c',
path: '/trainee/ai-practice-center'
}
])
// 最近考试
const recentExams = ref<any[]>([])
// 加载最近考试
const loadRecentExams = async () => {
try {
const res = await getRecentExams(3)
if (res.code === 200 && res.data) {
recentExams.value = res.data
}
} catch (error) {
console.error('加载最近考试失败:', error)
}
}
/**
* 处理快捷操作点击
*/
const handleAction = (action: any) => {
router.push(action.path)
}
/**
* 开始考试
*/
const startExam = (exam: any) => {
router.push({
path: '/trainee/exam',
query: { courseId: exam.courseId }
})
}
// 页面挂载时加载数据
onMounted(() => {
loadStatistics()
loadRecentExams()
})
</script>
<style lang="scss" scoped>
.dashboard-container {
max-width: 1400px;
margin: 0 auto;
}
// 欢迎卡片
.welcome-card {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
margin-bottom: 24px;
padding: 32px;
.welcome-content {
.welcome-title {
font-size: 28px;
font-weight: 600;
margin-bottom: 12px;
}
.welcome-desc {
font-size: 16px;
opacity: 0.9;
.highlight {
font-size: 24px;
font-weight: 600;
color: #ffd700;
}
}
}
.welcome-image {
opacity: 0.8;
}
}
// 统计卡片
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 32px;
.stat-card {
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-content {
flex: 1;
.stat-value {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.stat-title {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.stat-trend {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
&.up {
color: #67c23a;
background-color: rgba(103, 194, 58, 0.1);
}
&.down {
color: #f56c6c;
background-color: rgba(245, 108, 108, 0.1);
}
}
}
}
}
// 通用标题
.section-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
// 快捷操作
.quick-actions {
margin-bottom: 32px;
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
.action-card {
text-align: center;
padding: 32px 24px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
.action-title {
color: #667eea;
}
}
.action-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 12px 0 8px;
transition: color 0.3s;
}
.action-desc {
font-size: 14px;
color: #666;
}
}
}
}
// 最近考试
.recent-exams {
.exam-list {
display: flex;
flex-direction: column;
gap: 16px;
.exam-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.exam-info {
.exam-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.exam-meta {
display: flex;
gap: 20px;
flex-wrap: wrap;
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #666;
.el-icon {
color: #999;
}
}
}
}
.exam-actions {
.el-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
}
}
}
}
.empty-exams {
padding: 40px 20px;
text-align: center;
}
}
// 响应式
@media (max-width: 768px) {
.welcome-card {
flex-direction: column;
text-align: center;
.welcome-image {
margin-top: 20px;
}
}
.stats-grid {
grid-template-columns: 1fr;
}
.action-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.exam-item {
flex-direction: column;
align-items: flex-start !important;
gap: 16px;
.exam-actions {
width: 100%;
.el-button {
width: 100%;
}
}
}
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<div class="error-page">
<div class="error-content">
<div class="error-icon">
<el-icon :size="120" color="#909399">
<Warning />
</el-icon>
</div>
<h1 class="error-title">404</h1>
<p class="error-desc">抱歉您访问的页面不存在</p>
<p class="error-tips">请检查您输入的网址是否正确或点击下方按钮返回首页</p>
<div class="error-actions">
<el-button @click="goBack">返回上一页</el-button>
<el-button type="primary" @click="goHome">返回首页</el-button>
</div>
<div class="error-illustration">
<svg viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.8">
<circle cx="200" cy="150" r="100" fill="#f5f7fa"/>
<path d="M150 120 Q200 100, 250 120" stroke="#909399" stroke-width="3" fill="none"/>
<circle cx="170" cy="130" r="10" fill="#909399"/>
<circle cx="230" cy="130" r="10" fill="#909399"/>
<path d="M170 180 Q200 160, 230 180" stroke="#909399" stroke-width="3" fill="none"/>
</g>
</svg>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
/**
* 返回上一页
*/
const goBack = () => {
router.back()
}
/**
* 返回首页
*/
const goHome = () => {
router.push('/dashboard')
}
</script>
<style lang="scss" scoped>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
.error-content {
text-align: center;
max-width: 500px;
.error-icon {
margin-bottom: 24px;
animation: shake 2s ease-in-out infinite;
}
.error-title {
font-size: 120px;
font-weight: 700;
color: #333;
margin: 0;
line-height: 1;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.error-desc {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 24px 0 16px;
}
.error-tips {
font-size: 16px;
color: #666;
margin-bottom: 32px;
}
.error-actions {
display: flex;
gap: 16px;
justify-content: center;
margin-bottom: 48px;
.el-button {
min-width: 120px;
&--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
.error-illustration {
max-width: 300px;
margin: 0 auto;
opacity: 0.6;
svg {
width: 100%;
height: auto;
}
}
}
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-5px);
}
20%, 40%, 60%, 80% {
transform: translateX(5px);
}
}
// 响应式
@media (max-width: 768px) {
.error-page {
.error-content {
.error-title {
font-size: 80px;
}
.error-desc {
font-size: 20px;
}
.error-actions {
flex-direction: column;
.el-button {
width: 100%;
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,367 @@
<template>
<div class="login-container">
<div class="login-bg">
<div class="bg-shape bg-shape-1"></div>
<div class="bg-shape bg-shape-2"></div>
<div class="bg-shape bg-shape-3"></div>
</div>
<div class="login-card">
<div class="login-header">
<div class="logo">
<el-icon :size="48" color="#667eea">
<Notebook />
</el-icon>
</div>
<h1 class="title">考培练系统</h1>
<p class="subtitle">让学习更高效让进步看得见</p>
</div>
<el-form ref="formRef" :model="loginForm" :rules="rules" class="login-form">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
size="large"
clearable
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
@keyup.enter="handleLogin"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<div class="login-options">
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
<el-link type="primary" :underline="false" @click="forgotPassword">
忘记密码
</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
</el-button>
</el-form-item>
<div class="other-login">
<el-divider>其他登录方式</el-divider>
<div class="social-icons">
<div class="social-icon" @click="socialLogin('wechat')">
<el-icon :size="20"><ChatDotRound /></el-icon>
</div>
<div class="social-icon" @click="socialLogin('qq')">
<el-icon :size="20"><Connection /></el-icon>
</div>
<div class="social-icon" @click="socialLogin('github')">
<el-icon :size="20"><Link /></el-icon>
</div>
</div>
</div>
<div class="register-link">
还没有账号
<el-link type="primary" :underline="false" @click="goRegister">
立即注册
</el-link>
</div>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { login } from '@/api/auth'
import { authManager } from '@/utils/auth'
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
// 登录表单
const loginForm = reactive({
username: '',
password: '',
remember: false
})
// 表单验证规则
const rules = reactive<FormRules>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于 6 个字符', trigger: 'blur' }
]
})
/**
* 登录
*/
const handleLogin = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
// 调用真实的登录API
const response = await login({
username: loginForm.username,
password: loginForm.password
})
if (response.code === 200) {
// 使用authManager保存认证信息
authManager.setAccessToken(response.data.token.access_token)
authManager.setRefreshToken(response.data.token.refresh_token)
// 添加缺少的字段
const userInfo = {
...response.data.user,
created_at: response.data.user.created_at || new Date().toISOString(),
updated_at: response.data.user.updated_at || new Date().toISOString()
}
authManager.setCurrentUser(userInfo)
ElMessage.success('登录成功')
// 跳转到用户默认页面或指定的重定向页面
const redirect = new URLSearchParams(window.location.search).get('redirect') || authManager.getDefaultRoute()
router.push(redirect)
} else {
ElMessage.error(response.message || '登录失败')
}
} catch (error: any) {
console.error('登录错误:', error)
ElMessage.error(error.message || '登录失败,请稍后重试')
} finally {
loading.value = false
}
}
})
}
/**
* 忘记密码
*/
const forgotPassword = () => {
ElMessage.info('请联系管理员重置密码')
}
/**
* 社交登录
*/
const socialLogin = (type: string) => {
ElMessage.info(`${type} 登录功能开发中`)
}
/**
* 去注册
*/
const goRegister = () => {
ElMessage.info('注册功能开发中')
}
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
position: relative;
overflow: hidden;
.login-bg {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
.bg-shape {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.6;
animation: float 20s infinite ease-in-out;
&-1 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
top: -200px;
right: -100px;
}
&-2 {
width: 300px;
height: 300px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
bottom: -150px;
left: -100px;
animation-delay: -5s;
}
&-3 {
width: 350px;
height: 350px;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -10s;
}
}
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
padding: 48px;
width: 420px;
position: relative;
z-index: 1;
.login-header {
text-align: center;
margin-bottom: 40px;
.logo {
margin-bottom: 16px;
}
.title {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #666;
}
}
.login-form {
.login-options {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
letter-spacing: 2px;
}
.other-login {
margin-top: 32px;
:deep(.el-divider__text) {
color: #999;
font-size: 13px;
}
.social-icons {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 24px;
.social-icon {
width: 44px;
height: 44px;
border: 1px solid #e4e7ed;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: #667eea;
color: #667eea;
transform: translateY(-2px);
}
}
}
}
.register-link {
text-align: center;
margin-top: 24px;
font-size: 14px;
color: #666;
}
}
}
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
33% {
transform: translateY(-30px) rotate(120deg);
}
66% {
transform: translateY(30px) rotate(240deg);
}
}
// 响应式
@media (max-width: 768px) {
.login-container {
padding: 20px;
.login-card {
width: 100%;
max-width: 400px;
padding: 32px 24px;
}
}
}
</style>

View File

@@ -0,0 +1,769 @@
<template>
<div class="assignment-center-container">
<div class="page-header">
<h1 class="page-title">任务中心</h1>
<el-button type="primary" @click="createTask">
<el-icon class="el-icon--left"><Plus /></el-icon>
创建任务
</el-button>
</div>
<!-- 任务统计 -->
<div class="task-stats">
<div class="stat-card card" v-for="stat in taskStats" :key="stat.label">
<div class="stat-icon" :style="{ backgroundColor: stat.bgColor }">
<el-icon :size="24" :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>
</div>
</div>
<!-- 任务列表 -->
<div class="task-section">
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="进行中" name="ongoing">
<span slot="label">
进行中 <el-badge :value="12" class="tab-badge" />
</span>
</el-tab-pane>
<el-tab-pane label="待开始" name="pending">
<span slot="label">
待开始 <el-badge :value="5" class="tab-badge" />
</span>
</el-tab-pane>
<el-tab-pane label="已完成" name="completed">
<span slot="label">
已完成 <el-badge :value="28" class="tab-badge" />
</span>
</el-tab-pane>
<el-tab-pane label="已过期" name="expired">
<span slot="label">
已过期 <el-badge :value="3" class="tab-badge" />
</span>
</el-tab-pane>
</el-tabs>
<!-- 任务卡片列表 -->
<div class="task-list">
<div class="task-card card" v-for="task in taskList" :key="task.id">
<div class="task-header">
<div class="task-title-section">
<h3 class="task-title">{{ task.title }}</h3>
<el-tag :type="getTaskTagType(task.priority)" size="small">
{{ task.priority }}
</el-tag>
</div>
<el-dropdown trigger="click">
<el-button link>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="editTask(task)">
<el-icon><Edit /></el-icon>
编辑任务
</el-dropdown-item>
<el-dropdown-item @click="copyTask(task)">
<el-icon><CopyDocument /></el-icon>
复制任务
</el-dropdown-item>
<el-dropdown-item @click="endTask(task)" v-if="task.status === 'ongoing'">
<el-icon><CircleCheck /></el-icon>
结束任务
</el-dropdown-item>
<el-dropdown-item @click="deleteTaskItem(task)" divided>
<el-icon><Delete /></el-icon>
删除任务
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="task-content">
<p class="task-desc">{{ task.description }}</p>
<div class="task-info">
<div class="info-item">
<el-icon><User /></el-icon>
<span>分配人数{{ task.assigned_count }} </span>
</div>
<div class="info-item">
<el-icon><Calendar /></el-icon>
<span>截止时间{{ formatDeadline(task.deadline) }}</span>
</div>
<div class="info-item">
<el-icon><Collection /></el-icon>
<span>包含课程{{ task.courses.length }}</span>
</div>
</div>
<div class="task-courses">
<el-tag v-for="course in task.courses.slice(0, 3)" :key="course" size="small">
{{ course }}
</el-tag>
<el-tag v-if="task.courses.length > 3" type="info" size="small">
+{{ task.courses.length - 3 }}
</el-tag>
</div>
<div class="task-progress">
<div class="progress-header">
<span class="progress-label">完成进度</span>
<span class="progress-text">{{ task.completed_count }}/{{ task.assigned_count }} 人完成</span>
</div>
<el-progress :percentage="task.progress" :color="getProgressColor(task.progress)" />
</div>
</div>
<div class="task-footer">
<el-button size="small" @click="viewDetail(task)">查看详情</el-button>
<el-button type="primary" size="small" @click="sendReminder(task)">
发送提醒
</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty v-if="taskList.length === 0" description="暂无任务" />
</div>
<!-- 创建任务弹窗 -->
<el-dialog
v-model="createDialogVisible"
title="创建学习任务"
width="680px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="taskForm" :rules="rules" label-width="100px">
<el-form-item label="任务名称" prop="title">
<el-input v-model="taskForm.title" placeholder="请输入任务名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="任务描述" prop="description">
<el-input
v-model="taskForm.description"
type="textarea"
placeholder="请输入任务描述"
:rows="3"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-radio-group v-model="taskForm.priority">
<el-radio label="高">高优先级</el-radio>
<el-radio label="中">中优先级</el-radio>
<el-radio label="低">低优先级</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="分配对象" prop="assignType">
<el-radio-group v-model="taskForm.assignType" @change="handleAssignTypeChange">
<el-radio label="all">全体成员</el-radio>
<el-radio label="team">指定团队</el-radio>
<el-radio label="member">指定成员</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="taskForm.assignType === 'team'" label="选择团队" prop="teams">
<el-select v-model="taskForm.teams" multiple placeholder="请选择团队" style="width: 100%">
<el-option label="销售一组" value="team1" />
<el-option label="销售二组" value="team2" />
<el-option label="销售三组" value="team3" />
</el-select>
</el-form-item>
<el-form-item v-if="taskForm.assignType === 'member'" label="选择成员" prop="members">
<el-select v-model="taskForm.members" multiple placeholder="请选择成员" style="width: 100%">
<el-option label="张三" value="user1" />
<el-option label="李四" value="user2" />
<el-option label="王五" value="user3" />
<el-option label="赵六" value="user4" />
</el-select>
</el-form-item>
<el-form-item label="选择课程" prop="courses">
<el-select v-model="taskForm.courses" multiple placeholder="请选择课程" style="width: 100%">
<el-option-group label="销售技巧">
<el-option label="客户沟通技巧" value="course1" />
<el-option label="需求挖掘方法" value="course2" />
<el-option label="异议处理技巧" value="course3" />
</el-option-group>
<el-option-group label="产品知识">
<el-option label="产品基础知识" value="course4" />
<el-option label="竞品分析" value="course5" />
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="截止时间" prop="deadline">
<el-date-picker
v-model="taskForm.deadline"
type="datetime"
placeholder="请选择截止时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="任务要求" prop="requirements">
<el-checkbox-group v-model="taskForm.requirements">
<el-checkbox label="mustComplete">必须完成所有课程</el-checkbox>
<el-checkbox label="mustPass">考试必须及格</el-checkbox>
<el-checkbox label="mustPractice">必须完成AI陪练</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCreateTask" :loading="createLoading">
确定
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { getTasks, getTaskStats, createTask as createTaskApi, deleteTask, type Task } from '@/api/task'
// 当前标签页
const activeTab = ref('ongoing')
// 创建任务弹窗
const createDialogVisible = ref(false)
const formRef = ref<FormInstance>()
const createLoading = ref(false)
const loading = ref(false)
// 任务统计数据
const taskStats = ref([
{
label: '总任务数',
value: '0',
icon: 'Tickets',
color: '#667eea',
bgColor: 'rgba(102, 126, 234, 0.1)'
},
{
label: '进行中',
value: '0',
icon: 'Timer',
color: '#e6a23c',
bgColor: 'rgba(230, 162, 60, 0.1)'
},
{
label: '已完成',
value: '0',
icon: 'CircleCheck',
color: '#67c23a',
bgColor: 'rgba(103, 194, 58, 0.1)'
},
{
label: '平均完成率',
value: '0%',
icon: 'DataLine',
color: '#409eff',
bgColor: 'rgba(64, 158, 255, 0.1)'
}
])
// 任务列表数据
const allTasks = ref<Task[]>([])
// 任务表单
const taskForm = reactive({
title: '',
description: '',
priority: '中',
assignType: 'all',
teams: [],
members: [],
courses: [],
deadline: '',
requirements: ['mustComplete']
})
// 表单验证规则
const rules = reactive<FormRules>({
title: [
{ required: true, message: '请输入任务名称', trigger: 'blur' }
],
description: [
{ required: true, message: '请输入任务描述', trigger: 'blur' }
],
priority: [
{ required: true, message: '请选择优先级', trigger: 'change' }
],
courses: [
{ required: true, message: '请选择课程', trigger: 'change' }
],
deadline: [
{ required: true, message: '请选择截止时间', trigger: 'change' }
]
})
// 根据当前标签页筛选的任务列表
const taskList = computed(() => {
if (activeTab.value === 'ongoing') {
return allTasks.value
}
return allTasks.value.filter(task => task.status === activeTab.value)
})
/**
* 加载任务统计数据
*/
const loadTaskStats = async () => {
try {
const res = await getTaskStats()
if (res.code === 200 && res.data) {
const stats = res.data
taskStats.value[0].value = stats.total.toString()
taskStats.value[1].value = stats.ongoing.toString()
taskStats.value[2].value = stats.completed.toString()
taskStats.value[3].value = stats.avg_completion_rate.toFixed(1) + '%'
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
/**
* 加载任务列表
*/
const loadTasks = async () => {
loading.value = true
try {
const status = activeTab.value === 'ongoing' ? 'ongoing' : activeTab.value
const res = await getTasks({ status })
if (res.code === 200 && res.data) {
allTasks.value = res.data.items
}
} catch (error: any) {
console.error('加载任务列表失败:', error)
ElMessage.error(error.message || '加载任务列表失败')
} finally {
loading.value = false
}
}
/**
* 标签页切换
*/
const handleTabClick = async () => {
await loadTasks()
}
/**
* 格式化截止时间
*/
const formatDeadline = (deadline?: string) => {
if (!deadline) return '无截止时间'
const date = new Date(deadline)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* 创建任务
*/
const createTask = () => {
createDialogVisible.value = true
}
/**
* 分配类型改变
*/
const handleAssignTypeChange = () => {
taskForm.teams = []
taskForm.members = []
}
/**
* 提交创建任务
*/
const handleCreateTask = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
createLoading.value = true
try {
// 构建请求数据
const taskData = {
title: taskForm.title,
description: taskForm.description,
priority: taskForm.priority.toLowerCase(),
deadline: taskForm.deadline,
course_ids: taskForm.courses,
user_ids: taskForm.assignType === 'all' ? [] : taskForm.members,
requirements: {
mustComplete: taskForm.requirements.includes('mustComplete'),
allowRetake: taskForm.requirements.includes('allowRetake')
}
}
const res = await createTaskApi(taskData)
if (res.code === 200) {
ElMessage.success('任务创建成功')
createDialogVisible.value = false
formRef.value?.resetFields()
// 刷新数据
await loadTaskStats()
await loadTasks()
} else {
ElMessage.error(res.message || '创建任务失败')
}
} catch (error: any) {
console.error('创建任务失败:', error)
ElMessage.error(error.message || '创建任务失败')
} finally {
createLoading.value = false
}
}
})
}
/**
* 查看详情
*/
const viewDetail = (task: any) => {
ElMessage.info(`查看任务详情:${task.title}`)
}
/**
* 发送提醒
*/
const sendReminder = (_task: any) => {
ElMessageBox.confirm(
`确定要向未完成的成员发送任务提醒吗?`,
'发送提醒',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}
).then(() => {
ElMessage.success('提醒发送成功')
}).catch(() => {})
}
/**
* 编辑任务
*/
const editTask = async (task: Task) => {
// 这里可以打开编辑对话框填充task数据
// 简化实现:直接提示
ElMessage.info(`编辑任务功能开发中:${task.title}`)
// TODO: 实现完整的编辑功能
}
/**
* 复制任务
*/
const copyTask = (task: any) => {
ElMessage.success(`已复制任务:${task.title}`)
}
/**
* 结束任务
*/
const endTask = async (_task: Task) => {
try {
await ElMessageBox.confirm(
'确定要结束这个任务吗?结束后将不能再修改。',
'结束任务',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
ElMessage.success('任务已结束')
} catch {}
}
/**
* 删除任务
*/
const deleteTaskItem = async (task: Task) => {
try {
await ElMessageBox.confirm(
`确定要删除任务"${task.title}"吗?此操作不可撤销。`,
'删除任务',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const res = await deleteTask(task.id)
if (res.code === 200) {
ElMessage.success('任务已删除')
// 刷新数据
await loadTaskStats()
await loadTasks()
} else {
ElMessage.error(res.message || '删除任务失败')
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除任务失败:', error)
ElMessage.error(error.message || '删除任务失败')
}
}
}
/**
* 获取任务标签类型
*/
const getTaskTagType = (priority: string) => {
const typeMap: Record<string, string> = {
'高': 'danger',
'中': 'warning',
'低': 'info'
}
return typeMap[priority] || ''
}
/**
* 获取进度颜色
*/
const getProgressColor = (percentage: number) => {
if (percentage >= 80) return '#67c23a'
if (percentage >= 60) return '#409eff'
if (percentage >= 40) return '#e6a23c'
return '#f56c6c'
}
// 组件挂载时加载数据
onMounted(async () => {
await loadTaskStats()
await loadTasks()
})
</script>
<style lang="scss" scoped>
.assignment-center-container {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
.el-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
.task-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 24px;
.stat-card {
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-content {
.stat-value {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #666;
}
}
}
}
.task-section {
:deep(.el-tabs) {
.el-tabs__header {
margin-bottom: 24px;
}
.tab-badge {
margin-left: 8px;
.el-badge__content {
height: 18px;
line-height: 18px;
padding: 0 6px;
}
}
}
.task-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
.task-card {
padding: 20px;
display: flex;
flex-direction: column;
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
.task-title-section {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
.task-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
}
}
.task-content {
flex: 1;
.task-desc {
font-size: 14px;
color: #666;
line-height: 1.5;
margin-bottom: 16px;
}
.task-info {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
.info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #909399;
.el-icon {
color: #c0c4cc;
}
}
}
.task-courses {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.task-progress {
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
.progress-label {
color: #666;
}
.progress-text {
color: #909399;
}
}
}
}
.task-footer {
display: flex;
gap: 12px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ebeef5;
.el-button {
flex: 1;
&--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
}
}
}
}
// 响应式
@media (max-width: 768px) {
.assignment-center-container {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.el-button {
width: 100%;
}
}
.task-stats {
grid-template-columns: 1fr;
}
.task-list {
grid-template-columns: 1fr !important;
}
}
}
</style>

View File

@@ -0,0 +1,646 @@
<template>
<div class="course-management-container">
<div class="page-header">
<h1 class="page-title">课程管理</h1>
<div class="header-actions">
<el-button type="primary" @click="createCourse">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建课程
</el-button>
</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"
/>
</el-form-item>
<el-form-item label="课程分类">
<el-select
v-model="filterForm.category"
placeholder="全部分类"
clearable
@change="handleRealTimeSearch"
style="width: 120px"
>
<el-option label="技术" value="technology" />
<el-option label="管理" value="management" />
<el-option label="业务/产品" value="business" />
<el-option label="通用" value="general" />
</el-select>
</el-form-item>
<el-form-item label="课程状态">
<el-select
v-model="filterForm.status"
placeholder="全部状态"
clearable
@change="handleRealTimeSearch"
style="width: 120px"
>
<el-option label="已发布" value="published" />
<el-option label="草稿" value="draft" />
<el-option label="已归档" value="archived" />
</el-select>
</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.category"
closable
@close="clearCategory"
type="success"
>
分类{{ getCategoryText(filterForm.category) }}
</el-tag>
<el-tag
v-if="filterForm.status"
closable
@close="clearStatus"
type="warning"
>
状态{{ getStatusText(filterForm.status) }}
</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>{{ pagination.total }}</strong> 门课程
<span v-if="hasActiveFilters" class="filter-hint">已筛选</span>
</span>
</div>
</div>
<!-- 课程列表 -->
<div class="course-list-section card">
<div class="section-header">
<h3>课程列表</h3>
<div class="view-controls">
<el-button-group>
<el-button :type="viewMode === 'grid' ? 'primary' : ''" @click="viewMode = 'grid'">
<el-icon><Grid /></el-icon>
</el-button>
<el-button :type="viewMode === 'list' ? 'primary' : ''" @click="viewMode = 'list'">
<el-icon><List /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
<!-- 网格视图 -->
<div v-if="viewMode === 'grid'">
<div v-if="paginatedCourses.length > 0" class="course-grid">
<div v-for="course in paginatedCourses" :key="course.id" class="course-card">
<div class="course-info">
<div class="course-header">
<h4 class="course-title">{{ course.name }}</h4>
<el-tag :type="getStatusTagType(course.status)" size="small">
{{ getStatusText(course.status) }}
</el-tag>
</div>
<p class="course-description">{{ course.description }}</p>
<div class="course-meta">
<span class="meta-item">
<el-icon><User /></el-icon>
{{ course.studentCount || 0 }} 人学习
</span>
<span class="meta-item">
<el-icon><Clock /></el-icon>
{{ course.duration || '待定' }}
</span>
<span class="meta-item">
<el-tag type="info" size="small">{{ getCategoryText(course.category) }}</el-tag>
</span>
</div>
<div class="course-actions">
<el-button size="small" @click="viewCourseDetail(course)">查看详情</el-button>
<el-button size="small" type="primary" @click="editCourse(course)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCourse(course)">删除</el-button>
</div>
</div>
</div>
</div>
<!-- 空状态显示 -->
<div v-else class="empty-state">
<el-empty
:description="hasActiveFilters ? '没有符合条件的课程' : '暂无课程数据'"
:image-size="120"
>
<el-button v-if="hasActiveFilters" type="primary" @click="handleReset">
清空筛选条件
</el-button>
<el-button v-else type="primary" @click="createCourse">
创建第一门课程
</el-button>
</el-empty>
</div>
</div>
<!-- 列表视图 -->
<div v-if="viewMode === 'list'">
<el-table v-if="paginatedCourses.length > 0" :data="paginatedCourses" style="width: 100%">
<el-table-column prop="name" label="课程名称" width="200" />
<el-table-column prop="category" label="分类" width="120">
<template #default="scope">
{{ getCategoryText(scope.row.category) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusTagType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="studentCount" label="学习人数" width="100" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" @click="viewCourseDetail(scope.row)">详情</el-button>
<el-button size="small" type="primary" @click="editCourse(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCourse(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 列表视图空状态显示 -->
<div v-else class="empty-state">
<el-empty
:description="hasActiveFilters ? '没有符合条件的课程' : '暂无课程数据'"
:image-size="120"
>
<el-button v-if="hasActiveFilters" type="primary" @click="handleReset">
清空筛选条件
</el-button>
<el-button v-else type="primary" @click="createCourse">
创建第一门课程
</el-button>
</el-empty>
</div>
</div>
<!-- 分页 -->
<div v-if="filteredCourses.length > 0" class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[6, 12, 24, 48]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Grid,
List,
User,
Clock,
Refresh,
} from '@element-plus/icons-vue'
import { courseApi, type Course } from '@/api/course'
const router = useRouter()
// 响应式数据
const viewMode = ref('grid')
const filterForm = reactive({
keyword: '',
category: '',
status: ''
})
const pagination = reactive({
currentPage: 1,
pageSize: 6,
total: 0
})
// 课程数据(来自真实后端)
const courses = ref<any[]>([])
// 加载课程列表(真实查询后端)
async function fetchCourses() {
try {
const params: any = {
page: pagination.currentPage,
size: pagination.pageSize,
}
if (filterForm.keyword) params.keyword = filterForm.keyword
if (filterForm.category) params.category = filterForm.category
if (filterForm.status) params.status = filterForm.status
const res = await courseApi.list(params)
if (res.code === 200) {
const data = res.data
pagination.total = data.total || 0
// 映射后端字段到页面展示字段
courses.value = (data.items || []).map((c: Course) => ({
id: c.id,
name: c.name,
description: c.description || '',
category: c.category,
status: c.status,
// 后端为 duration_hours这里转为可读
duration: c.duration_hours ? `${c.duration_hours}小时` : undefined,
studentCount: c.student_count || 0,
isNew: c.is_new || false,
createTime: c.created_at?.replace('T', ' ').substring(0, 19)
}))
} else {
ElMessage.error(res.message || '加载课程失败')
}
} catch (error: any) {
console.error('获取课程列表失败:', error)
ElMessage.error(error.message || '获取课程列表失败')
}
}
// 来自服务端分页的当前页数据
const filteredCourses = computed(() => courses.value)
// 当前页显示的课程数据(服务端已分页,直接返回)
const paginatedCourses = computed(() => filteredCourses.value)
// 是否有活跃的筛选条件
const hasActiveFilters = computed(() => {
return !!(filterForm.keyword || filterForm.category || filterForm.status)
})
// 方法
/**
* 创建新课程
*/
const createCourse = () => {
router.push('/manager/create-course')
}
/**
* 实时搜索处理
*/
const handleRealTimeSearch = () => {
pagination.currentPage = 1
fetchCourses()
}
/**
* 重置搜索条件
*/
const handleReset = () => {
filterForm.keyword = ''
filterForm.category = ''
filterForm.status = ''
pagination.currentPage = 1
ElMessage.success('已重置所有筛选条件')
fetchCourses()
}
/**
* 清除关键词筛选
*/
const clearKeyword = () => {
filterForm.keyword = ''
pagination.currentPage = 1
fetchCourses()
}
/**
* 清除分类筛选
*/
const clearCategory = () => {
filterForm.category = ''
pagination.currentPage = 1
fetchCourses()
}
/**
* 清除状态筛选
*/
const clearStatus = () => {
filterForm.status = ''
pagination.currentPage = 1
fetchCourses()
}
/**
* 查看课程详情
*/
const viewCourseDetail = (course: any) => {
ElMessageBox.alert(
`
<div style="text-align: left; line-height: 1.6;">
<h4 style="margin: 0 0 10px 0; color: #409eff;">${course.name}</h4>
<p><strong>分类:</strong>${getCategoryText(course.category)}</p>
<p><strong>状态:</strong>${getStatusText(course.status)}</p>
<p><strong>学习人数:</strong>${course.studentCount} 人</p>
<p><strong>课程时长:</strong>${course.duration}</p>
<p><strong>创建时间:</strong>${course.createTime}</p>
<p><strong>课程描述:</strong></p>
<p style="color: #666; margin-left: 10px;">${course.description}</p>
</div>
`,
'课程详情',
{
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true
}
)
}
/**
* 编辑课程
*/
const editCourse = (course: any) => {
router.push('/manager/edit-course/' + course.id)
}
/**
* 删除课程
*/
const deleteCourse = (course: any) => {
ElMessageBox.confirm(
'确定要删除课程"' + course.name + '"吗?删除后将无法恢复。',
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(async () => {
try {
const res = await courseApi.delete(course.id)
if (res && res.code === 200 && res.data === true) {
ElMessage.success('课程"' + course.name + '"已删除')
if (courses.value.length === 1 && pagination.currentPage > 1) {
pagination.currentPage -= 1
}
fetchCourses()
} else {
throw new Error(res?.message || '删除失败')
}
} catch (err: any) {
const msg = err?.message || '删除课程失败'
ElMessage.error(msg)
}
})
.catch((action: any) => {
if (action === 'cancel' || action === 'close') {
ElMessage.info('已取消删除')
}
})
}
const getCategoryText = (category: string) => {
const map: Record<string, string> = {
technology: '技术',
management: '管理',
business: '业务/产品',
general: '通用'
}
return map[category] || '未知'
}
const getStatusText = (status: string) => {
const map: Record<string, string> = {
published: '已发布',
draft: '草稿',
archived: '已归档'
}
return map[status] || '未知'
}
const getStatusTagType = (status: string) => {
const map: Record<string, string> = {
published: 'success',
draft: 'warning',
archived: 'info'
}
return map[status] || 'info'
}
/**
* 分页大小改变
*/
const handleSizeChange = (val: number) => {
pagination.pageSize = val
pagination.currentPage = 1
ElMessage.success('每页显示 ' + val + ' 条记录')
fetchCourses()
}
/**
* 当前页码改变
*/
const handleCurrentChange = (val: number) => {
pagination.currentPage = val
fetchCourses()
}
// 初始化加载
fetchCourses()
</script>
<style scoped>
.course-management-container {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0;
}
.card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.filter-form {
display: flex;
flex-wrap: wrap;
gap: 16px;
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;
}
.result-count strong {
color: #409eff;
font-weight: 600;
}
.filter-hint {
color: #e6a23c;
font-size: 12px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.course-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.course-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.3s;
}
.course-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.course-info {
padding: 20px;
}
.course-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.course-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.course-description {
color: #666;
font-size: 14px;
margin: 0 0 12px 0;
line-height: 1.4;
}
.course-meta {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.course-actions {
display: flex;
gap: 8px;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
.empty-state {
padding: 40px 20px;
text-align: center;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,955 @@
<template>
<div class="growth-path-management-container">
<div class="page-header">
<h1 class="page-title">成长路径管理</h1>
<div class="header-actions">
<el-select v-model="selectedPosition" placeholder="选择岗位" @change="handlePositionChange">
<el-option label="全部岗位" value="" />
<el-option label="销售专员" value="sales" />
<el-option label="销售主管" value="sales_manager" />
<el-option label="客服专员" value="service" />
<el-option label="技术支持" value="tech" />
</el-select>
<el-button type="primary" @click="saveCurrentPath" :disabled="!hasChanges">
<el-icon class="el-icon--left"><DocumentChecked /></el-icon>
保存路径
</el-button>
</div>
</div>
<!-- 提示信息 -->
<el-alert
title="操作提示"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
从左侧课程库拖拽课程到右侧画布设计岗位的学习路径可以设置课程之间的前置依赖关系
</el-alert>
<div class="path-designer">
<!-- 左侧课程库 -->
<div class="course-library card">
<div class="library-header">
<h3>课程库</h3>
<el-input
v-model="searchText"
placeholder="搜索课程"
clearable
size="small"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="library-content">
<el-collapse v-model="activeNames">
<el-collapse-item title="销售技巧" name="sales">
<div class="course-list">
<div
v-for="course in filteredSalesCourses"
:key="course.id"
class="course-item"
draggable="true"
@dragstart="handleDragStart($event, course)"
>
<el-icon><Reading /></el-icon>
<span>{{ course.title }}</span>
<el-tag size="small" type="info">{{ course.duration }}分钟</el-tag>
</div>
</div>
</el-collapse-item>
<el-collapse-item title="产品知识" name="product">
<div class="course-list">
<div
v-for="course in filteredProductCourses"
:key="course.id"
class="course-item"
draggable="true"
@dragstart="handleDragStart($event, course)"
>
<el-icon><Reading /></el-icon>
<span>{{ course.title }}</span>
<el-tag size="small" type="info">{{ course.duration }}分钟</el-tag>
</div>
</div>
</el-collapse-item>
<el-collapse-item title="客户服务" name="service">
<div class="course-list">
<div
v-for="course in filteredServiceCourses"
:key="course.id"
class="course-item"
draggable="true"
@dragstart="handleDragStart($event, course)"
>
<el-icon><Reading /></el-icon>
<span>{{ course.title }}</span>
<el-tag size="small" type="info">{{ course.duration }}分钟</el-tag>
</div>
</div>
</el-collapse-item>
<el-collapse-item title="通用能力" name="general">
<div class="course-list">
<div
v-for="course in filteredGeneralCourses"
:key="course.id"
class="course-item"
draggable="true"
@dragstart="handleDragStart($event, course)"
>
<el-icon><Reading /></el-icon>
<span>{{ course.title }}</span>
<el-tag size="small" type="info">{{ course.duration }}分钟</el-tag>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
<!-- 右侧画布 -->
<div class="path-canvas card">
<div class="canvas-header">
<h3>{{ selectedPosition ? getPositionName(selectedPosition) : '请选择岗位' }} - 成长路径</h3>
<div class="canvas-actions">
<el-button link type="primary" size="small" @click="clearCanvas">
<el-icon><Delete /></el-icon>
清空画布
</el-button>
<el-button link type="primary" size="small" @click="autoLayout">
<el-icon><Grid /></el-icon>
自动布局
</el-button>
</div>
</div>
<div
class="canvas-content"
@dragover.prevent
@drop="handleDrop"
ref="canvasRef"
>
<div class="canvas-inner">
<!-- 阶段分隔线 -->
<div class="stage-divider" v-for="stage in stages" :key="stage.id" :style="{ top: stage.position + 'px' }">
<span class="stage-label">{{ stage.name }}</span>
</div>
<!-- 空状态 -->
<div v-if="pathNodes.length === 0" class="empty-state">
<el-icon :size="64" color="#c0c4cc"><FolderOpened /></el-icon>
<p>拖拽课程到这里开始设计成长路径</p>
</div>
<!-- 路径节点 -->
<div
v-for="node in pathNodes"
:key="node.id"
class="path-node"
:class="{ 'is-required': node.required }"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
@mousedown="startDrag($event, node)"
@contextmenu.prevent="showNodeMenu($event, node)"
>
<div class="node-header">
<span class="node-title">{{ node.title }}</span>
<el-dropdown trigger="click" @command="handleNodeCommand($event, node)">
<el-button link size="small">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="required">
{{ node.required ? '设为选修' : '设为必修' }}
</el-dropdown-item>
<el-dropdown-item command="dependency">设置前置课程</el-dropdown-item>
<el-dropdown-item command="delete" divided>删除节点</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="node-info">
<el-tag size="small" :type="node.required ? 'danger' : ''">
{{ node.required ? '必修' : '选修' }}
</el-tag>
<span class="node-duration">{{ node.duration }}分钟</span>
</div>
</div>
<!-- 连接线 -->
<svg class="connections" v-if="connections.length > 0">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#667eea" />
</marker>
</defs>
<path
v-for="conn in connections"
:key="`${conn.from}-${conn.to}`"
:d="getConnectionPath(conn)"
stroke="#667eea"
stroke-width="2"
fill="none"
marker-end="url(#arrowhead)"
/>
</svg>
</div>
</div>
<!-- 路径统计 -->
<div class="canvas-footer">
<div class="path-stats">
<div class="stat-item">
<span class="stat-label">课程总数</span>
<span class="stat-value">{{ pathNodes.length }}</span>
</div>
<div class="stat-item">
<span class="stat-label">必修课程</span>
<span class="stat-value">{{ requiredCount }}</span>
</div>
<div class="stat-item">
<span class="stat-label">总时长</span>
<span class="stat-value">{{ totalDuration }} 分钟</span>
</div>
<div class="stat-item">
<span class="stat-label">预计完成天数</span>
<span class="stat-value">{{ estimatedDays }} </span>
</div>
</div>
</div>
</div>
</div>
<!-- 设置前置课程弹窗 -->
<el-dialog
v-model="dependencyDialogVisible"
title="设置前置课程"
width="500px"
>
<div class="dependency-content">
<p>为课程 <strong>{{ currentNode?.title }}</strong> 设置前置课程</p>
<el-checkbox-group v-model="selectedDependencies">
<el-checkbox
v-for="node in availableDependencies"
:key="node.id"
:label="node.id"
>
{{ node.title }}
</el-checkbox>
</el-checkbox-group>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="dependencyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveDependencies">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// 选中的岗位
const selectedPosition = ref('sales')
const hasChanges = ref(false)
// 搜索文本
const searchText = ref('')
const activeNames = ref(['sales'])
// 画布相关
const canvasRef = ref()
const pathNodes = ref<any[]>([])
const connections = ref<any[]>([])
const draggedNode = ref<any>(null)
const dragOffset = ref({ x: 0, y: 0 })
// 前置课程设置
const dependencyDialogVisible = ref(false)
const currentNode = ref<any>(null)
const selectedDependencies = ref<string[]>([])
// 阶段分隔线
const stages = ref([
{ id: 1, name: '入门阶段', position: 100 },
{ id: 2, name: '提升阶段', position: 300 },
{ id: 3, name: '进阶阶段', position: 500 },
{ id: 4, name: '专家阶段', position: 700 }
])
// 课程数据
const salesCourses = ref([
{ id: 'c1', title: '客户沟通技巧', duration: 120 },
{ id: 'c2', title: '需求挖掘方法', duration: 90 },
{ id: 'c3', title: '异议处理技巧', duration: 60 },
{ id: 'c4', title: '成交技巧', duration: 90 }
])
const productCourses = ref([
{ id: 'c5', title: '产品基础知识', duration: 180 },
{ id: 'c6', title: '产品对比分析', duration: 120 },
{ id: 'c7', title: '产品演示技巧', duration: 90 }
])
const serviceCourses = ref([
{ id: 'c8', title: '客户服务礼仪', duration: 60 },
{ id: 'c9', title: '投诉处理技巧', duration: 90 },
{ id: 'c10', title: '客户关系维护', duration: 120 }
])
// 过滤后的课程数据
const filteredSalesCourses = computed(() => {
if (!searchText.value) return salesCourses.value
const keyword = searchText.value.toLowerCase()
return salesCourses.value.filter(course =>
course.title.toLowerCase().includes(keyword)
)
})
const filteredProductCourses = computed(() => {
if (!searchText.value) return productCourses.value
const keyword = searchText.value.toLowerCase()
return productCourses.value.filter(course =>
course.title.toLowerCase().includes(keyword)
)
})
const filteredServiceCourses = computed(() => {
if (!searchText.value) return serviceCourses.value
const keyword = searchText.value.toLowerCase()
return serviceCourses.value.filter(course =>
course.title.toLowerCase().includes(keyword)
)
})
const generalCourses = ref([
{ id: 'c11', title: '时间管理', duration: 60 },
{ id: 'c12', title: '商务礼仪', duration: 45 },
{ id: 'c13', title: '团队协作', duration: 90 }
])
const filteredGeneralCourses = computed(() => {
if (!searchText.value) return generalCourses.value
const keyword = searchText.value.toLowerCase()
return generalCourses.value.filter(course =>
course.title.toLowerCase().includes(keyword)
)
})
// 计算属性
const requiredCount = computed(() => pathNodes.value.filter(n => n.required).length)
const totalDuration = computed(() => pathNodes.value.reduce((sum, n) => sum + n.duration, 0))
const estimatedDays = computed(() => Math.ceil(totalDuration.value / 180)) // 假设每天学习3小时
const availableDependencies = computed(() =>
pathNodes.value.filter(n => n.id !== currentNode.value?.id)
)
/**
* 获取岗位名称
*/
const getPositionName = (position: string) => {
const positionMap: Record<string, string> = {
sales: '销售专员',
sales_manager: '销售主管',
service: '客服专员',
tech: '技术支持'
}
return positionMap[position] || position
}
/**
* 岗位切换
*/
const handlePositionChange = () => {
if (hasChanges.value) {
ElMessageBox.confirm(
'当前路径尚未保存,切换岗位将丢失未保存的更改,是否继续?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
loadPathData()
hasChanges.value = false
}).catch(() => {
// 恢复原选择
})
} else {
loadPathData()
}
}
/**
* 加载路径数据
*/
const loadPathData = () => {
// 模拟加载不同岗位的路径数据
if (selectedPosition.value === 'sales') {
pathNodes.value = [
{ id: 'n1', courseId: 'c5', title: '产品基础知识', duration: 180, required: true, x: 100, y: 50 },
{ id: 'n2', courseId: 'c1', title: '客户沟通技巧', duration: 120, required: true, x: 350, y: 50 },
{ id: 'n3', courseId: 'c2', title: '需求挖掘方法', duration: 90, required: true, x: 100, y: 250 },
{ id: 'n4', courseId: 'c3', title: '异议处理技巧', duration: 60, required: false, x: 350, y: 250 }
]
connections.value = [
{ from: 'n1', to: 'n2' },
{ from: 'n2', to: 'n3' },
{ from: 'n2', to: 'n4' }
]
} else {
pathNodes.value = []
connections.value = []
}
}
/**
* 开始拖拽课程
*/
const handleDragStart = (event: DragEvent, course: any) => {
event.dataTransfer!.effectAllowed = 'copy'
event.dataTransfer!.setData('course', JSON.stringify(course))
}
/**
* 处理放置
*/
const handleDrop = (event: DragEvent) => {
event.preventDefault()
const courseData = event.dataTransfer!.getData('course')
if (!courseData) return
const course = JSON.parse(courseData)
const rect = canvasRef.value.getBoundingClientRect()
const scrollLeft = canvasRef.value.scrollLeft
const scrollTop = canvasRef.value.scrollTop
const x = event.clientX - rect.left + scrollLeft - 75 // 节点宽度的一半
const y = event.clientY - rect.top + scrollTop - 40 // 节点高度的一半
// 检查是否已存在
if (pathNodes.value.some(n => n.courseId === course.id)) {
ElMessage.warning('该课程已在路径中')
return
}
// 添加新节点
const newNode = {
id: `n${Date.now()}`,
courseId: course.id,
title: course.title,
duration: course.duration,
required: true,
x: Math.max(0, x), // 不限制最大值,让用户可以在更大的画布上放置
y: Math.max(0, y)
}
pathNodes.value.push(newNode)
hasChanges.value = true
ElMessage.success('课程添加成功')
}
/**
* 开始拖拽节点
*/
const startDrag = (event: MouseEvent, node: any) => {
draggedNode.value = node
const rect = canvasRef.value.getBoundingClientRect()
const scrollLeft = canvasRef.value.scrollLeft
const scrollTop = canvasRef.value.scrollTop
dragOffset.value = {
x: event.clientX - rect.left + scrollLeft - node.x,
y: event.clientY - rect.top + scrollTop - node.y
}
const handleMouseMove = (e: MouseEvent) => {
if (!draggedNode.value) return
const rect = canvasRef.value.getBoundingClientRect()
const scrollLeft = canvasRef.value.scrollLeft
const scrollTop = canvasRef.value.scrollTop
draggedNode.value.x = Math.max(0, e.clientX - rect.left + scrollLeft - dragOffset.value.x)
draggedNode.value.y = Math.max(0, e.clientY - rect.top + scrollTop - dragOffset.value.y)
hasChanges.value = true
}
const handleMouseUp = () => {
draggedNode.value = null
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
/**
* 显示节点菜单
*/
const showNodeMenu = (_event: MouseEvent, _node: any) => {
// 右键菜单逻辑
}
/**
* 处理节点命令
*/
const handleNodeCommand = (command: string, node: any) => {
switch (command) {
case 'required':
node.required = !node.required
hasChanges.value = true
break
case 'dependency':
currentNode.value = node
selectedDependencies.value = connections.value
.filter(c => c.to === node.id)
.map(c => c.from)
dependencyDialogVisible.value = true
break
case 'delete':
deleteNode(node)
break
}
}
/**
* 删除节点
*/
const deleteNode = (node: any) => {
ElMessageBox.confirm(
`确定要删除课程"${node.title}"吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
// 删除节点
const index = pathNodes.value.findIndex(n => n.id === node.id)
if (index > -1) {
pathNodes.value.splice(index, 1)
}
// 删除相关连接
connections.value = connections.value.filter(c => c.from !== node.id && c.to !== node.id)
hasChanges.value = true
ElMessage.success('删除成功')
}).catch(() => {})
}
/**
* 保存前置课程设置
*/
const saveDependencies = () => {
if (!currentNode.value) return
// 移除旧的连接
connections.value = connections.value.filter(c => c.to !== currentNode.value.id)
// 添加新的连接
selectedDependencies.value.forEach(fromId => {
connections.value.push({ from: fromId, to: currentNode.value.id })
})
hasChanges.value = true
dependencyDialogVisible.value = false
ElMessage.success('前置课程设置成功')
}
/**
* 获取连接路径
*/
const getConnectionPath = (conn: any) => {
const fromNode = pathNodes.value.find(n => n.id === conn.from)
const toNode = pathNodes.value.find(n => n.id === conn.to)
if (!fromNode || !toNode) return ''
const x1 = fromNode.x + 75 // 节点中心
const y1 = fromNode.y + 80 // 节点底部
const x2 = toNode.x + 75 // 节点中心
const y2 = toNode.y // 节点顶部
// 贝塞尔曲线
const cx = (x1 + x2) / 2
const cy = (y1 + y2) / 2
return `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`
}
/**
* 清空画布
*/
const clearCanvas = () => {
ElMessageBox.confirm(
'确定要清空画布吗?此操作不可恢复。',
'清空确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
pathNodes.value = []
connections.value = []
hasChanges.value = true
ElMessage.success('画布已清空')
}).catch(() => {})
}
/**
* 自动布局
*/
const autoLayout = () => {
if (pathNodes.value.length === 0) {
ElMessage.warning('画布为空,无需布局')
return
}
// 简单的网格布局
const cols = 3
const xSpacing = 200
const ySpacing = 150
const startX = 50
const startY = 50
pathNodes.value.forEach((node, index) => {
const row = Math.floor(index / cols)
const col = index % cols
node.x = startX + col * xSpacing
node.y = startY + row * ySpacing
})
hasChanges.value = true
ElMessage.success('自动布局完成')
}
/**
* 保存当前路径
*/
const saveCurrentPath = () => {
if (!selectedPosition.value) {
ElMessage.warning('请先选择岗位')
return
}
ElMessageBox.confirm(
'确定要保存当前的成长路径吗?',
'保存确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}
).then(() => {
// 模拟保存
setTimeout(() => {
hasChanges.value = false
ElMessage.success('成长路径保存成功')
}, 1000)
}).catch(() => {})
}
// 初始化
loadPathData()
</script>
<style lang="scss" scoped>
.growth-path-management-container {
height: calc(100vh - 60px);
display: flex;
flex-direction: column;
padding: 20px;
box-sizing: border-box;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
gap: 12px;
.el-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
.path-designer {
flex: 1;
display: flex;
gap: 20px;
min-height: 0;
height: 100%;
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.course-library {
width: 320px;
display: flex;
flex-direction: column;
height: 100%;
.library-header {
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
}
.library-content {
flex: 1;
overflow-y: auto;
padding: 16px;
min-height: 0;
.course-list {
display: flex;
flex-direction: column;
gap: 8px;
.course-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: #f5f7fa;
border-radius: 6px;
cursor: grab;
transition: all 0.3s ease;
&:hover {
background: #e6e8eb;
transform: translateX(4px);
}
&:active {
cursor: grabbing;
}
> span {
flex: 1;
font-size: 14px;
color: #333;
}
}
}
}
}
.path-canvas {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.canvas-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
}
.canvas-actions {
display: flex;
gap: 12px;
}
}
.canvas-content {
flex: 1;
position: relative;
background: #fafafa;
overflow: auto;
min-height: 0;
.canvas-inner {
position: relative;
width: 100%;
min-width: 800px;
min-height: 900px;
background-image:
linear-gradient(rgba(0, 0, 0, .05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, .05) 1px, transparent 1px);
background-size: 20px 20px;
}
.stage-divider {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: #dcdfe6;
.stage-label {
position: absolute;
left: 20px;
top: -10px;
background: #fafafa;
padding: 0 8px;
font-size: 12px;
color: #909399;
}
}
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
p {
margin-top: 16px;
font-size: 14px;
color: #909399;
}
}
.path-node {
position: absolute;
width: 150px;
padding: 16px;
background: #fff;
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: move;
transition: box-shadow 0.3s ease;
user-select: none;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.is-required {
border-color: #f56c6c;
}
.node-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
.node-title {
font-size: 14px;
font-weight: 500;
color: #333;
flex: 1;
line-height: 1.4;
}
}
.node-info {
display: flex;
justify-content: space-between;
align-items: center;
.node-duration {
font-size: 12px;
color: #909399;
}
}
}
.connections {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
}
.canvas-footer {
padding: 16px 20px;
border-top: 1px solid #ebeef5;
background: #fff;
.path-stats {
display: flex;
gap: 32px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
.stat-label {
font-size: 14px;
color: #666;
}
.stat-value {
font-size: 16px;
font-weight: 600;
color: #333;
}
}
}
}
}
}
.dependency-content {
p {
margin-bottom: 16px;
font-size: 14px;
color: #666;
}
.el-checkbox-group {
display: flex;
flex-direction: column;
gap: 12px;
}
}
}
// 响应式
@media (max-width: 1024px) {
.growth-path-management-container {
.path-designer {
flex-direction: column;
.course-library {
width: 100%;
height: 300px;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,406 @@
<template>
<div class="register-container">
<div class="register-bg">
<div class="bg-shape bg-shape-1"></div>
<div class="bg-shape bg-shape-2"></div>
<div class="bg-shape bg-shape-3"></div>
</div>
<div class="register-card">
<div class="register-header">
<div class="logo">
<el-icon :size="48" color="#667eea">
<Notebook />
</el-icon>
</div>
<h1 class="title">考培练系统</h1>
<p class="subtitle">注册新账号开启学习之旅</p>
</div>
<el-form ref="formRef" :model="registerForm" :rules="rules" class="register-form">
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="请输入用户名"
size="large"
clearable
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
placeholder="请输入邮箱"
size="large"
clearable
>
<template #prefix>
<el-icon><Message /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="请确认密码"
size="large"
show-password
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="agree">
<el-checkbox v-model="registerForm.agree">
我已阅读并同意
<el-link type="primary" :underline="false" @click="showTerms">用户协议</el-link>
<el-link type="primary" :underline="false" @click="showPrivacy">隐私政策</el-link>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="register-btn"
:loading="loading"
@click="handleRegister"
>
</el-button>
</el-form-item>
<div class="login-link">
已有账号
<el-link type="primary" :underline="false" @click="goLogin">
立即登录
</el-link>
</div>
</el-form>
</div>
<!-- 注册成功提示弹窗 -->
<el-dialog
v-model="successDialogVisible"
title="注册成功"
width="400px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
>
<div class="success-content">
<el-icon :size="64" color="#67c23a" class="success-icon">
<CircleCheckFilled />
</el-icon>
<p class="success-message">注册成功请联系管理员为您激活账号并分配岗位</p>
<p class="success-tips">您的账号状态为"待激活"激活后方可登录使用</p>
</div>
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="handleSuccessClose">知道了</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
const successDialogVisible = ref(false)
// 注册表单
const registerForm = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
agree: false
})
// 自定义验证规则
const validatePassword = (_rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请输入密码'))
} else if (value.length < 6) {
callback(new Error('密码长度不能少于6个字符'))
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
callback(new Error('密码必须包含大小写字母和数字'))
} else {
if (registerForm.confirmPassword !== '') {
formRef.value?.validateField('confirmPassword')
}
callback()
}
}
const validateConfirmPassword = (_rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
const validateAgree = (_rule: any, value: any, callback: any) => {
if (!value) {
callback(new Error('请阅读并同意用户协议和隐私政策'))
} else {
callback()
}
}
// 表单验证规则
const rules = reactive<FormRules>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
password: [
{ required: true, validator: validatePassword, trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validateConfirmPassword, trigger: 'blur' }
],
agree: [
{ validator: validateAgree, trigger: 'change' }
]
})
/**
* 注册处理
*/
const handleRegister = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
// 模拟注册请求
setTimeout(() => {
loading.value = false
successDialogVisible.value = true
}, 1500)
}
})
}
/**
* 注册成功弹窗关闭
*/
const handleSuccessClose = () => {
successDialogVisible.value = false
router.push('/login')
}
/**
* 显示用户协议
*/
const showTerms = () => {
ElMessage.info('用户协议内容展示')
}
/**
* 显示隐私政策
*/
const showPrivacy = () => {
ElMessage.info('隐私政策内容展示')
}
/**
* 去登录
*/
const goLogin = () => {
router.push('/login')
}
</script>
<style lang="scss" scoped>
.register-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
position: relative;
overflow: hidden;
.register-bg {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
.bg-shape {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.6;
animation: float 20s infinite ease-in-out;
&-1 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
top: -200px;
right: -100px;
}
&-2 {
width: 300px;
height: 300px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
bottom: -150px;
left: -100px;
animation-delay: -5s;
}
&-3 {
width: 350px;
height: 350px;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -10s;
}
}
}
.register-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
padding: 48px;
width: 480px;
position: relative;
z-index: 1;
.register-header {
text-align: center;
margin-bottom: 40px;
.logo {
margin-bottom: 16px;
}
.title {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #666;
}
}
.register-form {
.register-btn {
width: 100%;
height: 44px;
font-size: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
letter-spacing: 2px;
}
.login-link {
text-align: center;
margin-top: 24px;
font-size: 14px;
color: #666;
}
}
}
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
33% {
transform: translateY(-30px) rotate(120deg);
}
66% {
transform: translateY(30px) rotate(240deg);
}
}
// 成功弹窗样式
.success-content {
text-align: center;
padding: 20px 0;
.success-icon {
margin-bottom: 20px;
}
.success-message {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.success-tips {
font-size: 14px;
color: #666;
}
}
// 响应式
@media (max-width: 768px) {
.register-container {
padding: 20px;
.register-card {
width: 100%;
max-width: 450px;
padding: 32px 24px;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
<template>
<div class="ai-practice-container">
<!-- 页面头部 -->
<div class="practice-header">
<el-page-header @back="goBack">
<template #content>
<span class="page-title">AI陪练会话</span>
</template>
<template #extra>
<el-tag v-if="cozeSession" type="success">会话已连接</el-tag>
<el-tag v-else type="info">未连接</el-tag>
</template>
</el-page-header>
</div>
<!-- 主要内容区 -->
<div class="practice-main">
<!-- 左侧陪练模式选择 -->
<div class="practice-sidebar">
<div class="mode-selector">
<h3>陪练模式</h3>
<el-radio-group v-model="practiceMode" @change="handleModeChange">
<el-radio-button value="text">文本对话</el-radio-button>
<el-radio-button value="voice">语音对话</el-radio-button>
</el-radio-group>
</div>
<div class="topic-selector">
<h3>陪练主题</h3>
<el-input
v-model="trainingTopic"
placeholder="请输入陪练主题(可选)"
:disabled="!!cozeSession"
/>
<el-button
type="primary"
@click="startSession"
:loading="isCreatingSession"
:disabled="!!cozeSession"
style="margin-top: 12px; width: 100%"
>
{{ cozeSession ? '会话进行中' : '开始陪练' }}
</el-button>
<el-button
v-if="cozeSession"
@click="endSession"
style="margin-top: 8px; width: 100%"
>
结束陪练
</el-button>
</div>
<div class="practice-tips">
<h3>陪练提示</h3>
<ul>
<li>保持专注认真倾听</li>
<li>积极思考主动表达</li>
<li>不要害怕犯错</li>
<li>及时总结经验</li>
</ul>
</div>
</div>
<!-- 右侧对话区域 -->
<div class="practice-content">
<!-- 文本对话模式 -->
<div v-if="practiceMode === 'text'" class="text-chat-area">
<!-- 消息列表 -->
<div class="message-list" ref="messageContainer">
<div v-if="messages.length === 0" class="empty-state">
<el-empty description="点击开始陪练开始您的AI陪练之旅">
<el-icon :size="64"><ChatDotRound /></el-icon>
</el-empty>
</div>
<div
v-for="(message, index) in messages"
:key="index"
class="message-item"
:class="message.role"
>
<div class="message-avatar">
<el-avatar :size="36">
{{ message.role === 'user' ? '我' : 'AI' }}
</el-avatar>
</div>
<div class="message-content">
<div class="message-text" v-if="!message.loading">
{{ message.content }}
</div>
<div class="message-loading" v-else>
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<div class="message-time">
{{ formatTime(message.timestamp) }}
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area" v-if="cozeSession">
<el-input
v-model="inputMessage"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="输入您的消息..."
@keyup.enter.ctrl="sendTextMessage"
:disabled="isLoading"
/>
<el-button
type="primary"
@click="sendTextMessage"
:loading="isLoading"
:disabled="!inputMessage.trim()"
style="margin-top: 10px"
>
发送
</el-button>
<el-button
v-if="isLoading"
@click="abortChat"
style="margin-top: 10px"
>
中断
</el-button>
</div>
</div>
<!-- 语音对话模式 -->
<div v-else class="voice-chat-area">
<div class="voice-visualization">
<div class="voice-status">
<el-icon :size="120" :class="{ recording: isRecording }">
<Microphone />
</el-icon>
<p class="status-text">{{ voiceStatusText }}</p>
</div>
<div class="voice-controls" v-if="cozeSession">
<el-button
type="primary"
size="large"
circle
@click="toggleRecording"
:disabled="isProcessing"
>
<el-icon :size="32">
<component :is="isRecording ? 'VideoPause' : 'VideoPlay'" />
</el-icon>
</el-button>
</div>
</div>
<!-- 语音对话历史 -->
<div class="voice-history">
<div
v-for="(message, index) in messages"
:key="index"
class="voice-message"
:class="message.role"
>
<span class="speaker">{{ message.role === 'user' ? '您' : 'AI陪练' }}</span>
<span class="content">{{ message.content }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createTrainingSession,
endTrainingSession,
sendMessage as sendCozeMessage,
type CozeSession,
type StreamEvent
} from '@/api/coze'
const router = useRouter()
// 陪练模式
const practiceMode = ref<'text' | 'voice'>('text')
const trainingTopic = ref('')
const cozeSession = ref<CozeSession | null>(null)
const isCreatingSession = ref(false)
// 消息相关
const messages = ref<any[]>([])
const inputMessage = ref('')
const isLoading = ref(false)
const abortController = ref<AbortController | null>(null)
// 语音相关
const isRecording = ref(false)
const isProcessing = ref(false)
const voiceStatusText = ref('点击开始按钮进行语音陪练')
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
// DOM引用
const messageContainer = ref<HTMLElement>()
/**
* 返回上一页
*/
const goBack = () => {
if (cozeSession.value) {
ElMessageBox.confirm('确定要离开吗?这将结束当前陪练会话。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
endSession()
router.back()
})
} else {
router.back()
}
}
/**
* 切换模式
*/
const handleModeChange = (_mode: string) => {
if (cozeSession.value) {
ElMessage.warning('请先结束当前会话再切换模式')
practiceMode.value = practiceMode.value === 'text' ? 'voice' : 'text'
}
}
/**
* 开始会话
*/
const startSession = async () => {
isCreatingSession.value = true
try {
const response = await createTrainingSession(trainingTopic.value || undefined)
if (response.data) {
cozeSession.value = response.data as unknown as CozeSession
ElMessage.success('陪练会话已创建')
// 添加欢迎消息
messages.value.push({
role: 'assistant',
content: '您好我是您的AI陪练助手。让我们开始今天的练习吧',
timestamp: new Date()
})
}
} catch (error: any) {
ElMessage.error('创建会话失败:' + (error.message || '未知错误'))
} finally {
isCreatingSession.value = false
}
}
/**
* 结束会话
*/
const endSession = async () => {
if (!cozeSession.value) return
try {
await endTrainingSession(cozeSession.value.sessionId, {
reason: '用户主动结束',
feedback: {
rating: 5,
comment: '陪练体验良好'
}
})
cozeSession.value = null
messages.value = []
inputMessage.value = ''
trainingTopic.value = ''
ElMessage.success('陪练会话已结束')
} catch (error: any) {
ElMessage.error('结束会话失败:' + (error.message || '未知错误'))
}
}
/**
* 发送文本消息
*/
const sendTextMessage = async () => {
const content = inputMessage.value.trim()
if (!content || !cozeSession.value) return
// 添加用户消息
messages.value.push({
role: 'user',
content,
timestamp: new Date()
})
// 清空输入
inputMessage.value = ''
// 添加AI加载消息
const aiMessageIndex = messages.value.length
messages.value.push({
role: 'assistant',
content: '',
loading: true,
timestamp: new Date()
})
// 滚动到底部
await scrollToBottom()
// 发送消息
isLoading.value = true
let fullContent = ''
try {
abortController.value = new AbortController()
await sendCozeMessage(
cozeSession.value.sessionId,
content,
true,
(event: StreamEvent) => {
if (event.event === 'message_delta') {
fullContent += event.data.content || ''
messages.value[aiMessageIndex] = {
role: 'assistant',
content: fullContent,
loading: false,
timestamp: new Date()
}
scrollToBottom()
} else if (event.event === 'message_completed') {
isLoading.value = false
} else if (event.event === 'error') {
throw new Error(event.data.error || '发送消息失败')
}
}
)
} catch (error: any) {
isLoading.value = false
messages.value[aiMessageIndex] = {
role: 'assistant',
content: '抱歉,发送消息时出现错误。请稍后重试。',
loading: false,
timestamp: new Date(),
isError: true
}
ElMessage.error(error.message || '发送消息失败')
}
}
/**
* 中断对话
*/
const abortChat = () => {
if (abortController.value) {
abortController.value.abort()
abortController.value = null
isLoading.value = false
ElMessage.info('对话已中断')
}
}
/**
* 切换录音状态
*/
const toggleRecording = async () => {
if (isRecording.value) {
stopRecording()
} else {
await startRecording()
}
}
/**
* 开始录音
*/
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder.value = new MediaRecorder(stream)
audioChunks.value = []
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data)
}
mediaRecorder.value.onstop = async () => {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' })
await processAudio(audioBlob)
}
mediaRecorder.value.start()
isRecording.value = true
voiceStatusText.value = '正在录音...'
} catch (error) {
ElMessage.error('无法访问麦克风')
}
}
/**
* 停止录音
*/
const stopRecording = () => {
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop()
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
isRecording.value = false
voiceStatusText.value = '正在处理...'
isProcessing.value = true
}
}
/**
* 处理录音
*/
const processAudio = async (_audioBlob: Blob) => {
// TODO: 实现音频转文本和发送逻辑
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
ElMessage.info('语音功能正在开发中')
}
/**
* 滚动到底部
*/
const scrollToBottom = async () => {
await nextTick()
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
}
/**
* 格式化时间
*/
const formatTime = (date: Date) => {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
// 清理
onUnmounted(() => {
if (abortController.value) {
abortController.value.abort()
}
if (mediaRecorder.value && isRecording.value) {
stopRecording()
}
})
</script>
<style lang="scss" scoped>
.ai-practice-container {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f7fa;
.practice-header {
background: white;
padding: 16px 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
}
.practice-main {
flex: 1;
display: flex;
overflow: hidden;
.practice-sidebar {
width: 300px;
background: white;
border-right: 1px solid #ebeef5;
padding: 24px;
overflow-y: auto;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.mode-selector {
margin-bottom: 32px;
.el-radio-group {
width: 100%;
display: flex;
.el-radio-button {
flex: 1;
:deep(.el-radio-button__inner) {
width: 100%;
}
}
}
}
.topic-selector {
margin-bottom: 32px;
}
.practice-tips {
ul {
list-style: none;
padding: 0;
margin: 0;
li {
padding: 8px 0;
color: #666;
font-size: 14px;
display: flex;
align-items: center;
&::before {
content: '•';
margin-right: 8px;
color: #667eea;
font-weight: bold;
}
}
}
}
}
.practice-content {
flex: 1;
display: flex;
flex-direction: column;
background: white;
.text-chat-area {
height: 100%;
display: flex;
flex-direction: column;
.message-list {
flex: 1;
overflow-y: auto;
padding: 24px;
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.el-empty {
padding: 40px;
}
}
.message-item {
display: flex;
gap: 12px;
margin-bottom: 24px;
&.user {
flex-direction: row-reverse;
.message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
.message-time {
color: rgba(255, 255, 255, 0.8);
}
}
}
&.assistant {
.message-content {
background: #f5f7fa;
color: #333;
}
}
.message-content {
max-width: 70%;
padding: 12px 16px;
border-radius: 12px;
.message-text {
font-size: 15px;
line-height: 1.6;
white-space: pre-wrap;
}
.message-loading {
display: flex;
gap: 4px;
padding: 8px 0;
.dot {
width: 8px;
height: 8px;
background: #909399;
border-radius: 50%;
animation: loading 1.4s ease-in-out infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
.message-time {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
}
}
}
.input-area {
border-top: 1px solid #ebeef5;
padding: 16px 24px;
background: white;
}
}
.voice-chat-area {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
.voice-visualization {
text-align: center;
.voice-status {
margin-bottom: 48px;
.el-icon {
color: #909399;
transition: all 0.3s;
&.recording {
color: #f56c6c;
animation: pulse 1.5s ease-in-out infinite;
}
}
.status-text {
font-size: 18px;
color: #666;
margin-top: 24px;
}
}
}
.voice-history {
width: 100%;
max-width: 600px;
max-height: 300px;
overflow-y: auto;
margin-top: 48px;
padding: 24px;
background: #f5f7fa;
border-radius: 12px;
.voice-message {
margin-bottom: 16px;
font-size: 14px;
line-height: 1.6;
.speaker {
font-weight: 600;
color: #333;
}
.content {
color: #666;
}
&.user .speaker {
color: #667eea;
}
&.assistant .speaker {
color: #764ba2;
}
}
}
}
}
}
}
@keyframes loading {
0%, 60%, 100% {
transform: scale(1);
opacity: 1;
}
30% {
transform: scale(1.3);
opacity: 0.5;
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 响应式
@media (max-width: 768px) {
.ai-practice-container {
.practice-main {
flex-direction: column;
.practice-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #ebeef5;
padding: 16px;
}
}
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="ai-practice-page">
<!-- 语音对话模式默认 -->
<VoiceChat v-if="chatModel === 'voice'" />
<!-- 文本对话模式 -->
<TextChat v-else />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { usePracticeStore } from '@/stores/practiceStore'
import { practiceApi } from '@/api/practice'
import { ElMessage } from 'element-plus'
import VoiceChat from '@/components/VoiceChat.vue'
import TextChat from '@/components/TextChat.vue'
const route = useRoute()
const router = useRouter()
const practiceStore = usePracticeStore()
const { chatModel } = storeToRefs(practiceStore)
// ==================== 生命周期 ====================
onMounted(async () => {
// 检查陪练模式自由对话mode=free、直接模式sceneId或课程模式sceneData
const mode = route.query.mode as string
const sceneId = route.query.sceneId ? Number(route.query.sceneId) : null
const sceneData = route.query.sceneData as string
if (mode === 'free') {
// 自由对话模式创建默认场景id为null表示无需数据库场景
const defaultScene = {
id: null,
name: 'AI自由对话陪练',
description: '与AI进行自由对话提升您的沟通能力',
background: '这是一个自由对话场景您可以与AI进行任何话题的交流练习。',
ai_role: '我是您的AI陪练助手我会根据您的话题提供专业的指导和反馈。',
objectives: [
'提升沟通表达能力',
'增强语言组织能力',
'培养自信心'
],
type: 'free',
difficulty: 'beginner',
status: 'active'
}
practiceStore.setCurrentScene(defaultScene)
console.log('自由对话模式启动')
} else if (mode === 'course' && sceneData) {
// 课程模式从URL解析场景数据
try {
const scene = JSON.parse(sceneData)
practiceStore.setCurrentScene(scene)
console.log('课程模式场景加载成功:', scene.name)
} catch (error) {
console.error('解析场景数据失败:', error)
ElMessage.error('场景数据格式错误')
router.push('/trainee/ai-practice-center')
return
}
} else if (sceneId) {
// 直接模式:从数据库加载场景详情
await loadSceneDetail(sceneId)
} else {
// 缺少必要参数
ElMessage.error('缺少场景参数')
router.push('/trainee/ai-practice-center')
return
}
// 默认设置为语音模式
practiceStore.setChatModel('voice')
})
onUnmounted(() => {
// 页面卸载时重置状态
practiceStore.resetChat()
// 如果是语音模式,断开连接
if (chatModel.value === 'voice') {
practiceStore.disconnectVoice()
}
})
// ==================== 方法 ====================
/**
* 加载场景详情
*/
const loadSceneDetail = async (sceneId: number) => {
try {
const response: any = await practiceApi.getSceneDetail(sceneId)
// http.ts响应拦截器已经返回了response.data所以response直接是业务数据
if (response.code === 200 && response.data) {
practiceStore.setCurrentScene(response.data)
} else {
ElMessage.error(response.message || '加载场景失败')
router.push('/trainee/ai-practice-center')
}
} catch (error) {
console.error('加载场景失败:', error)
ElMessage.error('加载场景失败')
router.push('/trainee/ai-practice-center')
}
}
</script>
<style lang="scss" scoped>
.ai-practice-page {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,638 @@
<template>
<div class="audio-player-container">
<div class="player-bg">
<div class="wave wave-1"></div>
<div class="wave wave-2"></div>
<div class="wave wave-3"></div>
</div>
<div class="player-content">
<div class="player-header">
<el-button link @click="goBack">
<el-icon :size="20"><ArrowLeft /></el-icon>
返回
</el-button>
</div>
<div class="player-main">
<div class="course-cover">
<img :src="courseInfo.cover" :alt="courseInfo.title" />
<div class="cover-overlay">
<el-icon :size="80" color="white"><Headset /></el-icon>
</div>
</div>
<div class="course-info">
<h1 class="course-title">{{ courseInfo.title }}</h1>
<p class="course-instructor">讲师{{ courseInfo.instructor }}</p>
</div>
<div class="audio-controls">
<div class="progress-bar">
<div class="time-display">{{ formatTime(currentTime) }}</div>
<el-slider
v-model="progress"
:show-tooltip="false"
@change="handleProgressChange"
class="audio-slider"
/>
<div class="time-display">{{ formatTime(duration) }}</div>
</div>
<div class="control-buttons">
<el-button circle size="large" @click="backward">
<el-icon :size="24"><DArrowLeft /></el-icon>
</el-button>
<el-button
type="primary"
circle
size="large"
class="play-button"
@click="togglePlay"
>
<el-icon :size="32">
<component :is="isPlaying ? 'VideoPause' : 'VideoPlay'" />
</el-icon>
</el-button>
<el-button circle size="large" @click="forward">
<el-icon :size="24"><DArrowRight /></el-icon>
</el-button>
</div>
<div class="extra-controls">
<div class="speed-control">
<span>倍速</span>
<el-select v-model="playbackRate" @change="handleSpeedChange">
<el-option label="0.5x" :value="0.5" />
<el-option label="0.75x" :value="0.75" />
<el-option label="1.0x" :value="1" />
<el-option label="1.25x" :value="1.25" />
<el-option label="1.5x" :value="1.5" />
<el-option label="2.0x" :value="2" />
</el-select>
</div>
<div class="volume-control">
<el-icon :size="20" @click="toggleMute">
<component :is="isMuted ? 'Mute' : 'Microphone'" />
</el-icon>
<el-slider
v-model="volume"
:show-tooltip="false"
@change="handleVolumeChange"
style="width: 100px"
/>
</div>
</div>
</div>
<div class="playlist">
<h3 class="playlist-title">播放列表</h3>
<div class="playlist-items">
<div
class="playlist-item"
:class="{ active: item.id === currentTrack.id }"
v-for="item in playlist"
:key="item.id"
@click="selectTrack(item)"
>
<div class="item-index">{{ item.index }}</div>
<div class="item-info">
<div class="item-title">{{ item.title }}</div>
<div class="item-duration">{{ item.duration }}</div>
</div>
<el-icon v-if="item.id === currentTrack.id" class="playing-icon">
<VideoPlay />
</el-icon>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
// 课程信息
const courseInfo = ref({
title: '销售技巧基础训练',
instructor: '王老师',
cover: 'https://via.placeholder.com/400x400/667eea/ffffff?text=音频课程'
})
// 播放状态
const isPlaying = ref(false)
const isMuted = ref(false)
const currentTime = ref(0)
const duration = ref(3600) // 60分钟
const progress = ref(0)
const volume = ref(80)
const playbackRate = ref(1)
// 当前播放的音频
const currentTrack = ref({
id: 1,
title: '第一章:销售基础认知',
url: '/audio/chapter1.mp3'
})
// 播放列表
const playlist = ref([
{
id: 1,
index: '01',
title: '第一章:销售基础认知',
duration: '15:32',
url: '/audio/chapter1.mp3'
},
{
id: 2,
index: '02',
title: '第二章:客户沟通技巧',
duration: '22:18',
url: '/audio/chapter2.mp3'
},
{
id: 3,
index: '03',
title: '第三章:需求挖掘方法',
duration: '18:45',
url: '/audio/chapter3.mp3'
},
{
id: 4,
index: '04',
title: '第四章:产品介绍技巧',
duration: '20:12',
url: '/audio/chapter4.mp3'
},
{
id: 5,
index: '05',
title: '第五章:异议处理',
duration: '16:38',
url: '/audio/chapter5.mp3'
}
])
// 音频元素
let audioElement: HTMLAudioElement | null = null
/**
* 返回
*/
const goBack = () => {
router.back()
}
/**
* 格式化时间
*/
const formatTime = (seconds: number) => {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
return `${m}:${s.toString().padStart(2, '0')}`
}
/**
* 播放/暂停
*/
const togglePlay = () => {
isPlaying.value = !isPlaying.value
if (isPlaying.value) {
audioElement?.play()
ElMessage.success('开始播放')
} else {
audioElement?.pause()
}
}
/**
* 快退10秒
*/
const backward = () => {
if (audioElement) {
audioElement.currentTime = Math.max(0, audioElement.currentTime - 10)
}
}
/**
* 快进10秒
*/
const forward = () => {
if (audioElement) {
audioElement.currentTime = Math.min(duration.value, audioElement.currentTime + 10)
}
}
/**
* 进度改变
*/
const handleProgressChange = (val: number) => {
if (audioElement) {
audioElement.currentTime = (val / 100) * duration.value
}
}
/**
* 速度改变
*/
const handleSpeedChange = (val: number) => {
if (audioElement) {
audioElement.playbackRate = val
}
}
/**
* 静音切换
*/
const toggleMute = () => {
isMuted.value = !isMuted.value
if (audioElement) {
audioElement.muted = isMuted.value
}
}
/**
* 音量改变
*/
const handleVolumeChange = (val: number) => {
if (audioElement) {
audioElement.volume = val / 100
}
}
/**
* 选择音轨
*/
const selectTrack = (track: any) => {
currentTrack.value = track
ElMessage.info(`切换到:${track.title}`)
// 重置播放状态
isPlaying.value = false
currentTime.value = 0
progress.value = 0
}
// 初始化音频
onMounted(() => {
audioElement = new Audio(currentTrack.value.url)
// 监听播放进度
audioElement.addEventListener('timeupdate', () => {
if (audioElement) {
currentTime.value = audioElement.currentTime
progress.value = (audioElement.currentTime / duration.value) * 100
}
})
// 监听播放结束
audioElement.addEventListener('ended', () => {
isPlaying.value = false
// 自动播放下一首
const currentIndex = playlist.value.findIndex(item => item.id === currentTrack.value.id)
if (currentIndex < playlist.value.length - 1) {
selectTrack(playlist.value[currentIndex + 1])
setTimeout(() => togglePlay(), 500)
}
})
})
// 清理
onUnmounted(() => {
if (audioElement) {
audioElement.pause()
audioElement = null
}
})
</script>
<style lang="scss" scoped>
.audio-player-container {
min-height: 100vh;
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
position: relative;
overflow: hidden;
.player-bg {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
opacity: 0.1;
.wave {
position: absolute;
width: 200%;
height: 200%;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
&-1 {
top: -50%;
left: -50%;
animation: wave 20s linear infinite;
}
&-2 {
bottom: -50%;
right: -50%;
animation: wave 15s linear infinite reverse;
}
&-3 {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: wave 25s linear infinite;
}
}
}
.player-content {
position: relative;
z-index: 1;
max-width: 800px;
margin: 0 auto;
padding: 24px;
.player-header {
margin-bottom: 32px;
.el-button {
color: white;
&:hover {
color: #667eea;
}
}
}
.player-main {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
border-radius: 24px;
padding: 40px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
.course-cover {
width: 240px;
height: 240px;
margin: 0 auto 32px;
position: relative;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
}
.course-info {
text-align: center;
margin-bottom: 40px;
.course-title {
font-size: 28px;
font-weight: 600;
color: white;
margin-bottom: 8px;
}
.course-instructor {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
}
}
.audio-controls {
.progress-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
.time-display {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
min-width: 50px;
}
.audio-slider {
flex: 1;
:deep(.el-slider__runway) {
background-color: rgba(255, 255, 255, 0.2);
}
:deep(.el-slider__bar) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
:deep(.el-slider__button) {
border: 2px solid #667eea;
}
}
}
.control-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
margin-bottom: 32px;
.el-button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
&.play-button {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
&:hover {
transform: scale(1.05);
}
}
}
}
.extra-controls {
display: flex;
justify-content: space-between;
align-items: center;
.speed-control,
.volume-control {
display: flex;
align-items: center;
gap: 12px;
color: rgba(255, 255, 255, 0.8);
span {
font-size: 14px;
}
.el-select {
width: 100px;
:deep(.el-input__inner) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: white;
}
}
.el-icon {
cursor: pointer;
&:hover {
color: #667eea;
}
}
.el-slider {
:deep(.el-slider__runway) {
background-color: rgba(255, 255, 255, 0.2);
}
:deep(.el-slider__bar) {
background: #667eea;
}
}
}
}
}
.playlist {
margin-top: 40px;
padding-top: 32px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
.playlist-title {
font-size: 18px;
font-weight: 600;
color: white;
margin-bottom: 20px;
}
.playlist-items {
.playlist-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
&.active {
background: rgba(102, 126, 234, 0.2);
.item-title {
color: #667eea;
}
}
.item-index {
width: 30px;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
}
.item-info {
flex: 1;
margin-left: 12px;
.item-title {
font-size: 14px;
color: white;
margin-bottom: 4px;
}
.item-duration {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
}
.playing-icon {
color: #667eea;
}
}
}
}
}
}
}
@keyframes wave {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// 响应式
@media (max-width: 768px) {
.audio-player-container {
.player-main {
padding: 24px !important;
.course-cover {
width: 180px;
height: 180px;
}
.extra-controls {
flex-direction: column;
gap: 16px;
}
}
}
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="broadcast-wrapper">
<div class="broadcast-header">
<el-button @click="goBack" circle>
<el-icon><Back /></el-icon>
</el-button>
<h2 class="course-title">{{ courseName }}</h2>
<div class="header-spacer"></div>
</div>
<div class="player-container">
<div class="player-card">
<div class="player-icon">
<el-icon :size="80">
<Microphone />
</el-icon>
</div>
<div class="player-info">
<h3>播课音频</h3>
<p class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</p>
</div>
<!-- HTML5 Audio 元素 -->
<audio
ref="audioPlayer"
:src="mp3Url"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedMetadata"
@ended="onEnded"
@error="onError"
/>
<!-- 进度条 -->
<div class="progress-container">
<el-slider
v-model="sliderValue"
:max="100"
:show-tooltip="false"
@change="seekTo"
/>
</div>
<!-- 播放控制栏 -->
<div class="controls">
<el-button circle @click="togglePlay" :disabled="!audioReady">
<el-icon :size="32">
<VideoPause v-if="isPlaying" />
<VideoPlay v-else />
</el-icon>
</el-button>
<div class="speed-control">
<span class="speed-label">播放速度</span>
<el-button-group>
<el-button
v-for="speed in speedOptions"
:key="speed"
:type="playbackRate === speed ? 'primary' : ''"
size="small"
@click="setSpeed(speed)"
>
{{ speed }}x
</el-button>
</el-button-group>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Back, Microphone, VideoPlay, VideoPause } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
// 从路由参数获取数据
const courseName = computed(() => route.query.courseName as string || '播课')
const mp3Url = computed(() => route.query.mp3Url as string)
// 音频播放器引用
const audioPlayer = ref<HTMLAudioElement>()
// 播放状态
const isPlaying = ref(false)
const audioReady = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const playbackRate = ref(1.0)
const sliderValue = ref(0)
// 播放速度选项
const speedOptions = [1.0, 1.25, 1.5, 2.0]
/**
* 返回
*/
const goBack = () => {
router.back()
}
/**
* 切换播放/暂停
*/
const togglePlay = async () => {
if (!audioPlayer.value) return
try {
if (isPlaying.value) {
audioPlayer.value.pause()
isPlaying.value = false
} else {
await audioPlayer.value.play()
isPlaying.value = true
}
} catch (error) {
console.error('播放控制失败:', error)
ElMessage.error('播放失败')
}
}
/**
* 设置播放速度
*/
const setSpeed = (speed: number) => {
if (!audioPlayer.value) return
audioPlayer.value.playbackRate = speed
playbackRate.value = speed
}
/**
* 跳转到指定位置
*/
const seekTo = (value: number) => {
if (!audioPlayer.value || !duration.value) return
const newTime = (value / 100) * duration.value
audioPlayer.value.currentTime = newTime
}
/**
* 音频时间更新
*/
const onTimeUpdate = () => {
if (!audioPlayer.value) return
currentTime.value = audioPlayer.value.currentTime
if (duration.value > 0) {
sliderValue.value = (currentTime.value / duration.value) * 100
}
}
/**
* 音频元数据加载完成
*/
const onLoadedMetadata = () => {
if (!audioPlayer.value) return
duration.value = audioPlayer.value.duration
audioReady.value = true
}
/**
* 播放结束
*/
const onEnded = () => {
isPlaying.value = false
currentTime.value = 0
sliderValue.value = 0
}
/**
* 音频加载错误
*/
const onError = (event: Event) => {
console.error('音频加载失败:', event)
ElMessage.error('音频加载失败,请检查网络或稍后重试')
audioReady.value = false
}
/**
* 格式化时间
*/
const formatTime = (seconds: number): string => {
if (isNaN(seconds) || seconds === 0) {
return '00:00'
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
} else {
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
}
// 验证必要参数
onMounted(() => {
if (!mp3Url.value) {
ElMessage.error('缺少播课音频地址')
goBack()
}
})
// 清理
onBeforeUnmount(() => {
if (audioPlayer.value) {
audioPlayer.value.pause()
}
})
</script>
<style scoped lang="scss">
.broadcast-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.broadcast-header {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 30px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
.course-title {
margin: 0;
color: #fff;
font-size: 24px;
font-weight: 600;
}
.header-spacer {
flex: 1;
}
}
.player-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.player-card {
width: 100%;
max-width: 600px;
background: #fff;
border-radius: 20px;
padding: 40px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
.player-icon {
text-align: center;
margin-bottom: 30px;
color: #667eea;
}
.player-info {
text-align: center;
margin-bottom: 30px;
h3 {
margin: 0 0 10px 0;
font-size: 20px;
color: #333;
}
.time-display {
margin: 0;
font-size: 14px;
color: #999;
}
}
.progress-container {
margin-bottom: 30px;
}
.controls {
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
.speed-control {
display: flex;
align-items: center;
gap: 10px;
.speed-label {
font-size: 14px;
color: #666;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,914 @@
<template>
<div class="course-center-wrapper">
<!-- 全屏Loading遮罩Teleport到body -->
<Teleport to="body">
<div v-if="loading" class="fullscreen-loading-mask">
<div class="loading-content">
<el-icon class="loading-icon is-loading">
<Loading />
</el-icon>
<div class="loading-text">{{ loadingText }}</div>
</div>
</div>
</Teleport>
<div class="course-center-container">
<div class="page-header">
<h1 class="page-title">课程中心</h1>
<div class="header-actions">
<el-input
v-model="searchKeyword"
placeholder="搜索课程"
clearable
style="width: 300px"
@keyup.enter="handleSearch"
@input="handleRealTimeSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
<!-- 分类筛选 -->
<div class="category-filter">
<el-radio-group v-model="selectedCategory" @change="handleCategoryChange">
<el-radio-button label="">全部课程</el-radio-button>
<el-radio-button label="required">必修课程</el-radio-button>
<el-radio-button label="optional">选修课程</el-radio-button>
<el-radio-button label="completed">已完成</el-radio-button>
<el-radio-button label="ongoing">学习中</el-radio-button>
</el-radio-group>
</div>
<!-- 课程列表 -->
<div v-if="filteredCourses.length > 0" class="course-grid">
<div class="course-card" v-for="course in filteredCourses" :key="course.id">
<!-- 卡片内容区域 -->
<div class="card-body">
<!-- 顶部徽章和进度 -->
<div class="card-header-info">
<div class="card-badges">
<span class="badge badge-new" v-if="course.isNew">
<el-icon><Star /></el-icon>
NEW
</span>
<span class="badge" :class="course.type === '必修' ? 'badge-required' : 'badge-optional'">
{{ course.type }}
</span>
</div>
<div class="progress-badge" v-if="course.progress > 0">
<span class="progress-text">{{ course.progress }}%</span>
</div>
</div>
<h3 class="course-title">{{ course.title }}</h3>
<p class="course-description">{{ course.description }}</p>
<div class="course-stats">
<div class="stat-item">
<el-icon><Clock /></el-icon>
<span>{{ course.duration }}</span>
</div>
<div class="stat-item">
<el-icon><Collection /></el-icon>
<span>{{ course.lessons }} 课时</span>
</div>
<div class="stat-item">
<el-icon><User /></el-icon>
<span>{{ course.students }}</span>
</div>
</div>
</div>
<!-- 卡片底部操作区域 -->
<div class="card-footer">
<!-- 主要操作按钮 -->
<button class="action-btn primary-btn" @click="enterCourse(course)">
<el-icon><VideoPlay /></el-icon>
<span>{{ course.progress > 0 ? '继续学习' : '开始学习' }}</span>
</button>
<!-- 次要操作按钮组 -->
<div class="secondary-actions">
<!-- 播课功能暂时关闭 -->
<button v-if="false" class="action-btn secondary-btn" @click="playAudio(course)">
<el-icon><Headset /></el-icon>
<span>播课</span>
</button>
<button class="action-btn secondary-btn" @click="chatWithCourse(course)">
<el-icon><ChatDotRound /></el-icon>
<span>对话</span>
</button>
<button
class="action-btn exam-btn"
@click="startExam(course)"
>
<el-icon><DocumentChecked /></el-icon>
<span>考试</span>
</button>
<button
class="action-btn practice-btn"
@click="startPractice(course)"
>
<el-icon><Microphone /></el-icon>
<span>陪练</span>
</button>
</div>
</div>
</div>
</div>
<!-- 空状态显示 -->
<div v-else class="empty-state">
<el-empty
:description="(searchKeyword || selectedCategory) ? '没有符合条件的课程' : '暂无课程数据'"
:image-size="120"
>
<el-button v-if="searchKeyword || selectedCategory" type="primary" @click="clearAllFilters">
清空筛选条件
</el-button>
<el-button v-else type="primary" disabled>
敬请期待更多课程
</el-button>
</el-empty>
</div>
<!-- 加载更多 -->
<div class="load-more" v-if="hasMore && filteredCourses.length > 0">
<el-button @click="loadMore">加载更多</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Search, Clock, User, Headset, ChatDotRound,
Microphone, Star, VideoPlay, Collection, DocumentChecked,
Loading
} from '@element-plus/icons-vue'
import { getCourseList, type CourseInfo } from '@/api/trainee'
import { practiceApi } from '@/api/practice'
import { broadcastApi } from '@/api/broadcast'
const router = useRouter()
// 搜索关键词
const searchKeyword = ref('')
// 选中的分类
const selectedCategory = ref('')
// 是否有更多
const hasMore = ref(true)
// 课程列表(展示用)
const courseList = ref<any[]>([])
// Loading状态
const loading = ref(false)
const loadingText = ref('正在加载...')
/**
* 从后端获取课程列表并映射为页面展示结构
*/
async function fetchCourses(): Promise<void> {
try {
const res = await getCourseList({ page: 1, size: 12 })
const items = res?.data?.items || []
// 将后端数据映射到当前页面使用的展示结构
courseList.value = (items as CourseInfo[]).map((c: any, idx) => {
// 根据后端返回的course_type字段判断课程类型
// required=必修, optional=选修, 无值表示该课程未分配给用户岗位
let courseType = '必修' // 默认为必修
if (c.course_type === 'optional') {
courseType = '选修'
} else if (c.course_type === 'required') {
courseType = '必修'
}
return {
id: c.id,
title: c.name || c.title || '', // 优先使用name数据库字段兼容title
description: c.description || '',
type: courseType,
isNew: idx < 2,
duration: formatDuration(c.duration),
lessons: c.materialCount ?? 0,
students: 0,
progress: c.progress ?? 0,
examPassed: (c.progress ?? 0) >= 80
}
})
} catch (error) {
ElMessage.error('课程加载失败,请稍后重试')
// 失败时保持空列表UI 会显示空状态
courseList.value = []
}
}
/**
* 将分钟数格式化为“X小时Y分”
*/
function formatDuration(minutes?: number): string {
if (!minutes || minutes <= 0) return '—'
const h = Math.floor(minutes / 60)
const m = minutes % 60
if (h > 0 && m > 0) return `${h}小时${m}`
if (h > 0) return `${h}小时`
return `${m}`
}
// 过滤后的课程数据
const filteredCourses = computed(() => {
let filtered = courseList.value
// 关键词搜索
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
filtered = filtered.filter(course =>
course.title.toLowerCase().includes(keyword) ||
course.description.toLowerCase().includes(keyword)
)
}
// 分类筛选
if (selectedCategory.value) {
switch (selectedCategory.value) {
case 'required':
filtered = filtered.filter(course => course.type === '必修')
break
case 'optional':
filtered = filtered.filter(course => course.type === '选修')
break
case 'completed':
filtered = filtered.filter(course => course.progress === 100)
break
case 'ongoing':
filtered = filtered.filter(course => course.progress > 0 && course.progress < 100)
break
}
}
return filtered
})
onMounted(() => {
fetchCourses()
})
/**
* 搜索课程
*/
const handleSearch = () => {
const count = filteredCourses.value.length
ElMessage.success('找到 ' + count + ' 门课程')
}
/**
* 分类切换
*/
const handleCategoryChange = () => {
const categoryText = getCategoryText(selectedCategory.value)
const count = filteredCourses.value.length
ElMessage.success('切换到:' + categoryText + ',找到 ' + count + ' 门课程')
}
/**
* 获取分类文本
*/
const getCategoryText = (category: string) => {
const map: Record<string, string> = {
'': '全部课程',
'required': '必修课程',
'optional': '选修课程',
'completed': '已完成',
'ongoing': '学习中'
}
return map[category] || '全部课程'
}
/**
* 实时搜索处理
*/
const handleRealTimeSearch = () => {
// 筛选逻辑在计算属性中处理
}
/**
* 清空所有筛选条件
*/
const clearAllFilters = () => {
searchKeyword.value = ''
selectedCategory.value = ''
ElMessage.success('已清空所有筛选条件')
}
/**
* 进入课程
*/
const enterCourse = (course: any) => {
router.push('/trainee/course-detail?id=' + course.id)
}
/**
* 播放音频
*/
const playAudio = async (course: any) => {
try {
const res: any = await broadcastApi.getInfo(course.id)
// http.ts 返回 AxiosResponse需要访问 res.data.data
const code = res.data?.code || res.code
const data = res.data?.data || res.data
if (code === 200 && data?.has_broadcast && data.mp3_url) {
router.push({
name: 'BroadcastCourse',
query: {
courseId: course.id,
courseName: course.title, // 使用映射后的title字段
mp3Url: data.mp3_url
}
})
} else {
ElMessage.warning('该课程暂无播课内容')
}
} catch (error) {
console.error('查询播课信息失败:', error)
ElMessage.error('查询播课信息失败')
}
}
/**
* 与课程对话
*/
const chatWithCourse = (course: any) => {
router.push('/trainee/chat-course?courseId=' + course.id)
}
/**
* 开始考试
*/
const startExam = (course: any) => {
router.push('/trainee/exam?courseId=' + course.id)
}
/**
* 开始陪练(课程模式)
*
* 流程:
* 1. 调用Dify工作流提取场景
* 2. 跳转到陪练对话页面,携带场景数据
*/
const startPractice = async (course: any) => {
try {
// 显示全屏Loading
loading.value = true
loadingText.value = '正在分析课程内容,生成陪练场景...\n预计需要10-30秒请稍候'
console.log('🚀 开始提取场景, course_id:', course.id)
// 调用Dify提取场景
const response: any = await practiceApi.extractScene({
course_id: course.id
})
console.log('📦 完整响应:', response)
console.log('📦 response.data:', response.data)
// http.ts返回AxiosResponse数据在response.data.data中
// response -> AxiosResponse
// response.data -> { code: 200, message: "success", data: {...} }
// response.data.data -> { scene: {...}, workflow_run_id, task_id }
const businessData = response.data?.data || response.data || response
console.log('📦 业务数据:', businessData)
console.log('📦 场景数据:', businessData?.scene)
if (!businessData || !businessData.scene) {
console.error('❌ 场景数据格式错误:', { response, businessData })
throw new Error('场景数据格式错误请查看Console')
}
// 更新Loading文字
loadingText.value = '场景生成完成,正在跳转...'
console.log('✅ 场景提取成功:', businessData.scene.name)
// 跳转到陪练对话页面,携带场景数据
await router.push({
name: 'AIPractice',
query: {
mode: 'course',
courseId: String(course.id),
sceneData: JSON.stringify(businessData.scene)
}
})
console.log('✅ 跳转完成')
} catch (error: any) {
console.error('❌ 场景提取失败:', error)
ElMessage.error(error.message || '场景提取失败,请稍后重试')
} finally {
// 关闭Loading
loading.value = false
loadingText.value = '正在加载...'
}
}
/**
* 加载更多
*/
const loadMore = () => {
ElMessage.info('加载更多课程...')
}
</script>
<style lang="scss" scoped>
// 全屏Loading遮罩样式Teleport到body
.fullscreen-loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
.loading-icon {
font-size: 60px;
color: #ffffff;
animation: rotating 2s linear infinite;
}
.loading-text {
color: #ffffff;
font-size: 18px;
font-weight: 600;
line-height: 1.8;
white-space: pre-line;
text-align: center;
max-width: 400px;
}
}
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.course-center-wrapper {
// 包裹容器,无样式
}
.course-center-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
.page-title {
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
letter-spacing: -0.5px;
}
.header-actions {
:deep(.el-input) {
border-radius: 25px;
.el-input__wrapper {
border-radius: 25px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #e4e7ed;
transition: all 0.3s;
&:hover {
border-color: #667eea;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.15);
}
}
}
}
}
.category-filter {
margin-bottom: 32px;
.el-radio-group {
display: flex;
gap: 12px;
:deep(.el-radio-button__inner) {
border-radius: 25px;
padding: 10px 24px;
font-weight: 500;
border: 2px solid #e4e7ed;
background: #fff;
transition: all 0.3s;
&:hover {
color: #667eea;
border-color: #667eea;
background: rgba(102, 126, 234, 0.05);
}
}
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
color: #fff;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
}
}
.course-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 28px;
margin-bottom: 48px;
.course-card {
background: #fff;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&:hover {
transform: translateY(-8px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
.primary-btn {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
}
.card-body {
padding: 20px;
.card-header-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.card-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
.badge {
padding: 4px 10px;
border-radius: 16px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
gap: 3px;
&.badge-new {
background: linear-gradient(135deg, #ff4757 0%, #ff6348 100%);
color: #fff;
box-shadow: 0 2px 8px rgba(255, 71, 87, 0.3);
}
&.badge-required {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #fff;
}
&.badge-optional {
background: #f4f4f5;
color: #606266;
}
}
}
.progress-badge {
display: flex;
align-items: center;
.progress-text {
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 600;
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
color: #fff;
box-shadow: 0 2px 8px rgba(103, 194, 58, 0.3);
}
}
}
.course-title {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
line-height: 1.3;
}
.course-description {
font-size: 13px;
color: #606266;
line-height: 1.6;
margin-bottom: 16px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.course-stats {
display: flex;
gap: 16px;
margin-bottom: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #909399;
.el-icon {
font-size: 14px;
color: #667eea;
}
}
}
}
.card-footer {
padding: 0 20px 20px;
display: flex;
flex-direction: column;
gap: 10px;
.action-btn {
height: 38px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&.primary-btn {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.25);
&:hover {
background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%);
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.35);
}
&:active {
transform: translateY(0);
}
}
&.secondary-btn {
flex: 1;
background: #fff;
border: 1.5px solid #e4e7ed;
color: #606266;
font-size: 13px;
font-weight: 500;
&:hover {
border-color: #667eea;
color: #667eea;
background: rgba(102, 126, 234, 0.05);
transform: translateY(-1px);
}
}
&.exam-btn {
flex: 1;
background: #fff;
border: 1.5px solid #e6a23c;
color: #e6a23c;
font-size: 13px;
font-weight: 500;
&:hover {
background: rgba(230, 162, 60, 0.08);
transform: translateY(-1px);
}
}
&.practice-btn {
flex: 1;
background: #fff;
border: 1.5px solid #67c23a;
color: #67c23a;
font-size: 13px;
font-weight: 500;
&:hover {
background: rgba(103, 194, 58, 0.08);
transform: translateY(-1px);
}
}
.el-icon {
font-size: 16px;
}
span {
white-space: nowrap;
}
}
.secondary-actions {
display: flex;
gap: 8px;
width: 100%;
}
}
}
}
.empty-state {
padding: 80px 20px;
text-align: center;
:deep(.el-empty) {
.el-empty__description {
font-size: 16px;
color: #909399;
}
}
}
.load-more {
text-align: center;
margin-bottom: 60px;
.el-button {
min-width: 200px;
height: 44px;
border-radius: 22px;
font-size: 15px;
font-weight: 500;
}
}
}
// 响应式设计
@media (max-width: 1200px) {
.course-center-container {
.course-grid {
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
}
}
}
@media (max-width: 768px) {
.course-center-container {
padding: 0 12px;
.page-header {
flex-direction: column;
align-items: stretch;
gap: 16px;
margin-bottom: 24px;
.page-title {
font-size: 24px;
}
.header-actions {
.el-input {
width: 100% !important;
}
}
}
.category-filter {
margin-bottom: 24px;
.el-radio-group {
width: 100%;
overflow-x: auto;
flex-wrap: nowrap;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
gap: 8px;
&::-webkit-scrollbar {
display: none; // Hide scrollbar for cleaner look
}
:deep(.el-radio-button__inner) {
padding: 8px 16px;
font-size: 13px;
}
}
}
.course-grid {
grid-template-columns: 1fr;
gap: 16px;
.course-card {
border-radius: 16px;
.card-body {
padding: 16px;
.course-title {
font-size: 17px;
}
}
.card-footer {
padding: 0 16px 16px;
gap: 12px;
.action-btn {
height: 40px;
font-size: 14px;
.el-icon {
font-size: 16px;
}
}
.secondary-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
.action-btn {
flex-direction: column;
height: auto;
padding: 8px 2px;
gap: 4px;
border-radius: 8px;
&.secondary-btn,
&.exam-btn,
&.practice-btn {
font-size: 11px;
span {
display: block !important;
line-height: 1;
transform: scale(0.95);
}
.el-icon {
margin: 0;
font-size: 18px;
margin-bottom: 2px;
}
}
}
}
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,810 @@
<template>
<div class="exam-result-container">
<div class="result-card card">
<!-- 成绩展示 -->
<div class="result-header">
<div class="score-circle" :class="scoreLevel">
<div class="score-value">{{ examResult.score }}</div>
<div class="score-label"></div>
</div>
<div class="result-info">
<h1 class="exam-title">{{ examResult.examTitle }}</h1>
<div class="result-meta">
<span class="meta-item">
<el-icon><Clock /></el-icon>
用时{{ formatDuration(examResult.duration) }}
</span>
<span class="meta-item">
<el-icon><Document /></el-icon>
题数{{ examResult.totalQuestions }}
</span>
<span class="meta-item">
<el-icon><CircleCheck /></el-icon>
正确{{ examResult.correctCount }}
</span>
<span class="meta-item">
<el-icon><CircleClose /></el-icon>
错误{{ examResult.wrongCount }}
</span>
</div>
<div class="result-status">
<el-tag :type="examResult.passed ? 'success' : 'danger'" size="large">
{{ examResult.passed ? '考试通过' : '未通过' }}
</el-tag>
<span class="pass-line">及格线{{ examResult.passScore }}</span>
</div>
</div>
</div>
<!-- 统计分析 -->
<div class="statistics-section">
<h2 class="section-title">答题统计</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">正确率</div>
<div class="stat-value">{{ examResult.accuracy }}%</div>
<el-progress :percentage="examResult.accuracy" :color="getProgressColor(examResult.accuracy)" />
</div>
<div class="stat-item">
<div class="stat-label">排名</div>
<div class="stat-value">{{ examResult.rank }}/{{ examResult.totalParticipants }}</div>
<div class="stat-desc">超过了{{ examResult.surpassRate }}%的人</div>
</div>
<div class="stat-item">
<div class="stat-label">平均用时</div>
<div class="stat-value">{{ examResult.avgTimePerQuestion }}/</div>
<div class="stat-desc">{{ examResult.timeEfficiency }}</div>
</div>
</div>
</div>
<!-- 各轮次得分 -->
<div class="round-scores-section">
<h2 class="section-title">各轮次得分</h2>
<div class="round-scores">
<div class="round-item" v-for="round in examResult.roundScores" :key="round.round">
<div class="round-header">
<span class="round-label">{{ round.round }}</span>
<span class="round-score" :class="getScoreClass(round.score)">{{ round.score }}</span>
</div>
<div class="round-details">
<div class="detail-item">
<span class="detail-label">题数</span>
<span class="detail-value">{{ round.questions }}</span>
</div>
<div class="detail-item">
<span class="detail-label">正确</span>
<span class="detail-value correct">{{ round.correct }}</span>
</div>
<div class="detail-item">
<span class="detail-label">错误</span>
<span class="detail-value wrong">{{ round.wrong }}</span>
</div>
<div class="detail-item">
<span class="detail-label">正确率</span>
<span class="detail-value">{{ Math.round(round.correct / round.questions * 100) }}%</span>
</div>
</div>
<el-progress
:percentage="Math.round(round.correct / round.questions * 100)"
:color="getProgressColor(Math.round(round.correct / round.questions * 100))"
:show-text="false"
/>
</div>
</div>
</div>
<!-- 题型分析 -->
<div class="type-analysis-section">
<h2 class="section-title">题型分析</h2>
<div class="type-stats">
<div class="type-item" v-for="type in questionTypeStats" :key="type.type">
<div class="type-header">
<span class="type-name">{{ type.name }}</span>
<span class="type-accuracy">正确率{{ type.accuracy }}%</span>
</div>
<el-progress :percentage="type.accuracy" :color="getProgressColor(type.accuracy)" />
<div class="type-detail">
<span>{{ type.total }}</span>
<span>正确{{ type.correct }}</span>
<span>错误{{ type.wrong }}</span>
</div>
</div>
</div>
</div>
<!-- 错题记录 -->
<div class="mistakes-section" v-if="mistakes.length > 0">
<h2 class="section-title">错题记录</h2>
<div class="mistakes-list">
<div class="mistake-item" v-for="(mistake, _index) in mistakes" :key="mistake.id">
<div class="mistake-header">
<span class="question-number">{{ mistake.number }}</span>
<el-tag :type="getQuestionTypeTag(mistake.type)" size="small">
{{ getQuestionTypeText(mistake.type) }}
</el-tag>
</div>
<div class="mistake-content">
<h4 class="question-title">{{ mistake.title }}</h4>
<div class="answer-comparison">
<div class="answer-item your-answer">
<span class="answer-label">你的答案</span>
<span class="answer-value wrong">{{ mistake.yourAnswer }}</span>
</div>
<div class="answer-item correct-answer">
<span class="answer-label">正确答案</span>
<span class="answer-value correct">{{ mistake.correctAnswer }}</span>
</div>
</div>
<div class="explanation">
<div class="explanation-title">
<el-icon><InfoFilled /></el-icon>
答案解析
</div>
<p class="explanation-content">{{ mistake.explanation }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button @click="viewReport">查看详细报告</el-button>
<el-button @click="retakeExam">重新考试</el-button>
<el-button v-if="examResult.passed" type="primary" @click="startPractice">
开始陪练
</el-button>
<el-button type="primary" @click="backToCourse">
返回课程
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
// 考试结果数据
const examResult = ref({
examTitle: '销售技巧基础训练 - 期末考试',
score: 85,
passScore: 60,
passed: true,
totalQuestions: 50,
correctCount: 42,
wrongCount: 8,
duration: 3600,
accuracy: 84,
rank: 12,
totalParticipants: 156,
surpassRate: 92.3,
avgTimePerQuestion: 72,
timeEfficiency: '答题速度适中',
// 三轮考试得分
roundScores: [
{ round: 1, score: 75, questions: 50, correct: 38, wrong: 12 },
{ round: 2, score: 82, questions: 12, correct: 10, wrong: 2 },
{ round: 3, score: 100, questions: 2, correct: 2, wrong: 0 }
]
})
// 分数等级
const scoreLevel = computed(() => {
const score = examResult.value.score
if (score >= 90) return 'excellent'
if (score >= 80) return 'good'
if (score >= 60) return 'pass'
return 'fail'
})
// 题型统计
const questionTypeStats = ref([
{
type: 'single',
name: '单选题',
total: 20,
correct: 18,
wrong: 2,
accuracy: 90
},
{
type: 'multiple',
name: '多选题',
total: 15,
correct: 12,
wrong: 3,
accuracy: 80
},
{
type: 'judge',
name: '判断题',
total: 10,
correct: 8,
wrong: 2,
accuracy: 80
},
{
type: 'blank',
name: '填空题',
total: 5,
correct: 4,
wrong: 1,
accuracy: 80
}
])
// 错题列表
const mistakes = ref([
{
id: 1,
number: 12,
type: 'single',
title: '以下哪个不是有效的客户沟通技巧?',
yourAnswer: 'B',
correctAnswer: 'C',
explanation: '在客户沟通中倾听比说话更重要。选项C中的"打断客户发言"违背了有效沟通的基本原则。'
},
{
id: 2,
number: 25,
type: 'multiple',
title: '成功的销售人员应该具备哪些特质?',
yourAnswer: 'A, B, D',
correctAnswer: 'A, B, C, D',
explanation: '成功的销售人员需要具备多方面的特质包括良好的沟通能力、同理心、专业知识和持续学习的态度。选项C"抗压能力"也是必不可少的。'
},
{
id: 3,
number: 38,
type: 'judge',
title: '销售过程中,价格是决定客户购买的唯一因素。',
yourAnswer: '正确',
correctAnswer: '错误',
explanation: '价格只是影响客户购买决策的因素之一。产品质量、服务、品牌信誉、售后支持等都是重要的考虑因素。'
}
])
/**
* 格式化时长
*/
const formatDuration = (seconds: number) => {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) {
return `${h}小时${m}分钟${s}`
}
return `${m}分钟${s}`
}
/**
* 获取进度条颜色
*/
const getProgressColor = (percentage: number) => {
if (percentage >= 90) return '#67c23a'
if (percentage >= 75) return '#409eff'
if (percentage >= 60) return '#e6a23c'
return '#f56c6c'
}
/**
* 获取分数样式类
*/
const getScoreClass = (score: number) => {
if (score >= 90) return 'excellent'
if (score >= 80) return 'good'
if (score >= 60) return 'pass'
return 'fail'
}
/**
* 获取题型标签类型
*/
const getQuestionTypeTag = (type: string) => {
const typeMap: Record<string, string> = {
single: '',
multiple: 'success',
judge: 'warning',
blank: 'danger'
}
return typeMap[type] || ''
}
/**
* 获取题型文本
*/
const getQuestionTypeText = (type: string) => {
const typeMap: Record<string, string> = {
single: '单选题',
multiple: '多选题',
judge: '判断题',
blank: '填空题'
}
return typeMap[type] || ''
}
/**
* 查看详细报告
*/
const viewReport = () => {
ElMessage.info('生成详细分析报告中...')
}
/**
* 重新考试
*/
const retakeExam = () => {
router.push('/trainee/exam')
}
/**
* 开始陪练
*/
const startPractice = () => {
router.push('/trainee/ai-practice')
}
/**
* 返回课程
*/
const backToCourse = () => {
router.push('/trainee/course-center')
}
</script>
<style lang="scss" scoped>
.exam-result-container {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
.result-card {
padding: 32px;
.result-header {
display: flex;
gap: 48px;
padding-bottom: 32px;
border-bottom: 1px solid #ebeef5;
margin-bottom: 32px;
.score-circle {
width: 160px;
height: 160px;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
background: #f5f7fa;
&::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
padding: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
&.excellent::before {
background: linear-gradient(135deg, #67c23a 0%, #409eff 100%);
}
&.good::before {
background: linear-gradient(135deg, #409eff 0%, #667eea 100%);
}
&.pass::before {
background: linear-gradient(135deg, #e6a23c 0%, #f56c6c 100%);
}
&.fail::before {
background: linear-gradient(135deg, #f56c6c 0%, #909399 100%);
}
.score-value {
font-size: 48px;
font-weight: 700;
color: #333;
}
.score-label {
font-size: 16px;
color: #666;
}
}
.result-info {
flex: 1;
.exam-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.result-meta {
display: flex;
gap: 24px;
margin-bottom: 20px;
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #666;
.el-icon {
color: #909399;
}
}
}
.result-status {
display: flex;
align-items: center;
gap: 16px;
.pass-line {
font-size: 14px;
color: #909399;
}
}
}
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
.statistics-section {
margin-bottom: 32px;
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
.stat-item {
text-align: center;
padding: 20px;
background: #f5f7fa;
border-radius: 12px;
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.stat-desc {
font-size: 13px;
color: #909399;
margin-top: 8px;
}
}
}
}
.round-scores-section {
margin-bottom: 32px;
.round-scores {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
.round-item {
padding: 20px;
background: #f9fafb;
border-radius: 12px;
border: 1px solid #ebeef5;
transition: all 0.3s;
&:hover {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.round-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.round-label {
font-size: 16px;
font-weight: 500;
color: #333;
}
.round-score {
font-size: 24px;
font-weight: 700;
&.excellent {
color: #67c23a;
}
&.good {
color: #409eff;
}
&.pass {
color: #e6a23c;
}
&.fail {
color: #f56c6c;
}
}
}
.round-details {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
.detail-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
.detail-label {
color: #909399;
}
.detail-value {
color: #606266;
font-weight: 500;
&.correct {
color: #67c23a;
}
&.wrong {
color: #f56c6c;
}
}
}
}
.el-progress {
height: 6px;
:deep(.el-progress-bar) {
border-radius: 3px;
}
}
}
}
}
.type-analysis-section {
margin-bottom: 32px;
.type-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
.type-item {
padding: 16px;
background: #f9fafb;
border-radius: 8px;
.type-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
.type-name {
font-size: 15px;
font-weight: 500;
color: #333;
}
.type-accuracy {
font-size: 14px;
color: #666;
}
}
.type-detail {
display: flex;
gap: 16px;
margin-top: 12px;
font-size: 13px;
color: #909399;
}
}
}
}
.mistakes-section {
margin-bottom: 32px;
.mistakes-list {
.mistake-item {
padding: 20px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.mistake-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.question-number {
font-size: 14px;
font-weight: 500;
color: #666;
}
}
.mistake-content {
.question-title {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 16px;
line-height: 1.5;
}
.answer-comparison {
display: flex;
gap: 32px;
margin-bottom: 20px;
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
.answer-item {
display: flex;
align-items: center;
gap: 8px;
.answer-label {
font-size: 14px;
color: #666;
}
.answer-value {
font-size: 15px;
font-weight: 500;
&.wrong {
color: #f56c6c;
}
&.correct {
color: #67c23a;
}
}
}
}
.explanation {
.explanation-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
color: #667eea;
margin-bottom: 8px;
}
.explanation-content {
font-size: 14px;
color: #666;
line-height: 1.6;
padding-left: 22px;
}
}
}
}
}
}
.action-buttons {
display: flex;
justify-content: center;
gap: 16px;
padding-top: 32px;
border-top: 1px solid #ebeef5;
.el-button {
min-width: 120px;
&--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
}
}
// 响应式
@media (max-width: 768px) {
.exam-result-container {
.result-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.statistics-section {
.stats-grid {
grid-template-columns: 1fr;
}
}
.round-scores-section {
.round-scores {
grid-template-columns: 1fr;
}
}
.type-analysis-section {
.type-stats {
grid-template-columns: 1fr;
}
}
.mistakes-section {
.answer-comparison {
flex-direction: column;
gap: 12px !important;
}
}
.action-buttons {
flex-wrap: wrap;
.el-button {
flex: 1;
min-width: 0;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,913 @@
<template>
<div class="practice-report-container">
<div class="report-header card">
<div class="header-content">
<h1 class="report-title">陪练分析报告</h1>
<div class="report-meta">
<span>
<el-icon><Calendar /></el-icon>
{{ reportData.date }}
</span>
<span>
<el-icon><Clock /></el-icon>
通话时长{{ reportData.duration }}
</span>
<span>
<el-icon><ChatDotRound /></el-icon>
对话轮次{{ reportData.turns }}
</span>
</div>
</div>
<div class="header-actions">
<el-button @click="downloadReport">
<el-icon class="el-icon--left"><Download /></el-icon>
下载报告
</el-button>
<el-button type="primary" @click="startNewPractice">
再练一次
</el-button>
</div>
</div>
<!-- 综合评分 -->
<div class="score-section card">
<h2 class="section-title">综合评分</h2>
<div class="score-content">
<div class="total-score">
<div class="score-circle">
<el-progress
type="circle"
:percentage="reportData.totalScore"
:width="160"
:stroke-width="12"
:color="getScoreColor(reportData.totalScore)"
>
<template #default="{ percentage }">
<div class="score-text">
<div class="score-value">{{ percentage }}</div>
<div class="score-label">综合得分</div>
</div>
</template>
</el-progress>
</div>
<div class="score-level">
<el-tag :type="getScoreLevel(reportData.totalScore).type" size="large">
{{ getScoreLevel(reportData.totalScore).text }}
</el-tag>
</div>
</div>
<div class="score-breakdown">
<div class="breakdown-item" v-for="item in scoreBreakdown" :key="item.name">
<div class="item-header">
<span class="item-name">{{ item.name }}</span>
<span class="item-score">{{ item.score }}</span>
</div>
<el-progress :percentage="item.score" :color="getScoreColor(item.score)" />
<div class="item-desc">{{ item.description }}</div>
</div>
</div>
</div>
</div>
<!-- 能力维度雷达图 -->
<div class="radar-section card">
<h2 class="section-title">能力维度分析</h2>
<div class="radar-content">
<div class="radar-chart" ref="radarChartRef"></div>
<div class="radar-legend">
<div class="legend-item" v-for="dimension in abilityDimensions" :key="dimension.name">
<div class="legend-header">
<span class="legend-name">{{ dimension.name }}</span>
<span class="legend-score">{{ dimension.score }}</span>
</div>
<el-progress :percentage="dimension.score" :show-text="false" />
<div class="legend-feedback">{{ dimension.feedback }}</div>
</div>
</div>
</div>
</div>
<!-- 对话复盘 -->
<div class="dialogue-section card">
<h2 class="section-title">完整对话复盘</h2>
<div class="dialogue-controls">
<el-button-group>
<el-button
:type="dialogueFilter === 'all' ? 'primary' : ''"
@click="dialogueFilter = 'all'"
>
全部对话
</el-button>
<el-button
:type="dialogueFilter === 'highlight' ? 'primary' : ''"
@click="dialogueFilter = 'highlight'"
>
亮点话术
</el-button>
<el-button
:type="dialogueFilter === 'golden' ? 'primary' : ''"
@click="dialogueFilter = 'golden'"
>
金牌话术
</el-button>
</el-button-group>
</div>
<div class="dialogue-timeline">
<div
class="dialogue-item"
v-for="(item, index) in filteredDialogue"
:key="index"
:class="item.speaker"
>
<div class="speaker-avatar">
<el-avatar :size="36">
{{ item.speaker === 'user' ? 'ME' : 'AI' }}
</el-avatar>
</div>
<div class="dialogue-content">
<div class="content-header">
<span class="speaker-name">{{ item.speaker === 'user' ? '你' : 'AI客户' }}</span>
<span class="dialogue-time">{{ item.time }}</span>
</div>
<div class="content-text">{{ item.content }}</div>
<div class="content-tags" v-if="item.tags && item.tags.length">
<el-tag
v-for="tag in item.tags"
:key="tag"
:type="getTagType(tag)"
size="small"
>
{{ tag }}
</el-tag>
</div>
<div class="content-comment" v-if="item.comment">
<el-icon><InfoFilled /></el-icon>
<span>{{ item.comment }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 改进建议 -->
<div class="suggestions-section card">
<h2 class="section-title">改进建议</h2>
<div class="suggestions-list">
<div class="suggestion-item" v-for="(suggestion, index) in suggestions" :key="index">
<div class="suggestion-icon">
<el-icon :size="24" :color="suggestion.color">
<component :is="suggestion.icon" />
</el-icon>
</div>
<div class="suggestion-content">
<h3>{{ suggestion.title }}</h3>
<p>{{ suggestion.content }}</p>
<div class="suggestion-example" v-if="suggestion.example">
<span class="example-label">示例</span>
<span class="example-text">{{ suggestion.example }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { practiceApi } from '@/api/practice'
import * as echarts from 'echarts'
const router = useRouter()
const route = useRoute()
// 雷达图引用
const radarChartRef = ref()
let radarChart: any = null
// 报告数据
const reportData = ref({
date: '2024-03-20 14:30',
duration: '12分35秒',
turns: 16,
totalScore: 85
})
// 分数细分
const scoreBreakdown = ref([
{
name: '开场技巧',
score: 90,
description: '开场自然,快速建立信任'
},
{
name: '需求挖掘',
score: 85,
description: '能够有效识别客户需求'
},
{
name: '产品介绍',
score: 88,
description: '产品介绍清晰,重点突出'
},
{
name: '异议处理',
score: 78,
description: '处理客户异议还需加强'
},
{
name: '成交技巧',
score: 82,
description: '成交话术运用良好'
}
])
// 能力维度
const abilityDimensions = ref([
{
name: '沟通表达',
score: 88,
feedback: '语言流畅,表达清晰,但语速可以适当放慢'
},
{
name: '倾听理解',
score: 92,
feedback: '能够准确理解客户意图,给予恰当回应'
},
{
name: '情绪控制',
score: 85,
feedback: '整体情绪稳定,面对异议时保持专业'
},
{
name: '专业知识',
score: 90,
feedback: '产品知识扎实,能够准确回答客户问题'
},
{
name: '销售技巧',
score: 83,
feedback: '基本销售流程掌握良好,细节还可优化'
},
{
name: '应变能力',
score: 80,
feedback: '面对突发问题反应较快,但处理方式可更灵活'
}
])
// 对话记录
const dialogueRecords = ref([
{
speaker: 'user',
time: '00:05',
content: '您好王女士我是XX保险公司的销售顾问小张今天打电话给您是想了解一下您对保险产品的需求。',
tags: ['亮点话术'],
comment: '开场专业,身份介绍清晰'
},
{
speaker: 'ai',
time: '00:12',
content: '哦,保险啊,我现在已经有社保了,不需要额外的保险。',
tags: [],
comment: ''
},
{
speaker: 'user',
time: '00:18',
content: '王女士,社保确实是基础保障,但您知道吗,社保的报销比例和范围是有限的。比如很多进口药品和先进治疗手段都不在社保范围内。',
tags: ['金牌话术'],
comment: '巧妙引导,从客户角度出发'
},
{
speaker: 'ai',
time: '00:28',
content: '这个我倒是听说过,但是保险费用会不会很贵?',
tags: [],
comment: ''
},
{
speaker: 'user',
time: '00:35',
content: '王女士,其实保险的费用是根据您的年龄和保额来定的。像您这个年龄,每月只需要几百元,就相当于每天一杯咖啡的钱,却能获得几十万的保障。',
tags: ['亮点话术'],
comment: '类比生动,让客户容易理解'
}
])
// 对话筛选
const dialogueFilter = ref('all')
// 筛选后的对话
const filteredDialogue = computed(() => {
if (dialogueFilter.value === 'all') {
return dialogueRecords.value
}
return dialogueRecords.value.filter(item =>
item.tags && item.tags.includes(
dialogueFilter.value === 'highlight' ? '亮点话术' : '金牌话术'
)
)
})
// 改进建议
const suggestions = ref([
{
icon: 'Clock',
color: '#e6a23c',
title: '控制语速',
content: '您的语速偏快,建议适当放慢,给客户更多思考时间',
example: '说完产品优势后停顿2-3秒观察客户反应'
},
{
icon: 'QuestionFilled',
color: '#667eea',
title: '多用开放式问题',
content: '增加开放式问题的使用,更深入了解客户需求',
example: '"您对未来的保障有什么期望?"而不是"您需要保险吗?"'
},
{
icon: 'Trophy',
color: '#67c23a',
title: '强化成交信号识别',
content: '客户已经表现出兴趣时,要及时推进成交',
example: '当客户问"费用多少"时,这是购买信号,应该立即报价并促成'
}
])
/**
* 获取分数颜色
*/
const getScoreColor = (score: number) => {
if (score >= 90) return '#67c23a'
if (score >= 80) return '#409eff'
if (score >= 70) return '#e6a23c'
return '#f56c6c'
}
/**
* 获取分数等级
*/
const getScoreLevel = (score: number) => {
if (score >= 90) return { type: 'success', text: '优秀' }
if (score >= 80) return { type: '', text: '良好' }
if (score >= 70) return { type: 'warning', text: '及格' }
return { type: 'danger', text: '待提升' }
}
/**
* 获取标签类型
*/
const getTagType = (tag: string) => {
if (tag === '金牌话术') return 'warning'
if (tag === '亮点话术') return 'success'
return ''
}
/**
* 下载报告
*/
const downloadReport = () => {
ElMessage.success('报告下载中...')
}
/**
* 初始化雷达图
*/
const initRadarChart = () => {
if (!radarChartRef.value) return
radarChart = echarts.init(radarChartRef.value)
const option = {
title: {
text: '能力维度雷达图',
left: 'center',
top: 10,
textStyle: {
fontSize: 16,
fontWeight: 600,
color: '#333'
}
},
tooltip: {
trigger: 'item',
formatter: function(params: any) {
return `${params.name}: ${params.value}`
}
},
radar: {
indicator: abilityDimensions.value.map(item => ({
name: item.name,
max: 100
})),
center: ['50%', '55%'],
radius: '70%',
startAngle: 90,
splitNumber: 5,
shape: 'polygon',
name: {
formatter: '{value}',
textStyle: {
color: '#666',
fontSize: 13
}
},
splitArea: {
areaStyle: {
color: ['rgba(102, 126, 234, 0.05)', 'rgba(102, 126, 234, 0.1)', 'rgba(102, 126, 234, 0.15)', 'rgba(102, 126, 234, 0.2)', 'rgba(102, 126, 234, 0.25)']
}
},
axisLine: {
lineStyle: {
color: 'rgba(102, 126, 234, 0.3)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(102, 126, 234, 0.3)'
}
}
},
series: [{
name: '能力评估',
type: 'radar',
data: [{
value: abilityDimensions.value.map(item => item.score),
name: '当前表现',
areaStyle: {
color: 'rgba(102, 126, 234, 0.2)'
},
lineStyle: {
color: '#667eea',
width: 3
},
itemStyle: {
color: '#667eea',
borderWidth: 2,
borderColor: '#fff'
}
}]
}]
}
radarChart.setOption(option)
// 响应式处理
window.addEventListener('resize', () => {
radarChart?.resize()
})
}
/**
* 开始新的练习
*/
const startNewPractice = () => {
router.push('/trainee/ai-practice')
}
/**
* 组件挂载时初始化
*/
onMounted(async () => {
// 1. 从URL获取sessionId从路径参数
const sessionId = route.params.id as string
if (!sessionId) {
ElMessage.error('缺少会话ID')
router.push('/trainee/ai-practice-center')
return
}
console.log('[PracticeReport] 加载报告sessionId=', sessionId)
// 2. 加载报告数据
try {
const response: any = await practiceApi.getPracticeReport(sessionId)
if (response.code === 200 && response.data) {
const data = response.data
// 3. 填充会话信息
const startTime = new Date(data.session_info.start_time)
const durationMin = Math.floor(data.session_info.duration_seconds / 60)
const durationSec = data.session_info.duration_seconds % 60
reportData.value = {
date: startTime.toLocaleString('zh-CN'),
duration: `${durationMin}${durationSec}`,
turns: data.session_info.turns,
totalScore: data.analysis.total_score
}
// 4. 填充分析数据
scoreBreakdown.value = data.analysis.score_breakdown
abilityDimensions.value = data.analysis.ability_dimensions
dialogueRecords.value = data.analysis.dialogue_review
suggestions.value = data.analysis.suggestions
// 5. 初始化图表
nextTick(() => {
initRadarChart()
})
} else {
ElMessage.error('加载报告失败')
router.push('/trainee/ai-practice-center')
}
} catch (error: any) {
console.error('加载报告失败:', error)
ElMessage.error(error.message || '加载报告失败')
router.push('/trainee/ai-practice-center')
}
// 6. 响应式处理
window.addEventListener('resize', handleResize)
})
/**
* 组件卸载时清理资源
*/
onUnmounted(() => {
// 销毁图表实例
radarChart?.dispose()
// 移除事件监听
window.removeEventListener('resize', handleResize)
})
/**
* 窗口大小改变处理
*/
const handleResize = () => {
radarChart?.resize()
}
</script>
<style lang="scss" scoped>
.practice-report-container {
max-width: 1200px;
margin: 0 auto;
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
margin-bottom: 24px;
.header-content {
.report-title {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.report-meta {
display: flex;
gap: 24px;
font-size: 14px;
color: #666;
span {
display: flex;
align-items: center;
gap: 6px;
.el-icon {
color: #909399;
}
}
}
}
.header-actions {
display: flex;
gap: 12px;
.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
.section-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.score-section {
padding: 32px;
margin-bottom: 24px;
.score-content {
display: grid;
grid-template-columns: 300px 1fr;
gap: 48px;
.total-score {
text-align: center;
.score-circle {
margin-bottom: 24px;
.score-text {
.score-value {
font-size: 48px;
font-weight: 700;
color: #333;
}
.score-label {
font-size: 14px;
color: #666;
margin-top: 4px;
}
}
}
}
.score-breakdown {
display: grid;
gap: 20px;
.breakdown-item {
.item-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
.item-name {
font-size: 15px;
font-weight: 500;
color: #333;
}
.item-score {
font-size: 14px;
color: #667eea;
font-weight: 600;
}
}
.item-desc {
font-size: 13px;
color: #909399;
margin-top: 6px;
}
}
}
}
}
.radar-section {
padding: 32px;
margin-bottom: 24px;
.radar-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
.radar-chart {
height: 400px;
width: 100%;
}
.radar-legend {
.legend-item {
margin-bottom: 24px;
.legend-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
.legend-name {
font-size: 15px;
font-weight: 500;
color: #333;
}
.legend-score {
font-size: 14px;
color: #667eea;
font-weight: 600;
}
}
.legend-feedback {
font-size: 13px;
color: #666;
margin-top: 8px;
line-height: 1.5;
}
}
}
}
}
.dialogue-section {
padding: 32px;
margin-bottom: 24px;
.dialogue-controls {
margin-bottom: 24px;
.el-button-group {
.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
}
}
}
.dialogue-timeline {
.dialogue-item {
display: flex;
gap: 16px;
margin-bottom: 24px;
&.ai {
flex-direction: row-reverse;
.dialogue-content {
background: #f5f7fa;
}
}
.speaker-avatar {
flex-shrink: 0;
}
.dialogue-content {
flex: 1;
max-width: 70%;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 12px;
padding: 16px;
.content-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
.speaker-name {
font-size: 14px;
font-weight: 500;
color: #333;
}
.dialogue-time {
font-size: 12px;
color: #909399;
}
}
.content-text {
font-size: 15px;
color: #333;
line-height: 1.6;
}
.content-tags {
margin-top: 12px;
display: flex;
gap: 8px;
}
.content-comment {
margin-top: 12px;
padding: 8px 12px;
background: #f5f7fa;
border-radius: 6px;
font-size: 13px;
color: #666;
display: flex;
align-items: flex-start;
gap: 6px;
.el-icon {
color: #667eea;
margin-top: 2px;
flex-shrink: 0;
}
}
}
}
}
}
.suggestions-section {
padding: 32px;
.suggestions-list {
.suggestion-item {
display: flex;
gap: 20px;
margin-bottom: 24px;
padding: 20px;
background: #f9fafb;
border-radius: 12px;
&:last-child {
margin-bottom: 0;
}
.suggestion-icon {
width: 48px;
height: 48px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.suggestion-content {
flex: 1;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
line-height: 1.6;
margin-bottom: 12px;
}
.suggestion-example {
padding: 12px;
background: white;
border-radius: 8px;
font-size: 13px;
.example-label {
color: #909399;
margin-right: 8px;
}
.example-text {
color: #333;
font-style: italic;
}
}
}
}
}
}
}
// 响应式
@media (max-width: 1024px) {
.practice-report-container {
.report-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.score-content {
grid-template-columns: 1fr !important;
}
.radar-content {
grid-template-columns: 1fr !important;
}
}
}
@media (max-width: 768px) {
.practice-report-container {
.dialogue-item {
.dialogue-content {
max-width: 85% !important;
}
}
.suggestion-item {
flex-direction: column;
text-align: center;
.suggestion-icon {
margin: 0 auto;
}
}
}
}
</style>

View File

@@ -0,0 +1,790 @@
<template>
<div class="score-query-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="score-overview">
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(102, 126, 234, 0.1)">
<el-icon :size="32" color="#667eea">
<Document />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ overviewData.total_exams || 0 }}</div>
<div class="card-label">总考试次数</div>
</div>
</div>
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(103, 194, 58, 0.1)">
<el-icon :size="32" color="#67c23a">
<TrendCharts />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ overviewData.avg_score?.toFixed(1) || '0.0' }}</div>
<div class="card-label">平均分</div>
</div>
</div>
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(230, 162, 60, 0.1)">
<el-icon :size="32" color="#e6a23c">
<CircleCheck />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ (overviewData.pass_rate || 0).toFixed(1) }}%</div>
<div class="card-label">通过率</div>
</div>
</div>
<div class="overview-card card">
<div class="card-icon" style="background-color: rgba(64, 158, 255, 0.1)">
<el-icon :size="32" color="#409eff">
<EditPen />
</el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ overviewData.total_questions || 0 }}</div>
<div class="card-label">答题总数</div>
</div>
</div>
</div>
<!-- 筛选和搜索 -->
<div class="filter-section card">
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="课程">
<el-select v-model="filterForm.courseId" placeholder="请选择课程" clearable style="width: 200px">
<el-option label="全部课程" :value="null" />
<el-option
v-for="course in courseList"
:key="course.id"
:label="course.name"
:value="course.id"
/>
</el-select>
</el-form-item>
<el-form-item label="成绩范围">
<el-select v-model="filterForm.scoreRange" placeholder="请选择" clearable style="width: 180px">
<el-option label="全部成绩" value="" />
<el-option label="优秀 (90-100)" value="excellent" />
<el-option label="良好 (80-89)" value="good" />
<el-option label="及格 (60-79)" value="pass" />
<el-option label="不及格 (0-59)" value="fail" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon class="el-icon--left"><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 成绩列表 -->
<div class="score-list card">
<el-table
:data="filteredScoreList"
style="width: 100%"
v-loading="loading"
@row-click="viewScoreDetail"
>
<el-table-column prop="exam_name" label="考试名称" min-width="200" />
<el-table-column prop="course_name" label="课程" width="150" />
<el-table-column prop="score" label="得分" width="100" sortable>
<template #default="scope">
<span :class="getScoreClass(scope.row.score)" class="score-text">
{{ scope.row.score ?? '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="total_score" label="总分" width="80">
<template #default="scope">
<span class="total-score">{{ scope.row.total_score }}</span>
</template>
</el-table-column>
<el-table-column prop="accuracy" label="正确率" width="100" sortable>
<template #default="scope">
<el-progress
v-if="scope.row.accuracy !== null"
:percentage="scope.row.accuracy"
:color="getAccuracyColor(scope.row.accuracy)"
:stroke-width="8"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="duration_seconds" label="用时" width="100">
<template #default="scope">
<span>{{ formatDuration(scope.row.duration_seconds) }}</span>
</template>
</el-table-column>
<el-table-column prop="start_time" label="考试时间" width="180" sortable>
<template #default="scope">
{{ formatDateTime(scope.row.start_time) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusTag(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click.stop="viewScoreDetail(scope.row)">
<el-icon><View /></el-icon>
查看详情
</el-button>
<el-button link type="danger" size="small" @click.stop="viewMistakes(scope.row)">
<el-icon><Warning /></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="detailDialogVisible"
:title="`${currentScore?.exam_name} - 成绩详情`"
width="800px"
>
<div class="score-detail" v-if="currentScore">
<!-- 基本信息 -->
<div class="detail-section">
<h3>基本信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="考试名称">{{ currentScore.exam_name }}</el-descriptions-item>
<el-descriptions-item label="课程">{{ currentScore.course_name }}</el-descriptions-item>
<el-descriptions-item label="考试时间">{{ formatDateTime(currentScore.start_time) }}</el-descriptions-item>
<el-descriptions-item label="用时">{{ formatDuration(currentScore.duration_seconds) }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTag(currentScore.status)">
{{ getStatusText(currentScore.status) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 成绩统计 -->
<div class="detail-section">
<h3>成绩统计</h3>
<div class="score-stats">
<div class="stat-item">
<div class="stat-label">总得分</div>
<div class="stat-value score" :class="getScoreClass(currentScore.score)">
{{ currentScore.score ?? '-' }}/{{ currentScore.total_score }}
</div>
</div>
<div class="stat-item">
<div class="stat-label">正确率</div>
<div class="stat-value">
<el-progress
v-if="currentScore.accuracy !== null"
:percentage="currentScore.accuracy"
:color="getAccuracyColor(currentScore.accuracy)"
:stroke-width="12"
/>
<span v-else>-</span>
</div>
</div>
<div class="stat-item">
<div class="stat-label">题目统计</div>
<div class="stat-value">
<div class="question-stats">
<span class="correct">正确: {{ currentScore.correct_count ?? 0 }}</span>
<span class="wrong">错误: {{ currentScore.wrong_count ?? 0 }}</span>
<span class="total">总计: {{ currentScore.question_count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 分题型统计 -->
<div class="detail-section" v-if="currentScore.question_type_stats && currentScore.question_type_stats.length > 0">
<h3>分题型统计</h3>
<el-table :data="currentScore.question_type_stats" size="small">
<el-table-column prop="type" label="题型" width="120">
<template #default="scope">
<el-tag size="small">{{ scope.row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="total" label="总题数" width="100" />
<el-table-column prop="correct" label="正确数" width="100" />
<el-table-column prop="wrong" label="错误数" width="100" />
<el-table-column prop="accuracy" label="正确率" width="150">
<template #default="scope">
<el-progress
:percentage="scope.row.accuracy"
:stroke-width="6"
:show-text="false"
/>
<span style="margin-left: 8px;">{{ scope.row.accuracy }}%</span>
</template>
</el-table-column>
</el-table>
</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 {
getExamRecords,
getExamStatistics,
getCourseList,
type ExamRecord,
type ExamStatistics as ExamStatsType
} from '@/api/score'
const router = useRouter()
// 加载状态
const loading = ref(false)
// 日期范围
const dateRange = ref<[Date, Date]>([
new Date(new Date().setDate(new Date().getDate() - 30)),
new Date()
])
// 分页
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
// 弹窗状态
const detailDialogVisible = ref(false)
const currentScore = ref<ExamRecord | null>(null)
// 筛选表单
const filterForm = reactive({
courseId: null as number | null,
scoreRange: ''
})
// 概览数据
const overviewData = ref<ExamStatsType>({
total_exams: 0,
avg_score: 0,
pass_rate: 0,
total_questions: 0
})
// 课程列表
const courseList = ref<any[]>([])
// 成绩列表数据
const scoreList = ref<ExamRecord[]>([])
// 筛选后的成绩列表(前端筛选成绩范围)
const filteredScoreList = computed(() => {
let filtered = scoreList.value
// 按成绩范围筛选(前端过滤)
if (filterForm.scoreRange) {
filtered = filtered.filter(item => {
const score = item.score
if (score === null) return false
switch (filterForm.scoreRange) {
case 'excellent':
return score >= 90
case 'good':
return score >= 80 && score < 90
case 'pass':
return score >= 60 && score < 80
case 'fail':
return score < 60
default:
return true
}
})
}
return filtered
})
/**
* 获取分数样式类
*/
const getScoreClass = (score: number | null) => {
if (score === null) return ''
if (score >= 90) return 'score-excellent'
if (score >= 80) return 'score-good'
if (score >= 60) return 'score-pass'
return 'score-fail'
}
/**
* 获取正确率颜色
*/
const getAccuracyColor = (accuracy: number) => {
if (accuracy >= 90) return '#67c23a'
if (accuracy >= 80) return '#409eff'
if (accuracy >= 60) return '#e6a23c'
return '#f56c6c'
}
/**
* 获取状态标签样式
*/
const getStatusTag = (status: string) => {
const tagMap: Record<string, string> = {
completed: 'success',
submitted: 'success',
in_progress: 'warning',
started: 'warning',
timeout: 'info',
failed: 'danger'
}
return tagMap[status] || ''
}
/**
* 获取状态文本
*/
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
completed: '已完成',
submitted: '已提交',
in_progress: '进行中',
started: '进行中',
timeout: '超时',
failed: '已失败'
}
return textMap[status] || status
}
/**
* 格式化时长
*/
const formatDuration = (seconds: number | null) => {
if (!seconds) return '-'
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 formatDateTime = (dateStr: string | null) => {
if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
}
/**
* 格式化日期为字符串
*/
const formatDate = (date: Date) => {
return date.toISOString().split('T')[0]
}
/**
* 加载概览数据
*/
const loadOverviewData = async () => {
try {
const params: any = {}
if (filterForm.courseId) {
params.course_id = filterForm.courseId
}
const res = await getExamStatistics(params)
if (res.code === 200) {
overviewData.value = res.data
}
} catch (error) {
console.error('获取概览数据失败:', error)
}
}
/**
* 加载成绩列表
*/
const loadScoreList = async () => {
loading.value = true
try {
const params: any = {
page: currentPage.value,
size: pageSize.value
}
if (filterForm.courseId) {
params.course_id = filterForm.courseId
}
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = formatDate(dateRange.value[0])
params.end_date = formatDate(dateRange.value[1])
}
const res = await getExamRecords(params)
if (res.code === 200) {
scoreList.value = res.data.items
total.value = res.data.total
}
} catch (error) {
console.error('获取成绩列表失败:', error)
ElMessage.error('获取成绩列表失败')
} finally {
loading.value = false
}
}
/**
* 加载课程列表
*/
const loadCourseList = async () => {
try {
const res = await getCourseList()
if (res.code === 200) {
courseList.value = res.data.items || []
}
} catch (error) {
console.error('获取课程列表失败:', error)
}
}
/**
* 日期变化处理
*/
const handleDateChange = () => {
handleSearch()
}
/**
* 刷新数据
*/
const refreshData = async () => {
await Promise.all([loadOverviewData(), loadScoreList()])
ElMessage.success('数据已刷新')
}
/**
* 搜索处理
*/
const handleSearch = () => {
currentPage.value = 1
loadOverviewData()
loadScoreList()
}
/**
* 重置筛选
*/
const handleReset = () => {
filterForm.courseId = null
filterForm.scoreRange = ''
dateRange.value = [
new Date(new Date().setDate(new Date().getDate() - 30)),
new Date()
]
handleSearch()
}
/**
* 分页大小改变
*/
const handleSizeChange = (val: number) => {
pageSize.value = val
currentPage.value = 1
loadScoreList()
}
/**
* 当前页改变
*/
const handleCurrentChange = (val: number) => {
currentPage.value = val
loadScoreList()
}
/**
* 查看成绩详情
*/
const viewScoreDetail = (score: ExamRecord) => {
currentScore.value = score
detailDialogVisible.value = true
}
/**
* 查看错题本
*/
const viewMistakes = (exam: ExamRecord) => {
// 跳转到错题本页面并带上课程ID和考试ID筛选
router.push({
path: '/analysis/mistakes',
query: {
course_id: exam.course_id,
exam_id: exam.id
}
})
}
// 组件挂载时初始化数据
onMounted(() => {
loadCourseList()
loadOverviewData()
loadScoreList()
})
</script>
<style lang="scss" scoped>
.score-query-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;
}
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.score-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 24px;
.overview-card {
padding: 24px;
display: flex;
align-items: center;
gap: 20px;
.card-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-content {
flex: 1;
.card-value {
font-size: 32px;
font-weight: 700;
color: #333;
line-height: 1;
margin-bottom: 8px;
}
.card-label {
font-size: 14px;
color: #666;
}
}
}
}
.filter-section {
padding: 20px;
margin-bottom: 20px;
.filter-form {
.el-form-item {
margin-bottom: 0;
}
}
}
.score-list {
padding: 24px;
.score-text {
font-weight: 600;
font-size: 16px;
&.score-excellent {
color: #67c23a;
}
&.score-good {
color: #409eff;
}
&.score-pass {
color: #e6a23c;
}
&.score-fail {
color: #f56c6c;
}
}
.total-score {
color: #666;
font-size: 14px;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 24px;
}
}
.score-detail {
.detail-section {
margin-bottom: 24px;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #f0f0f0;
}
}
.score-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
.stat-item {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 12px;
}
.stat-value {
&.score {
font-size: 24px;
font-weight: 700;
}
.question-stats {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 14px;
.correct {
color: #67c23a;
}
.wrong {
color: #f56c6c;
}
.total {
color: #333;
font-weight: 500;
}
}
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.score-query-container {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.header-actions {
width: 100%;
justify-content: flex-end;
}
}
.score-overview {
grid-template-columns: 1fr;
}
.filter-form {
.el-form-item {
display: block;
margin-bottom: 16px !important;
}
}
.score-stats {
grid-template-columns: 1fr;
}
}
}
</style>

View File

@@ -0,0 +1,376 @@
<template>
<div class="change-password-container">
<div class="page-header">
<h1 class="page-title">修改密码</h1>
</div>
<div class="password-form card">
<el-form
ref="formRef"
:model="passwordForm"
:rules="rules"
label-width="120px"
@submit.prevent="handleSubmit"
>
<el-form-item label="当前密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
placeholder="请输入当前密码"
show-password
maxlength="20"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
placeholder="请输入新密码"
show-password
maxlength="20"
/>
<div class="password-tips">
<p>密码要求</p>
<ul>
<li>长度 8-20 个字符</li>
<li>至少包含大小写字母数字中的两种</li>
<li>不能包含常见的弱密码</li>
</ul>
</div>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password
maxlength="20"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
size="large"
>
修改密码
</el-button>
<el-button @click="handleReset" size="large">
重置
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 密码强度指示器 -->
<div class="password-strength card" v-if="passwordForm.newPassword">
<h3>密码强度</h3>
<div class="strength-bar">
<div
class="strength-fill"
:class="strengthClass"
:style="{ width: strengthPercentage + '%' }"
></div>
</div>
<div class="strength-text">{{ strengthText }}</div>
</div>
<!-- 安全提示 -->
<div class="security-tips card">
<h3>安全提示</h3>
<ul>
<li>定期更换密码建议每 3-6 个月更换一次</li>
<li>不要使用与其他网站相同的密码</li>
<li>不要将密码告诉他人或写在容易被发现的地方</li>
<li>如发现账号异常请立即修改密码并联系管理员</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { changePassword } from '@/api/user'
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
const router = useRouter()
// 密码表单
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
// 表单验证规则
const rules = reactive<FormRules>({
oldPassword: [
{ required: true, message: '请输入当前密码', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, max: 20, message: '密码长度在 8 到 20 个字符', trigger: 'blur' },
{ validator: validatePassword, trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
})
/**
* 验证密码强度
*/
function validatePassword(_rule: any, value: string, callback: any) {
if (!value) {
callback()
return
}
// 检查是否包含至少两种类型的字符
const hasLower = /[a-z]/.test(value)
const hasUpper = /[A-Z]/.test(value)
const hasNumber = /\d/.test(value)
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(value)
const typeCount = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean).length
if (typeCount < 2) {
callback(new Error('密码至少包含大小写字母、数字、特殊字符中的两种'))
return
}
// 检查常见弱密码
const weakPasswords = ['12345678', 'password', 'qwerty', '11111111', '00000000']
if (weakPasswords.includes(value.toLowerCase())) {
callback(new Error('不能使用常见的弱密码'))
return
}
callback()
}
/**
* 验证确认密码
*/
function validateConfirmPassword(_rule: any, value: string, callback: any) {
if (!value) {
callback()
return
}
if (value !== passwordForm.newPassword) {
callback(new Error('两次输入的密码不一致'))
return
}
callback()
}
/**
* 密码强度计算
*/
const passwordStrength = computed(() => {
const password = passwordForm.newPassword
if (!password) return 0
let score = 0
// 长度分数
if (password.length >= 8) score += 25
if (password.length >= 12) score += 25
// 字符类型分数
if (/[a-z]/.test(password)) score += 10
if (/[A-Z]/.test(password)) score += 10
if (/\d/.test(password)) score += 10
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score += 20
return Math.min(score, 100)
})
const strengthPercentage = computed(() => passwordStrength.value)
const strengthClass = computed(() => {
const strength = passwordStrength.value
if (strength < 30) return 'weak'
if (strength < 60) return 'medium'
if (strength < 80) return 'strong'
return 'very-strong'
})
const strengthText = computed(() => {
const strength = passwordStrength.value
if (strength < 30) return '弱'
if (strength < 60) return '中等'
if (strength < 80) return '强'
return '很强'
})
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
// 调用真实API
await changePassword({
old_password: passwordForm.oldPassword,
new_password: passwordForm.newPassword
})
ElMessage.success('密码修改成功,请重新登录')
handleReset()
// 清除token并跳转登录页
localStorage.removeItem('token')
router.push('/login')
} catch (error: any) {
ElMessage.error(error.message || '密码修改失败,请重试')
} finally {
submitLoading.value = false
}
}
})
}
/**
* 重置表单
*/
const handleReset = () => {
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
formRef.value?.resetFields()
}
</script>
<style lang="scss" scoped>
.change-password-container {
max-width: 600px;
margin: 0 auto;
.page-header {
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
}
.password-form {
padding: 32px;
margin-bottom: 24px;
.password-tips {
margin-top: 8px;
font-size: 12px;
color: #666;
p {
margin: 0 0 4px 0;
font-weight: 500;
}
ul {
margin: 0;
padding-left: 16px;
li {
margin-bottom: 2px;
}
}
}
.el-button {
margin-right: 12px;
}
}
.password-strength {
padding: 24px;
margin-bottom: 24px;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.strength-bar {
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
.strength-fill {
height: 100%;
transition: width 0.3s, background-color 0.3s;
border-radius: 4px;
&.weak {
background: linear-gradient(90deg, #ff4757, #ff6b7a);
}
&.medium {
background: linear-gradient(90deg, #ffa502, #ff6348);
}
&.strong {
background: linear-gradient(90deg, #2ed573, #7bed9f);
}
&.very-strong {
background: linear-gradient(90deg, #5352ed, #70a1ff);
}
}
}
.strength-text {
font-size: 14px;
font-weight: 500;
color: #666;
}
}
.security-tips {
padding: 24px;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
ul {
margin: 0;
padding-left: 20px;
color: #666;
line-height: 1.6;
li {
margin-bottom: 8px;
}
}
}
}
</style>

View File

@@ -0,0 +1,554 @@
<template>
<div class="notifications-container">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">消息中心</h1>
<div class="header-actions">
<el-button
v-if="unreadCount > 0"
type="primary"
plain
@click="handleMarkAllRead"
>
<el-icon><Check /></el-icon>
全部标记已读
</el-button>
</div>
</div>
<!-- 筛选区域 -->
<div class="filter-section card">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane label="全部" name="all">
<template #label>
<span>全部</span>
<el-badge v-if="total > 0" :value="total" :max="99" class="tab-badge" />
</template>
</el-tab-pane>
<el-tab-pane label="未读" name="unread">
<template #label>
<span>未读</span>
<el-badge v-if="unreadCount > 0" :value="unreadCount" :max="99" class="tab-badge" type="danger" />
</template>
</el-tab-pane>
<el-tab-pane label="岗位通知" name="position_assign" />
<el-tab-pane label="课程通知" name="course_assign" />
<el-tab-pane label="系统通知" name="system" />
</el-tabs>
</div>
<!-- 通知列表 -->
<div class="notification-list card" v-loading="loading">
<template v-if="notifications.length > 0">
<div
v-for="item in notifications"
:key="item.id"
class="notification-item"
:class="{ unread: !item.is_read }"
>
<div class="item-left">
<div class="item-icon" :class="getTypeClass(item.type)">
<el-icon :size="20">
<component :is="getTypeIcon(item.type)" />
</el-icon>
</div>
</div>
<div class="item-main">
<div class="item-header">
<span class="item-title">{{ item.title }}</span>
<el-tag size="small" :type="getTypeTagType(item.type)">
{{ getTypeName(item.type) }}
</el-tag>
</div>
<div class="item-content">{{ item.content }}</div>
<div class="item-footer">
<span class="item-time">
<el-icon><Clock /></el-icon>
{{ formatTime(item.created_at) }}
</span>
<span v-if="item.sender_name" class="item-sender">
来自{{ item.sender_name }}
</span>
</div>
</div>
<div class="item-actions">
<el-button
v-if="!item.is_read"
link
type="primary"
size="small"
@click="handleMarkRead(item)"
>
标记已读
</el-button>
<el-button
v-if="item.related_id"
link
type="primary"
size="small"
@click="handleViewDetail(item)"
>
查看详情
</el-button>
<el-button
link
type="danger"
size="small"
@click="handleDelete(item)"
>
删除
</el-button>
</div>
</div>
</template>
<el-empty v-else description="暂无消息" :image-size="120" />
</div>
<!-- 分页 -->
<div v-if="total > pageSize" class="pagination-section">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Bell, Check, Clock,
OfficeBuilding, Notebook, Document, InfoFilled
} from '@element-plus/icons-vue'
import { notificationApi, type Notification, type NotificationType } from '@/api/notification'
const router = useRouter()
// 状态
const loading = ref(false)
const activeTab = ref('all')
const notifications = ref<Notification[]>([])
const total = ref(0)
const unreadCount = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
/**
* 加载通知列表
*/
const loadNotifications = async () => {
loading.value = true
try {
const params: any = {
page: currentPage.value,
page_size: pageSize.value
}
// 根据tab筛选
if (activeTab.value === 'unread') {
params.is_read = false
} else if (activeTab.value !== 'all') {
params.type = activeTab.value
}
const res = await notificationApi.getNotifications(params)
if (res.code === 200 && res.data) {
notifications.value = res.data.items
total.value = res.data.total
unreadCount.value = res.data.unread_count
}
} catch (error) {
ElMessage.error('加载消息失败')
} finally {
loading.value = false
}
}
/**
* Tab切换
*/
const handleTabChange = () => {
currentPage.value = 1
loadNotifications()
}
/**
* 分页切换
*/
const handlePageChange = () => {
loadNotifications()
}
/**
* 标记单条已读
*/
const handleMarkRead = async (item: Notification) => {
try {
await notificationApi.markAsRead([item.id])
item.is_read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
ElMessage.success('已标记为已读')
} catch (error) {
ElMessage.error('操作失败')
}
}
/**
* 标记全部已读
*/
const handleMarkAllRead = async () => {
try {
await ElMessageBox.confirm('确定将所有消息标记为已读?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
await notificationApi.markAsRead()
unreadCount.value = 0
notifications.value = notifications.value.map(n => ({ ...n, is_read: true }))
ElMessage.success('已全部标记为已读')
} catch (error) {
// 取消操作不处理
}
}
/**
* 查看详情
*/
const handleViewDetail = (item: Notification) => {
// 先标记已读
if (!item.is_read) {
handleMarkRead(item)
}
// 根据类型跳转
if (item.related_type === 'course' && item.related_id) {
router.push(`/trainee/course/${item.related_id}`)
} else if (item.related_type === 'position') {
router.push('/trainee/course-center')
}
}
/**
* 删除通知
*/
const handleDelete = async (item: Notification) => {
try {
await ElMessageBox.confirm('确定删除这条消息?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await notificationApi.deleteNotification(item.id)
// 从列表中移除
const index = notifications.value.findIndex(n => n.id === item.id)
if (index > -1) {
notifications.value.splice(index, 1)
total.value--
if (!item.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
ElMessage.success('删除成功')
} catch (error) {
// 取消操作不处理
}
}
/**
* 获取通知类型图标
*/
const getTypeIcon = (type: NotificationType) => {
const iconMap: Record<NotificationType, any> = {
position_assign: OfficeBuilding,
course_assign: Notebook,
exam_remind: Clock,
task_assign: Document,
system: InfoFilled
}
return iconMap[type] || InfoFilled
}
/**
* 获取通知类型样式类
*/
const getTypeClass = (type: NotificationType) => {
const classMap: Record<NotificationType, string> = {
position_assign: 'type-position',
course_assign: 'type-course',
exam_remind: 'type-exam',
task_assign: 'type-task',
system: 'type-system'
}
return classMap[type] || 'type-system'
}
/**
* 获取通知类型名称
*/
const getTypeName = (type: NotificationType) => {
const nameMap: Record<NotificationType, string> = {
position_assign: '岗位通知',
course_assign: '课程通知',
exam_remind: '考试提醒',
task_assign: '任务通知',
system: '系统通知'
}
return nameMap[type] || '系统通知'
}
/**
* 获取通知类型标签样式
*/
const getTypeTagType = (type: NotificationType) => {
const tagMap: Record<NotificationType, string> = {
position_assign: 'success',
course_assign: 'primary',
exam_remind: 'warning',
task_assign: 'info',
system: ''
}
return tagMap[type] || ''
}
/**
* 格式化时间
*/
const formatTime = (time: string) => {
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(diff / (1000 * 60 * 60))
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleString()
}
// 组件挂载
onMounted(() => {
loadNotifications()
})
</script>
<style lang="scss" scoped>
.notifications-container {
max-width: 900px;
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: #303133;
margin: 0;
}
}
.filter-section {
padding: 16px 24px 0;
margin-bottom: 16px;
:deep(.el-tabs__header) {
margin-bottom: 0;
}
.tab-badge {
margin-left: 6px;
:deep(.el-badge__content) {
font-size: 10px;
height: 16px;
line-height: 16px;
padding: 0 4px;
}
}
}
.notification-list {
padding: 16px;
.notification-item {
display: flex;
gap: 16px;
padding: 20px;
border-radius: 12px;
transition: all 0.3s;
border: 1px solid transparent;
&:not(:last-child) {
margin-bottom: 12px;
}
&:hover {
background-color: #f5f7fa;
}
&.unread {
background-color: rgba(102, 126, 234, 0.04);
border-color: rgba(102, 126, 234, 0.1);
}
.item-left {
flex-shrink: 0;
.item-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
&.type-position {
background: rgba(103, 194, 58, 0.1);
color: #67c23a;
}
&.type-course {
background: rgba(102, 126, 234, 0.1);
color: #667eea;
}
&.type-exam {
background: rgba(230, 162, 60, 0.1);
color: #e6a23c;
}
&.type-task {
background: rgba(144, 147, 153, 0.1);
color: #909399;
}
&.type-system {
background: rgba(64, 158, 255, 0.1);
color: #409eff;
}
}
}
.item-main {
flex: 1;
min-width: 0;
.item-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.item-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.item-content {
font-size: 14px;
color: #606266;
line-height: 1.6;
margin-bottom: 12px;
}
.item-footer {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: #909399;
.item-time {
display: flex;
align-items: center;
gap: 4px;
}
}
}
.item-actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
}
}
.pagination-section {
display: flex;
justify-content: center;
margin-top: 24px;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
// 响应式
@media (max-width: 768px) {
.notifications-container {
padding: 0 12px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
.page-title {
font-size: 20px;
}
}
.notification-list {
.notification-item {
flex-direction: column;
gap: 12px;
.item-left {
.item-icon {
width: 36px;
height: 36px;
}
}
.item-actions {
flex-direction: row;
justify-content: flex-start;
}
}
}
}
</style>

View File

@@ -0,0 +1,652 @@
<template>
<div class="profile-container">
<div class="page-header">
<h1 class="page-title">个人信息</h1>
</div>
<div class="profile-content">
<!-- 基本信息 -->
<div class="info-section card">
<div class="section-header">
<h2 class="section-title">基本信息</h2>
<el-button v-if="!isEditing" link type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>
编辑
</el-button>
</div>
<div class="info-content">
<div class="avatar-section">
<el-upload
class="avatar-uploader"
:show-file-list="false"
:disabled="!isEditing"
@change="handleAvatarChange"
>
<el-avatar :size="120" :src="userInfo.avatar">
<el-icon :size="60"><UserFilled /></el-icon>
</el-avatar>
<div v-if="isEditing" class="avatar-overlay">
<el-icon :size="24"><Camera /></el-icon>
</div>
</el-upload>
<div class="user-name">{{ userInfo.name }}</div>
<div class="user-role">{{ userInfo.role }}</div>
</div>
<el-form
ref="formRef"
:model="userInfo"
:rules="rules"
label-width="100px"
:disabled="!isEditing"
class="info-form"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="userInfo.username" />
</el-form-item>
<el-form-item label="真实姓名" prop="name">
<el-input v-model="userInfo.name" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="userInfo.gender">
<el-radio label="male"></el-radio>
<el-radio label="female"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="userInfo.phone" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="userInfo.email" />
</el-form-item>
<el-form-item label="学校" prop="school">
<el-input v-model="userInfo.school" />
</el-form-item>
<el-form-item label="专业" prop="major">
<el-input v-model="userInfo.major" />
</el-form-item>
<el-form-item label="个人简介" prop="bio">
<el-input
v-model="userInfo.bio"
type="textarea"
:rows="3"
placeholder="介绍一下自己吧"
/>
</el-form-item>
</el-form>
<div v-if="isEditing" class="form-actions">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存修改</el-button>
</div>
</div>
</div>
<!-- 账号安全 -->
<div class="security-section card">
<h2 class="section-title">账号安全</h2>
<div class="security-item">
<div class="security-info">
<div class="security-label">登录密码</div>
<div class="security-desc">定期修改密码可以提高账号安全性</div>
</div>
<el-button type="primary" @click="handleChangePassword">
修改密码
</el-button>
</div>
</div>
<!-- 学习统计 -->
<div class="stats-section card">
<h2 class="section-title">学习统计</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-icon" style="background-color: rgba(102, 126, 234, 0.1)">
<el-icon :size="24" color="#667eea"><Calendar /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.learningDays }}</div>
<div class="stat-label">学习天数</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon" style="background-color: rgba(103, 194, 58, 0.1)">
<el-icon :size="24" color="#67c23a"><Clock /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.totalHours }}</div>
<div class="stat-label">学习时长(小时)</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon" style="background-color: rgba(230, 162, 60, 0.1)">
<el-icon :size="24" color="#e6a23c"><Document /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.practiceQuestions }}</div>
<div class="stat-label">练习题数</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon" style="background-color: rgba(245, 108, 108, 0.1)">
<el-icon :size="24" color="#f56c6c"><Trophy /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.averageScore }}</div>
<div class="stat-label">平均成绩</div>
</div>
</div>
</div>
</div>
<!-- 成就徽章 -->
<div class="achievement-section card">
<h2 class="section-title">成就徽章</h2>
<div class="achievement-grid">
<div class="achievement-item" v-for="badge in achievements" :key="badge.id">
<div class="badge-icon" :class="{ locked: !badge.unlocked }">
<el-icon :size="32">
<component :is="badge.icon" />
</el-icon>
</div>
<div class="badge-name">{{ badge.name }}</div>
<div class="badge-desc">{{ badge.description }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { getCurrentUserProfile, updateCurrentUserProfile, getCurrentUserStatistics } from '@/api/user'
import type { ApiResponse } from '@/api/config'
const formRef = ref<FormInstance>()
const isEditing = ref(false)
const router = useRouter()
// 用户信息(与后端字段对齐)
const userInfo = reactive({
id: 0,
username: '',
name: '',
gender: 'male',
phone: '',
email: '',
school: '',
major: '',
bio: '',
avatar: '',
role: ''
})
/**
* 加载当前用户信息
* 从后端 GET /api/v1/users/me 拉取真实数据并填充表单
*/
async function loadUserProfile(): Promise<void> {
try {
const res = await getCurrentUserProfile()
// 后端统一响应结构 { code, message, data }
const data = (res as ApiResponse<any>).data
if (!data) {
ElMessage.error('获取个人信息失败:数据为空')
return
}
// 兼容后端字段full_name -> name, avatar_url -> avatar
userInfo.id = data.id ?? 0
userInfo.username = data.username ?? ''
userInfo.name = data.full_name ?? data.name ?? ''
userInfo.phone = data.phone ?? ''
userInfo.email = data.email ?? ''
userInfo.bio = data.bio ?? ''
userInfo.avatar = data.avatar_url ?? data.avatar ?? ''
userInfo.role = data.role ?? ''
userInfo.gender = data.gender ?? 'male' // 加载性别字段,默认 male
// 加载学校和专业字段
userInfo.school = data.school ?? ''
userInfo.major = data.major ?? ''
} catch (err: any) {
ElMessage.error(err?.message || '获取个人信息失败')
}
}
// 表单验证规则
const rules = reactive<FormRules>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
]
})
// 成就徽章
const achievements = ref([
{
id: 1,
name: '初学者',
description: '完成第一次考试',
icon: 'Star',
unlocked: true
},
{
id: 2,
name: '勤奋学习',
description: '连续学习7天',
icon: 'Calendar',
unlocked: true
},
{
id: 3,
name: '题海战术',
description: '累计练习1000题',
icon: 'Files',
unlocked: true
},
{
id: 4,
name: '学霸',
description: '连续10次考试90分以上',
icon: 'Trophy',
unlocked: false
},
{
id: 5,
name: '完美主义',
description: '单次考试满分',
icon: 'CircleCheck',
unlocked: true
},
{
id: 6,
name: '持之以恒',
description: '连续学习30天',
icon: 'Flag',
unlocked: false
}
])
// 学习统计(真实数据)
const stats = reactive({
learningDays: 0,
totalHours: 0,
practiceQuestions: 0,
averageScore: 0
})
/**
* 加载当前用户学习统计
*/
async function loadUserStatistics(): Promise<void> {
try {
const res = await getCurrentUserStatistics()
const data = (res as ApiResponse<any>).data
if (data) {
stats.learningDays = data.learningDays ?? 0
stats.totalHours = data.totalHours ?? 0
stats.practiceQuestions = data.practiceQuestions ?? 0
stats.averageScore = data.averageScore ?? 0
}
} catch (err: any) {
// 静默失败,保留占位
}
}
/**
* 编辑信息
* 进入编辑态
*/
function handleEdit(): void {
isEditing.value = true
}
/**
* 取消编辑
* 退出编辑态并重置表单至服务器状态
*/
async function handleCancel(): Promise<void> {
isEditing.value = false
formRef.value?.resetFields()
await loadUserProfile()
}
/**
* 保存修改
* 提交 PUT /api/v1/users/me只传后端可接受字段
*/
async function handleSave(): Promise<void> {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
try {
const payload = {
// 与 app/schemas/user.py::UserUpdate 对齐
email: userInfo.email || undefined,
phone: userInfo.phone || undefined,
full_name: userInfo.name || undefined,
avatar_url: userInfo.avatar || undefined,
bio: userInfo.bio || undefined,
gender: userInfo.gender || undefined,
school: userInfo.school || undefined,
major: userInfo.major || undefined
}
await updateCurrentUserProfile(payload)
ElMessage.success('个人信息修改成功')
isEditing.value = false
await loadUserProfile()
} catch (err: any) {
ElMessage.error(err?.message || '保存失败')
}
})
}
/**
* 头像更改
* 目前仅前端提示,后续可改为实际上传后设置 avatar_url
*/
function handleAvatarChange(_file: any): void {
ElMessage.success('头像更新成功(示例)')
}
/**
* 跳转到修改密码页面
*/
function handleChangePassword(): void {
router.push('/user/change-password')
}
onMounted(() => {
loadUserProfile()
loadUserStatistics()
})
</script>
<style lang="scss" scoped>
.profile-container {
max-width: 1200px;
margin: 0 auto;
.page-header {
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
}
.profile-content {
display: grid;
gap: 24px;
.info-section {
padding: 24px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.info-content {
display: flex;
gap: 48px;
.avatar-section {
text-align: center;
.avatar-uploader {
position: relative;
display: inline-block;
cursor: pointer;
.el-avatar {
border: 4px solid #f0f2f5;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.avatar-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
color: white;
&:hover {
opacity: 1;
}
}
}
.user-name {
font-size: 20px;
font-weight: 600;
color: #333;
margin-top: 16px;
}
.user-role {
font-size: 14px;
color: #666;
margin-top: 8px;
}
}
.info-form {
flex: 1;
max-width: 500px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
}
.stats-section {
padding: 24px;
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px;
.stat-item {
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-content {
.stat-value {
font-size: 24px;
font-weight: 600;
color: #333;
}
.stat-label {
font-size: 14px;
color: #666;
margin-top: 4px;
}
}
}
}
}
.security-section {
padding: 24px;
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.security-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
.security-info {
flex: 1;
.security-label {
font-size: 14px;
color: #333;
margin-bottom: 4px;
font-weight: 500;
}
.security-desc {
font-size: 12px;
color: #909399;
}
}
.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
.achievement-section {
padding: 24px;
.section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.achievement-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 24px;
.achievement-item {
text-align: center;
padding: 20px;
border-radius: 12px;
background: #f5f7fa;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.badge-icon {
width: 64px;
height: 64px;
margin: 0 auto 12px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
&.locked {
background: #c0c4cc;
filter: grayscale(1);
}
}
.badge-name {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.badge-desc {
font-size: 12px;
color: #666;
}
}
}
}
}
}
// 响应式
@media (max-width: 768px) {
.profile-container {
.info-content {
flex-direction: column !important;
gap: 24px !important;
.avatar-section {
margin-bottom: 24px;
}
}
.stats-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.achievement-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
}
}
</style>

View File

@@ -0,0 +1,478 @@
<template>
<div class="settings-container">
<div class="page-header">
<h1 class="page-title">系统设置</h1>
</div>
<div class="settings-content">
<el-tabs v-model="activeTab" class="settings-tabs">
<!-- 通用设置 -->
<el-tab-pane label="通用设置" name="general">
<div class="tab-content">
<div class="setting-group">
<h3 class="group-title">界面设置</h3>
<div class="setting-item">
<div class="item-label">
<span>主题模式</span>
<span class="item-desc">选择系统的主题模式</span>
</div>
<el-radio-group v-model="settings.theme">
<el-radio label="light">浅色模式</el-radio>
<el-radio label="dark">深色模式</el-radio>
<el-radio label="auto">跟随系统</el-radio>
</el-radio-group>
</div>
<div class="setting-item">
<div class="item-label">
<span>语言设置</span>
<span class="item-desc">选择系统显示语言</span>
</div>
<el-select v-model="settings.language" style="width: 200px">
<el-option label="简体中文" value="zh-CN" />
<el-option label="繁体中文" value="zh-TW" />
<el-option label="English" value="en-US" />
</el-select>
</div>
<div class="setting-item">
<div class="item-label">
<span>字体大小</span>
<span class="item-desc">调整系统字体大小</span>
</div>
<el-slider
v-model="settings.fontSize"
:min="12"
:max="20"
:marks="fontSizeMarks"
style="width: 300px"
/>
</div>
</div>
<div class="setting-group">
<h3 class="group-title">学习设置</h3>
<div class="setting-item">
<div class="item-label">
<span>自动保存答案</span>
<span class="item-desc">练习时自动保存答案防止丢失</span>
</div>
<el-switch v-model="settings.autoSave" />
</div>
<div class="setting-item">
<div class="item-label">
<span>显示答题时间</span>
<span class="item-desc">在练习时显示每道题的答题时间</span>
</div>
<el-switch v-model="settings.showTimer" />
</div>
<div class="setting-item">
<div class="item-label">
<span>错题自动收藏</span>
<span class="item-desc">自动将错题加入错题本</span>
</div>
<el-switch v-model="settings.autoCollectMistakes" />
</div>
</div>
</div>
</el-tab-pane>
<!-- 通知设置 -->
<el-tab-pane label="通知设置" name="notification">
<div class="tab-content">
<div class="setting-group">
<h3 class="group-title">系统通知</h3>
<div class="setting-item">
<div class="item-label">
<span>考试提醒</span>
<span class="item-desc">考试开始前推送提醒</span>
</div>
<el-switch v-model="notifications.examReminder" />
</div>
<div class="setting-item">
<div class="item-label">
<span>成绩通知</span>
<span class="item-desc">考试成绩公布时推送通知</span>
</div>
<el-switch v-model="notifications.scoreNotice" />
</div>
<div class="setting-item">
<div class="item-label">
<span>学习报告</span>
<span class="item-desc">定期推送学习报告</span>
</div>
<el-switch v-model="notifications.studyReport" />
</div>
</div>
<div class="setting-group">
<h3 class="group-title">邮件通知</h3>
<div class="setting-item">
<div class="item-label">
<span>接收邮件通知</span>
<span class="item-desc">通过邮件接收重要通知</span>
</div>
<el-switch v-model="notifications.emailEnabled" />
</div>
<div class="setting-item" v-if="notifications.emailEnabled">
<div class="item-label">
<span>通知频率</span>
<span class="item-desc">设置邮件通知的频率</span>
</div>
<el-select v-model="notifications.emailFrequency" style="width: 200px">
<el-option label="实时" value="realtime" />
<el-option label="每日汇总" value="daily" />
<el-option label="每周汇总" value="weekly" />
</el-select>
</div>
</div>
</div>
</el-tab-pane>
<!-- 隐私设置 -->
<el-tab-pane label="隐私设置" name="privacy">
<div class="tab-content">
<div class="setting-group">
<h3 class="group-title">个人信息</h3>
<div class="setting-item">
<div class="item-label">
<span>公开个人资料</span>
<span class="item-desc">允许其他用户查看您的个人资料</span>
</div>
<el-switch v-model="privacy.profilePublic" />
</div>
<div class="setting-item">
<div class="item-label">
<span>显示学习排名</span>
<span class="item-desc">在排行榜中显示您的排名</span>
</div>
<el-switch v-model="privacy.showRanking" />
</div>
<div class="setting-item">
<div class="item-label">
<span>分享学习记录</span>
<span class="item-desc">允许分享您的学习记录</span>
</div>
<el-switch v-model="privacy.shareRecord" />
</div>
</div>
<div class="setting-group">
<h3 class="group-title">数据管理</h3>
<div class="setting-item">
<div class="item-label">
<span>导出个人数据</span>
<span class="item-desc">导出您的所有学习数据</span>
</div>
<el-button @click="exportData">导出数据</el-button>
</div>
<div class="setting-item">
<div class="item-label">
<span>清除缓存</span>
<span class="item-desc">清除本地缓存数据</span>
</div>
<el-button @click="clearCache">清除缓存</el-button>
</div>
</div>
</div>
</el-tab-pane>
<!-- 账号安全 -->
<el-tab-pane label="账号安全" name="security">
<div class="tab-content">
<div class="setting-group">
<h3 class="group-title">密码设置</h3>
<div class="setting-item">
<div class="item-label">
<span>修改密码</span>
<span class="item-desc">定期修改密码可以提高账号安全性</span>
</div>
<el-button @click="changePassword">修改密码</el-button>
</div>
</div>
<div class="setting-group">
<h3 class="group-title">登录设置</h3>
<div class="setting-item">
<div class="item-label">
<span>双因素认证</span>
<span class="item-desc">开启后需要验证码才能登录</span>
</div>
<el-switch v-model="security.twoFactor" />
</div>
<div class="setting-item">
<div class="item-label">
<span>登录设备管理</span>
<span class="item-desc">查看和管理登录设备</span>
</div>
<el-button @click="manageDevices">管理设备</el-button>
</div>
</div>
<div class="setting-group danger-zone">
<h3 class="group-title">危险操作</h3>
<div class="setting-item">
<div class="item-label">
<span>注销账号</span>
<span class="item-desc">永久删除账号和所有数据</span>
</div>
<el-button type="danger" @click="deleteAccount">注销账号</el-button>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<!-- 保存按钮 -->
<div class="save-actions">
<el-button @click="resetSettings">重置</el-button>
<el-button type="primary" @click="saveSettings">保存设置</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
// 当前标签页
const activeTab = ref('general')
const router = useRouter()
// 通用设置
const settings = reactive({
theme: 'light',
language: 'zh-CN',
fontSize: 14,
autoSave: true,
showTimer: true,
autoCollectMistakes: true
})
// 字体大小标记
const fontSizeMarks = {
12: '小',
14: '标准',
16: '大',
20: '特大'
}
// 通知设置
const notifications = reactive({
examReminder: true,
scoreNotice: true,
studyReport: true,
emailEnabled: false,
emailFrequency: 'daily'
})
// 隐私设置
const privacy = reactive({
profilePublic: true,
showRanking: true,
shareRecord: false
})
// 安全设置
const security = reactive({
twoFactor: false
})
/**
* 保存设置
*/
const saveSettings = () => {
ElMessage.success('设置保存成功')
}
/**
* 重置设置
*/
const resetSettings = async () => {
try {
await ElMessageBox.confirm('确定要重置所有设置吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('设置已重置')
} catch {}
}
/**
* 导出数据
*/
const exportData = () => {
ElMessage.success('数据导出成功')
}
/**
* 清除缓存
*/
const clearCache = async () => {
try {
await ElMessageBox.confirm('确定要清除所有缓存吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('缓存清除成功')
} catch {}
}
/**
* 修改密码
* 跳转到独立的修改密码页面
*/
const changePassword = () => {
router.push('/user/change-password')
}
/**
* 管理设备
*/
const manageDevices = () => {
ElMessage.info('打开设备管理页面')
}
/**
* 注销账号
*/
const deleteAccount = async () => {
try {
await ElMessageBox.confirm(
'注销账号后,您的所有数据将被永久删除且无法恢复。确定要继续吗?',
'危险操作',
{
confirmButtonText: '确定注销',
cancelButtonText: '取消',
type: 'error'
}
)
ElMessage.info('账号注销流程已启动')
} catch {}
}
</script>
<style lang="scss" scoped>
.settings-container {
max-width: 1000px;
margin: 0 auto;
.page-header {
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
}
}
.settings-content {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
.settings-tabs {
:deep(.el-tabs__header) {
margin-bottom: 32px;
}
.tab-content {
.setting-group {
margin-bottom: 48px;
&:last-child {
margin-bottom: 0;
}
.group-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
&.danger-zone {
.group-title {
color: #f56c6c;
border-bottom-color: #fde2e2;
}
}
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
&:not(:last-child) {
border-bottom: 1px solid #f5f7fa;
}
.item-label {
flex: 1;
span {
display: block;
&:first-child {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
&.item-desc {
font-size: 12px;
color: #909399;
}
}
}
}
}
}
.save-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #ebeef5;
.el-button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
}
}
}
// 响应式
@media (max-width: 768px) {
.settings-container {
.setting-item {
flex-direction: column;
align-items: flex-start !important;
gap: 12px;
.item-label {
width: 100%;
}
}
.save-actions {
flex-direction: column;
.el-button {
width: 100%;
}
}
}
}
</style>