feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
1086
frontend/src/views/admin/dashboard.vue
Normal file
1086
frontend/src/views/admin/dashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
378
frontend/src/views/admin/logs.vue
Normal file
378
frontend/src/views/admin/logs.vue
Normal 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>
|
||||
1337
frontend/src/views/admin/position-management.vue
Normal file
1337
frontend/src/views/admin/position-management.vue
Normal file
File diff suppressed because it is too large
Load Diff
1683
frontend/src/views/admin/user-management.vue
Normal file
1683
frontend/src/views/admin/user-management.vue
Normal file
File diff suppressed because it is too large
Load Diff
1116
frontend/src/views/analysis/mistakes.vue
Normal file
1116
frontend/src/views/analysis/mistakes.vue
Normal file
File diff suppressed because it is too large
Load Diff
692
frontend/src/views/analysis/report.vue
Normal file
692
frontend/src/views/analysis/report.vue
Normal 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>
|
||||
984
frontend/src/views/analysis/statistics.vue
Normal file
984
frontend/src/views/analysis/statistics.vue
Normal 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>
|
||||
478
frontend/src/views/dashboard/index.vue
Normal file
478
frontend/src/views/dashboard/index.vue
Normal 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>
|
||||
157
frontend/src/views/error/404.vue
Normal file
157
frontend/src/views/error/404.vue
Normal 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>
|
||||
1704
frontend/src/views/exam/practice.vue
Normal file
1704
frontend/src/views/exam/practice.vue
Normal file
File diff suppressed because it is too large
Load Diff
367
frontend/src/views/login/index.vue
Normal file
367
frontend/src/views/login/index.vue
Normal 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>
|
||||
769
frontend/src/views/manager/assignment-center.vue
Normal file
769
frontend/src/views/manager/assignment-center.vue
Normal 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>
|
||||
646
frontend/src/views/manager/course-management.vue
Normal file
646
frontend/src/views/manager/course-management.vue
Normal 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>
|
||||
3233
frontend/src/views/manager/course-management.vue.current
Normal file
3233
frontend/src/views/manager/course-management.vue.current
Normal file
File diff suppressed because it is too large
Load Diff
3080
frontend/src/views/manager/edit-course.vue
Normal file
3080
frontend/src/views/manager/edit-course.vue
Normal file
File diff suppressed because it is too large
Load Diff
955
frontend/src/views/manager/growth-path-management.vue
Normal file
955
frontend/src/views/manager/growth-path-management.vue
Normal 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>
|
||||
1235
frontend/src/views/manager/practice-scene-management.vue
Normal file
1235
frontend/src/views/manager/practice-scene-management.vue
Normal file
File diff suppressed because it is too large
Load Diff
1059
frontend/src/views/manager/student-practice.vue
Normal file
1059
frontend/src/views/manager/student-practice.vue
Normal file
File diff suppressed because it is too large
Load Diff
1006
frontend/src/views/manager/student-scores.vue
Normal file
1006
frontend/src/views/manager/student-scores.vue
Normal file
File diff suppressed because it is too large
Load Diff
1254
frontend/src/views/manager/team-dashboard.vue
Normal file
1254
frontend/src/views/manager/team-dashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
1463
frontend/src/views/manager/team-management.vue
Normal file
1463
frontend/src/views/manager/team-management.vue
Normal file
File diff suppressed because it is too large
Load Diff
406
frontend/src/views/register/index.vue
Normal file
406
frontend/src/views/register/index.vue
Normal 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>
|
||||
1151
frontend/src/views/trainee/ai-practice-center.vue
Normal file
1151
frontend/src/views/trainee/ai-practice-center.vue
Normal file
File diff suppressed because it is too large
Load Diff
754
frontend/src/views/trainee/ai-practice-coze.vue
Normal file
754
frontend/src/views/trainee/ai-practice-coze.vue
Normal 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>
|
||||
119
frontend/src/views/trainee/ai-practice.vue
Normal file
119
frontend/src/views/trainee/ai-practice.vue
Normal 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>
|
||||
638
frontend/src/views/trainee/audio-player.vue
Normal file
638
frontend/src/views/trainee/audio-player.vue
Normal 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>
|
||||
314
frontend/src/views/trainee/broadcast-course.vue
Normal file
314
frontend/src/views/trainee/broadcast-course.vue
Normal 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>
|
||||
|
||||
1015
frontend/src/views/trainee/chat-course.vue
Normal file
1015
frontend/src/views/trainee/chat-course.vue
Normal file
File diff suppressed because it is too large
Load Diff
914
frontend/src/views/trainee/course-center.vue
Normal file
914
frontend/src/views/trainee/course-center.vue
Normal 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>
|
||||
1298
frontend/src/views/trainee/course-detail.vue
Normal file
1298
frontend/src/views/trainee/course-detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
810
frontend/src/views/trainee/exam-result.vue
Normal file
810
frontend/src/views/trainee/exam-result.vue
Normal 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>
|
||||
2259
frontend/src/views/trainee/growth-path.vue
Normal file
2259
frontend/src/views/trainee/growth-path.vue
Normal file
File diff suppressed because it is too large
Load Diff
1035
frontend/src/views/trainee/practice-records.vue
Normal file
1035
frontend/src/views/trainee/practice-records.vue
Normal file
File diff suppressed because it is too large
Load Diff
913
frontend/src/views/trainee/practice-report.vue
Normal file
913
frontend/src/views/trainee/practice-report.vue
Normal 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>
|
||||
790
frontend/src/views/trainee/score-query.vue
Normal file
790
frontend/src/views/trainee/score-query.vue
Normal 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>
|
||||
376
frontend/src/views/user/change-password.vue
Normal file
376
frontend/src/views/user/change-password.vue
Normal 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>
|
||||
554
frontend/src/views/user/notifications.vue
Normal file
554
frontend/src/views/user/notifications.vue
Normal 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>
|
||||
|
||||
|
||||
|
||||
|
||||
652
frontend/src/views/user/profile.vue
Normal file
652
frontend/src/views/user/profile.vue
Normal 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>
|
||||
478
frontend/src/views/user/settings.vue
Normal file
478
frontend/src/views/user/settings.vue
Normal 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>
|
||||
Reference in New Issue
Block a user