web-学习任务

This commit is contained in:
2025-10-23 18:57:31 +08:00
parent 042209b98d
commit 6f5603dd8b
17 changed files with 1156 additions and 189 deletions

View File

@@ -131,5 +131,10 @@ export const learningTaskApi = {
filter filter
}); });
return response.data; return response.data;
} },
async getUserProgress(userID: string): Promise<ResultDomain<TaskVO>> {
const response = await api.post<TaskVO>(`${this.learningTaskPrefix}/user/progress/${userID}`);
return response.data;
},
}; };

View File

@@ -73,7 +73,7 @@ const dropdownPositions = ref<Record<string, { left: number; top: number; width:
// 获取所有菜单 // 获取所有菜单
const allMenus = computed(() => store.getters['auth/menuTree']); const allMenus = computed(() => store.getters['auth/menuTree']);
const userInfo = computed(() => store.getters['auth/userInfo']); const userInfo = computed(() => store.getters['auth/user']);
// 获取第一层的导航菜单MenuType.NAVIGATION过滤掉用户相关菜单 // 获取第一层的导航菜单MenuType.NAVIGATION过滤掉用户相关菜单
const navigationMenus = computed(() => { const navigationMenus = computed(() => {

View File

@@ -74,7 +74,7 @@ const authModule: Module<AuthState, any> = {
}, },
// 获取用户信息 // 获取用户信息
userInfo: (state) => { user: (state) => {
return state.loginDomain?.user || null; return state.loginDomain?.user || null;
}, },

View File

@@ -231,6 +231,11 @@ export interface TaskVO extends BaseDTO {
taskCourses: TaskItemVO[]; taskCourses: TaskItemVO[];
taskResources: TaskItemVO[]; taskResources: TaskItemVO[];
taskUsers: TaskItemVO[]; taskUsers: TaskItemVO[];
totalTaskNum?: number;
completedTaskNum?: number;
learningTaskNum?: number;
notStartTaskNum?: number;
taskStatus?: number;
} }
/** /**
* 任务课程关联实体 * 任务课程关联实体

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="course-detail"> <div class="course-detail">
<!-- 页面头部 --> <!-- 返回按钮可选 -->
<div class="page-header"> <div v-if="showBackButton" class="back-header">
<el-button @click="handleBack" :icon="ArrowLeft">返回</el-button> <el-button @click="handleBack" :icon="ArrowLeft">{{ backButtonText }}</el-button>
</div> </div>
<!-- 加载中 --> <!-- 加载中 -->
@@ -202,11 +202,20 @@ import type { CourseVO, LearningRecord } from '@/types';
import { CollectionType } from '@/types'; import { CollectionType } from '@/types';
import { FILE_DOWNLOAD_URL } from '@/config'; import { FILE_DOWNLOAD_URL } from '@/config';
defineOptions({
name: 'CourseDetail'
});
interface Props { interface Props {
courseId: string; courseId: string;
showBackButton?: boolean;
backButtonText?: string;
} }
const props = defineProps<Props>(); const props = withDefaults(defineProps<Props>(), {
showBackButton: false,
backButtonText: '返回'
});
const emit = defineEmits<{ const emit = defineEmits<{
'back': []; 'back': [];
@@ -215,7 +224,9 @@ const emit = defineEmits<{
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const authStore = computed(() => store.state.auth);
// 从 store 获取用户信息
const userInfo = computed(() => store.getters['auth/user']);
const loading = ref(false); const loading = ref(false);
const enrolling = ref(false); const enrolling = ref(false);
@@ -278,11 +289,11 @@ async function loadCourseDetail() {
// 检查收藏状态 // 检查收藏状态
async function checkCollectionStatus() { async function checkCollectionStatus() {
if (!authStore.value.userInfo?.userID) return; if (!userInfo.value?.id) return;
try { try {
const res = await userCollectionApi.isCollected( const res = await userCollectionApi.isCollected(
authStore.value.userInfo.userID, userInfo.value.id,
CollectionType.COURSE, CollectionType.COURSE,
props.courseId props.courseId
); );
@@ -296,11 +307,11 @@ async function checkCollectionStatus() {
// 加载学习进度 // 加载学习进度
async function loadLearningProgress() { async function loadLearningProgress() {
if (!authStore.value.userInfo?.userID) return; if (!userInfo.value?.id) return;
try { try {
const res = await learningRecordApi.getRecordList({ const res = await learningRecordApi.getRecordList({
userID: authStore.value.userInfo.userID, userID: userInfo.value.id,
resourceType: 2, // 课程类型 resourceType: 2, // 课程类型
resourceID: props.courseId resourceID: props.courseId
}); });
@@ -316,7 +327,7 @@ async function loadLearningProgress() {
// 处理收藏 // 处理收藏
async function handleCollect() { async function handleCollect() {
if (!authStore.value.userInfo?.userID) { if (!userInfo.value?.id) {
ElMessage.warning('请先登录'); ElMessage.warning('请先登录');
router.push('/login'); router.push('/login');
return; return;
@@ -326,7 +337,7 @@ async function handleCollect() {
if (isCollected.value) { if (isCollected.value) {
// 取消收藏 // 取消收藏
const res = await userCollectionApi.removeCollection( const res = await userCollectionApi.removeCollection(
authStore.value.userInfo.userID, userInfo.value.id,
CollectionType.COURSE, CollectionType.COURSE,
props.courseId props.courseId
); );
@@ -337,7 +348,7 @@ async function handleCollect() {
} else { } else {
// 添加收藏 // 添加收藏
const res = await userCollectionApi.addCollection({ const res = await userCollectionApi.addCollection({
userID: authStore.value.userInfo.userID, userID: userInfo.value.id,
collectionType: CollectionType.COURSE, collectionType: CollectionType.COURSE,
collectionID: props.courseId collectionID: props.courseId
}); });
@@ -354,7 +365,7 @@ async function handleCollect() {
// 开始学习 // 开始学习
async function handleStartLearning() { async function handleStartLearning() {
if (!authStore.value.userInfo?.userID) { if (!userInfo.value?.id) {
ElMessage.warning('请先登录'); ElMessage.warning('请先登录');
router.push('/login'); router.push('/login');
return; return;
@@ -371,7 +382,7 @@ async function handleStartLearning() {
if (!isEnrolled.value) { if (!isEnrolled.value) {
await courseApi.incrementLearnCount(props.courseId); await courseApi.incrementLearnCount(props.courseId);
await learningRecordApi.createRecord({ await learningRecordApi.createRecord({
userID: authStore.value.userInfo.userID, userID: userInfo.value.id,
resourceType: 2, // 课程 resourceType: 2, // 课程
resourceID: props.courseId, resourceID: props.courseId,
progress: 0, progress: 0,
@@ -393,7 +404,7 @@ async function handleStartLearning() {
// 点击节点 // 点击节点
function handleNodeClick(chapterIndex: number, nodeIndex: number) { function handleNodeClick(chapterIndex: number, nodeIndex: number) {
if (!authStore.value.userInfo?.userID) { if (!userInfo.value?.id) {
ElMessage.warning('请先登录'); ElMessage.warning('请先登录');
router.push('/login'); router.push('/login');
return; return;
@@ -428,13 +439,11 @@ function formatDuration(minutes?: number): string {
padding-bottom: 60px; padding-bottom: 60px;
} }
.page-header { .back-header {
padding: 16px 24px; padding: 16px 24px;
background: #fff; background: #fff;
border-bottom: 1px solid #e4e7ed; border-bottom: 1px solid #e4e7ed;
position: sticky; margin-bottom: 0;
top: 0;
z-index: 10;
} }
.loading { .loading {

View File

@@ -156,7 +156,7 @@ const store = useStore();
// const { hasPermission } = usePermission(); // 暂时注释,后续权限功能开发时启用 // const { hasPermission } = usePermission(); // 暂时注释,后续权限功能开发时启用
// 计算属性 // 计算属性
const userInfo = computed(() => store.getters['auth/userInfo']); const userInfo = computed(() => store.getters['auth/user']);
const currentDate = computed(() => { const currentDate = computed(() => {
return new Date().toLocaleDateString('zh-CN', { return new Date().toLocaleDateString('zh-CN', {

View File

@@ -1,4 +1,3 @@
export { default as ResourceHead } from '../../../components/base/ResourceHead.vue';
export { default as ResourceSideBar } from './ResourceSideBar.vue'; export { default as ResourceSideBar } from './ResourceSideBar.vue';
export { default as ResourceList } from './ResourceList.vue'; export { default as ResourceList } from './ResourceList.vue';
export { default as ResourceArticle } from './ResourceArticle.vue'; export { default as ResourceArticle } from './ResourceArticle.vue';

View File

@@ -91,47 +91,14 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 课程详情对话框 -->
<el-drawer
v-model="detailDrawerVisible"
size="100%"
:show-close="false"
:with-header="false"
direction="rtl"
>
<CourseDetail
v-if="selectedCourseId"
:course-id="selectedCourseId"
@back="handleCloseDetail"
@start-learning="handleStartLearning"
/>
</el-drawer>
<!-- 课程学习对话框 -->
<el-drawer
v-model="learningDrawerVisible"
size="100%"
:show-close="false"
:with-header="false"
direction="rtl"
>
<CourseLearning
v-if="learningCourseId"
:course-id="learningCourseId"
:chapter-index="chapterIndex"
:node-index="nodeIndex"
@back="handleCloseLearning"
/>
</el-drawer>
</StudyPlanLayout> </StudyPlanLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { Search, VideoPlay } from '@element-plus/icons-vue'; import { Search, VideoPlay } from '@element-plus/icons-vue';
import { CourseDetail, CourseLearning } from '@/views/course';
import { courseApi } from '@/apis/study'; import { courseApi } from '@/apis/study';
import type { Course, PageParam } from '@/types'; import type { Course, PageParam } from '@/types';
import { StudyPlanLayout } from '@/views/study-plan'; import { StudyPlanLayout } from '@/views/study-plan';
@@ -142,6 +109,7 @@ defineOptions({
name: 'CourseCenterView' name: 'CourseCenterView'
}); });
const router = useRouter();
const loading = ref(false); const loading = ref(false);
const searchKeyword = ref(''); const searchKeyword = ref('');
const courseList = ref<Course[]>([]); const courseList = ref<Course[]>([]);
@@ -153,16 +121,6 @@ const pageParam = ref<PageParam>({
size: 6 size: 6
}); });
// 课程详情
const detailDrawerVisible = ref(false);
const selectedCourseId = ref<string>('');
// 课程学习
const learningDrawerVisible = ref(false);
const learningCourseId = ref<string>('');
const chapterIndex = ref(0);
const nodeIndex = ref(0);
onMounted(() => { onMounted(() => {
loadCourseList(); loadCourseList();
}); });
@@ -211,33 +169,14 @@ function handleCurrentChange() {
loadCourseList(); loadCourseList();
} }
// 点击课程卡片 // 点击课程卡片 - 跳转到课程详情路由
function handleCourseClick(courseId: string) { function handleCourseClick(courseId: string) {
selectedCourseId.value = courseId; console.log('handleCourseClick', courseId);
detailDrawerVisible.value = true; console.log('router', router.getRoutes());
} router.push({
path: '/study-plan/course-detail',
// 关闭课程详情 query: { courseId }
function handleCloseDetail() { });
detailDrawerVisible.value = false;
selectedCourseId.value = '';
}
// 开始学习
function handleStartLearning(courseId: string, chapter: number, node: number) {
detailDrawerVisible.value = false;
learningCourseId.value = courseId;
chapterIndex.value = chapter;
nodeIndex.value = node;
learningDrawerVisible.value = true;
}
// 关闭学习页面
function handleCloseLearning() {
learningDrawerVisible.value = false;
learningCourseId.value = '';
chapterIndex.value = 0;
nodeIndex.value = 0;
} }
// 格式化浏览次数 // 格式化浏览次数
@@ -431,10 +370,4 @@ function getCategoryName(): string {
justify-content: center; justify-content: center;
margin-top: 40px; margin-top: 40px;
} }
:deep(.el-drawer) {
.el-drawer__body {
padding: 0;
}
}
</style> </style>

View File

@@ -0,0 +1,48 @@
<template>
<CourseDetail
v-if="courseId"
:course-id="courseId"
:show-back-button="true"
back-button-text="返回课程列表"
@back="handleBack"
@start-learning="handleStartLearning"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { CourseDetail } from '@/views/course/components';
defineOptions({
name: 'CourseDetailView'
});
const router = useRouter();
const route = useRoute();
const courseId = computed(() => route.query.courseId as string || '');
// 返回上一页
function handleBack() {
router.back();
}
// 开始学习课程
function handleStartLearning(courseId: string, chapterIndex: number, nodeIndex: number) {
// 跳转到课程学习页面
router.push({
path: '/study-plan/course-study',
query: {
courseId,
chapterIndex: chapterIndex.toString(),
nodeIndex: nodeIndex.toString()
}
});
}
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,42 @@
<template>
<CourseLearning
v-if="courseId"
:course-id="courseId"
:chapter-index="chapterIndex"
:node-index="nodeIndex"
@back="handleBack"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { CourseLearning } from '@/views/course/components';
defineOptions({
name: 'CourseStudyView'
});
const router = useRouter();
const route = useRoute();
// 从路由参数获取课程ID和节点索引
const courseId = computed(() => route.query.courseId as string || '');
const chapterIndex = computed(() => parseInt(route.query.chapterIndex as string) || 0);
const nodeIndex = computed(() => parseInt(route.query.nodeIndex as string) || 0);
// 返回到课程详情页
function handleBack() {
router.push({
path: '/study-plan/course-detail',
query: {
courseId: courseId.value
}
});
}
</script>
<style lang="scss" scoped>
// 此组件只是容器,样式由子组件处理
</style>

View File

@@ -0,0 +1,33 @@
<template>
<LearingTaskDetail
:task-id="taskId"
:show-back-button="true"
back-button-text="返回任务列表"
@back="handleBack"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { LearingTaskDetail } from '@/views/task';
defineOptions({
name: 'LearningTaskDetailView'
});
const router = useRouter();
const route = useRoute();
const taskId = computed(() => route.query.taskId as string || '');
// 返回任务列表
function handleBack() {
router.back();
}
</script>
<style lang="scss" scoped>
// 此组件只是容器,样式由子组件处理
</style>

View File

@@ -108,23 +108,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { DocumentCopy, DocumentChecked } from '@element-plus/icons-vue'; import { DocumentCopy, DocumentChecked } from '@element-plus/icons-vue';
import { useStore } from 'vuex';
import { learningTaskApi } from '@/apis/study'; import { learningTaskApi } from '@/apis/study';
import type { LearningTask } from '@/types'; import type { LearningTask, TaskItemVO } from '@/types';
import { StudyPlanLayout } from '@/views/study-plan'; import { StudyPlanLayout } from '@/views/study-plan';
defineOptions({ defineOptions({
name: 'StudyTasksView' name: 'StudyTasksView'
}); });
const router = useRouter();
const store = useStore();
const loading = ref(false); const loading = ref(false);
const taskList = ref<LearningTask[]>([]); const taskList = ref<LearningTask[]>([]);
// 统计数据 // 统计数据
const totalCount = ref(16); const totalCount = ref(0);
const completedCount = ref(10); const completedCount = ref(0);
const pendingCount = ref(6); const pendingCount = ref(0);
const userLevel = ref('Level1'); const userLevel = ref('Level1');
// 计算进度百分比 // 计算进度百分比
@@ -134,119 +138,90 @@ const progressPercent = computed(() => {
}); });
onMounted(() => { onMounted(() => {
loadUserProgress();
loadTaskList(); loadTaskList();
loadStatistics();
}); });
// ==================== MOCK 数据(临时使用,后续删除) ==================== // 获取当前用户ID
const useMock = true; // 改为 false 即可使用真实接口 const getUserID = () => {
const userInfo = store.getters['auth/user'];
return userInfo?.id || '';
};
function getMockTaskList(): LearningTask[] { // 加载任务列表(用户视角)
return [
{
taskID: '1',
name: '新时代中国特色社会主义发展历程',
description: '习近平新时代中国特色社会主义思想是当代中国马克思主义、二十一世纪马克思主义,是中华文化和中国精神的时代精华,其核心要义与实践要求内涵丰富、意义深远。',
startTime: '2025-10-01',
endTime: '2025-10-25',
status: 0,
},
{
taskID: '2',
name: '党的二十大精神学习',
description: '深入学习贯彻党的二十大精神,全面把握新时代新征程党和国家事业发展的目标任务,为全面建设社会主义现代化国家、全面推进中华民族伟大复兴而团结奋斗。',
startTime: '2025-09-15',
endTime: '2025-11-30',
status: 1,
},
{
taskID: '3',
name: '党史学习教育',
description: '学习党的百年奋斗历程,从党的历史中汲取智慧和力量,做到学史明理、学史增信、学史崇德、学史力行。',
startTime: '2025-08-01',
endTime: '2025-10-15',
status: 2,
},
{
taskID: '4',
name: '红色经典阅读',
description: '阅读红色经典著作,传承革命精神,赓续红色血脉,坚定理想信念。',
startTime: '2025-10-10',
endTime: '2025-12-31',
status: 0,
},
];
}
function getMockStatistics() {
return {
totalCount: 16,
completedCount: 10,
pendingCount: 6,
level: 'Level1'
};
}
// ==================== MOCK 数据结束 ====================
// 加载任务列表
async function loadTaskList() { async function loadTaskList() {
loading.value = true; loading.value = true;
try { try {
// MOCK: 使用模拟数据(临时) const userID = getUserID();
if (useMock) { if (!userID) {
await new Promise(resolve => setTimeout(resolve, 500)); // 模拟加载延迟 ElMessage.warning('请先登录');
taskList.value = getMockTaskList();
loading.value = false; loading.value = false;
return; return;
} }
// TODO: 真实接口调用useMock 改为 false 后生效) // 调用用户任务分页接口
const res = await learningTaskApi.getTaskList(); const pageParam = {
page: 1,
size: 100 // 获取所有任务,不做分页
};
if (res.success) { const filter: TaskItemVO = {
taskList.value = res.dataList || []; userID
};
const res = await learningTaskApi.getUserTaskPage(pageParam, filter);
if (res.success && res.dataList) {
taskList.value = res.dataList;
} else { } else {
ElMessage.error('加载学习任务失败'); ElMessage.error(res.message || '加载学习任务失败');
} }
} catch (error) { } catch (error: any) {
console.error('加载学习任务失败:', error); console.error('加载学习任务失败:', error);
ElMessage.error('加载学习任务失败'); ElMessage.error(error?.message || '加载学习任务失败');
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
// 加载统计数据 // 加载用户进度统计数据
async function loadStatistics() { async function loadUserProgress() {
try { try {
// MOCK: 使用模拟数据(临时) const userID = getUserID();
if (useMock) { if (!userID) {
const mockData = getMockStatistics();
totalCount.value = mockData.totalCount;
completedCount.value = mockData.completedCount;
pendingCount.value = mockData.pendingCount;
userLevel.value = mockData.level;
return; return;
} }
// TODO: 真实接口调用useMock 改为 false 后生效) const res = await learningTaskApi.getUserProgress(userID);
// const res = await learningTaskApi.getStatistics();
// if (res.success && res.data) { if (res.success && res.data) {
// totalCount.value = res.data.totalCount; const progressData = res.data;
// completedCount.value = res.data.completedCount; const pending = (progressData.notStartTaskNum || 0) + (progressData.learningTaskNum || 0);
// pendingCount.value = res.data.pendingCount; // 设置统计数据
// userLevel.value = res.data.level; totalCount.value = progressData.totalTaskNum || 0;
// } pendingCount.value = pending;
}
} catch (error) { } catch (error) {
console.error('加载统计数据失败:', error); console.error('加载用户进度失败:', error);
// 不显示错误消息,使用从任务列表计算的统计数据即可
} }
} }
// 点击任务卡片 // 点击任务卡片
function handleTaskClick(task: LearningTask) { function handleTaskClick(task: LearningTask) {
// TODO: 跳转到任务详情或开始任务 if (!task.taskID) {
console.log('点击任务:', task); ElMessage.warning('任务ID不存在');
return;
}
// 使用路由跳转到任务详情页
router.push({
path: '/study-plan/task-detail',
query: {
taskId: task.taskID
}
});
} }
// 格式化日期 // 格式化日期

View File

@@ -2,3 +2,6 @@ export { default as StudyPlanLayout } from './StudyPlanLayout.vue';
export { default as StudyPlanView } from './StudyTasksView.vue'; export { default as StudyPlanView } from './StudyTasksView.vue';
export { default as CourseCenterView } from './CourseCenterView.vue'; export { default as CourseCenterView } from './CourseCenterView.vue';
export { default as StudyTasksView } from './StudyTasksView.vue'; export { default as StudyTasksView } from './StudyTasksView.vue';
export { default as LearningTaskDetailView } from './LearningTaskDetailView.vue';
export { default as CourseDetailView } from './CourseDetailView.vue';
export { default as CourseStudyView } from './CourseStudyView.vue';

View File

@@ -0,0 +1,915 @@
<template>
<div class="task-detail">
<!-- 返回按钮可选 -->
<div v-if="showBackButton" class="back-header">
<el-button @click="handleBack" :icon="ArrowLeft">{{ backButtonText }}</el-button>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading">
<el-skeleton :rows="10" animated />
</div>
<!-- 任务详情 -->
<div v-else-if="taskVO" class="task-content">
<!-- 任务基本信息 -->
<el-card class="task-info-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<el-icon class="header-icon"><Memo /></el-icon>
<span class="header-title">任务信息</span>
</div>
<el-tag
:type="getTaskStatusType(taskVO.learningTask.status)"
size="large"
>
{{ getTaskStatusText(taskVO.learningTask.status) }}
</el-tag>
</div>
</template>
<div class="task-info">
<h1 class="task-title">{{ taskVO.learningTask.name }}</h1>
<div class="task-meta">
<div class="meta-row">
<div class="meta-item">
<el-icon><Calendar /></el-icon>
<span class="meta-label">开始时间</span>
<span class="meta-value">{{ formatDate(taskVO.learningTask.startTime) }}</span>
</div>
<div class="meta-item">
<el-icon><Calendar /></el-icon>
<span class="meta-label">结束时间</span>
<span class="meta-value">{{ formatDate(taskVO.learningTask.endTime) }}</span>
</div>
</div>
<div class="meta-row">
<div class="meta-item">
<el-icon><User /></el-icon>
<span class="meta-label">创建者</span>
<span class="meta-value">{{ taskVO.learningTask.creator || '系统' }}</span>
</div>
<div class="meta-item">
<el-icon><Clock /></el-icon>
<span class="meta-label">创建时间</span>
<span class="meta-value">{{ formatDateTime(taskVO.learningTask.createTime) }}</span>
</div>
</div>
</div>
<div v-if="taskVO.learningTask.description" class="task-description">
<h3 class="section-subtitle">任务描述</h3>
<p>{{ taskVO.learningTask.description }}</p>
</div>
<!-- 任务统计 -->
<div class="task-stats">
<div class="stat-item">
<div class="stat-icon total">
<el-icon><Document /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">总任务数</div>
<div class="stat-value">{{ taskVO.totalTaskNum || 0 }}</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon completed">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">已完成</div>
<div class="stat-value">{{ taskVO.completedTaskNum || 0 }}</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon learning">
<el-icon><Reading /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">学习中</div>
<div class="stat-value">{{ taskVO.learningTaskNum || 0 }}</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon pending">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">未开始</div>
<div class="stat-value">{{ taskVO.notStartTaskNum || 0 }}</div>
</div>
</div>
</div>
<!-- 学习进度 -->
<div v-if="taskVO.totalTaskNum && taskVO.totalTaskNum > 0" class="learning-progress">
<div class="progress-header">
<span>学习进度</span>
<span class="progress-text">{{ progressPercent }}%</span>
</div>
<el-progress
:percentage="progressPercent"
:stroke-width="12"
:color="progressColor"
/>
</div>
</div>
</el-card>
<!-- 课程列表 -->
<el-card v-if="taskVO.taskCourses && taskVO.taskCourses.length > 0" class="course-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<el-icon class="header-icon"><VideoPlay /></el-icon>
<span class="header-title">学习课程</span>
</div>
<span class="item-count"> {{ taskVO.taskCourses.length }} 门课程</span>
</div>
</template>
<div class="course-list">
<div
v-for="(course, index) in taskVO.taskCourses"
:key="course.courseID"
class="course-item"
@click="handleCourseClick(course)"
>
<div class="course-index">{{ index + 1 }}</div>
<div class="course-info">
<div class="course-name-row">
<span class="course-name">{{ course.courseName }}</span>
<el-tag
v-if="course.required"
type="danger"
size="small"
effect="plain"
>
必修
</el-tag>
</div>
<div class="course-meta">
<span v-if="course.completeTime" class="complete-time">
<el-icon><CircleCheck /></el-icon>
完成于 {{ formatDateTime(course.completeTime) }}
</span>
<el-tag
v-else
:type="getCourseStatusType(course.progress)"
size="small"
>
{{ course.progress ? '学习中' : '未开始' }}
</el-tag>
</div>
</div>
<div class="course-action">
<el-button
type="primary"
:icon="course.progress ? VideoPlay : Reading"
@click.stop="handleCourseClick(course)"
>
{{ course.progress ? '继续学习' : '开始学习' }}
</el-button>
</div>
</div>
</div>
</el-card>
<!-- 文章/资源列表 -->
<el-card v-if="taskVO.taskResources && taskVO.taskResources.length > 0" class="resource-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<el-icon class="header-icon"><Document /></el-icon>
<span class="header-title">学习资源</span>
</div>
<span class="item-count"> {{ taskVO.taskResources.length }} 篇文章</span>
</div>
</template>
<div class="resource-list">
<div
v-for="(resource, index) in taskVO.taskResources"
:key="resource.resourceID"
class="resource-item"
@click="handleResourceClick(resource)"
>
<div class="resource-index">{{ index + 1 }}</div>
<div class="resource-info">
<div class="resource-name-row">
<span class="resource-name">{{ resource.resourceName }}</span>
<el-tag
v-if="resource.required"
type="danger"
size="small"
effect="plain"
>
必修
</el-tag>
</div>
<div class="resource-meta">
<span v-if="resource.completeTime" class="complete-time">
<el-icon><CircleCheck /></el-icon>
完成于 {{ formatDateTime(resource.completeTime) }}
</span>
<el-tag
v-else
:type="getResourceStatusType(resource.progress)"
size="small"
>
{{ resource.progress ? '阅读中' : '未开始' }}
</el-tag>
</div>
</div>
<div class="resource-action">
<el-button
type="primary"
:icon="resource.progress ? Reading : View"
@click.stop="handleResourceClick(resource)"
>
{{ resource.progress ? '继续阅读' : '开始阅读' }}
</el-button>
</div>
</div>
</div>
</el-card>
<!-- 空状态 -->
<el-card
v-if="(!taskVO.taskCourses || taskVO.taskCourses.length === 0) &&
(!taskVO.taskResources || taskVO.taskResources.length === 0)"
class="empty-card"
shadow="never"
>
<el-empty description="暂无学习内容" />
</el-card>
</div>
<!-- 加载失败 -->
<div v-else class="error-tip">
<el-empty description="加载任务失败" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import {
ArrowLeft,
Memo,
Calendar,
User,
Clock,
Document,
CircleCheck,
Reading,
VideoPlay,
View
} from '@element-plus/icons-vue';
import { learningTaskApi } from '@/apis/study';
import type { TaskVO, TaskItemVO } from '@/types';
interface Props {
taskId?: string;
showBackButton?: boolean;
backButtonText?: string;
}
defineOptions({
name: 'LearingTaskDetail'
});
const props = withDefaults(defineProps<Props>(), {
showBackButton: false,
backButtonText: '返回'
});
const emit = defineEmits<{
back: [];
}>();
const router = useRouter();
const loading = ref(false);
const taskVO = ref<TaskVO | null>(null);
const currentTaskId = computed(() => props.taskId || '');
// 计算学习进度百分比
const progressPercent = computed(() => {
if (!taskVO.value || !taskVO.value.totalTaskNum || taskVO.value.totalTaskNum === 0) {
return 0;
}
const completed = taskVO.value.completedTaskNum || 0;
const total = taskVO.value.totalTaskNum;
return Math.round((completed / total) * 100);
});
// 进度条颜色
const progressColor = computed(() => {
const progress = progressPercent.value;
if (progress >= 80) return '#67c23a';
if (progress >= 50) return '#409eff';
return '#e6a23c';
});
watch(() => currentTaskId.value, (newId) => {
if (newId) {
loadTaskDetail();
}
}, { immediate: true });
// 加载任务详情
async function loadTaskDetail() {
if (!currentTaskId.value) {
ElMessage.error('任务ID不存在');
return;
}
loading.value = true;
try {
const res = await learningTaskApi.getTaskById(currentTaskId.value);
if (res.success && res.data) {
taskVO.value = res.data;
// 确保数据结构完整
if (!taskVO.value.taskCourses) {
taskVO.value.taskCourses = [];
}
if (!taskVO.value.taskResources) {
taskVO.value.taskResources = [];
}
if (!taskVO.value.taskUsers) {
taskVO.value.taskUsers = [];
}
} else {
ElMessage.error(res.message || '加载任务失败');
}
} catch (error: any) {
console.error('加载任务失败:', error);
ElMessage.error(error?.message || '加载任务失败');
} finally {
loading.value = false;
}
}
// 处理课程点击
function handleCourseClick(course: TaskItemVO) {
if (!course.courseID) {
ElMessage.warning('课程ID不存在');
return;
}
// 跳转到课程学习路由
router.push({
path: '/study-plan/course-study',
query: {
courseId: course.courseID,
chapterIndex: '0',
nodeIndex: '0',
taskId: currentTaskId.value
}
});
}
// 处理资源点击
function handleResourceClick(resource: TaskItemVO) {
if (!resource.resourceID) {
ElMessage.warning('资源ID不存在');
return;
}
// 跳转到文章查看页面
router.push({
path: '/article/show',
query: {
articleId: resource.resourceID,
taskId: currentTaskId.value
}
});
}
// 处理返回
function handleBack() {
emit('back');
}
// 格式化日期
function formatDate(dateString?: string): string {
if (!dateString) return '--';
const date = new Date(dateString);
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
}
// 格式化日期时间
function formatDateTime(dateString?: string): string {
if (!dateString) return '--';
const date = new Date(dateString);
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
// 获取任务状态文本
function getTaskStatusText(status?: number): string {
switch (status) {
case 0:
return '草稿';
case 1:
return '进行中';
case 2:
return '已结束';
default:
return '未知';
}
}
// 获取任务状态类型
function getTaskStatusType(status?: number): 'info' | 'success' | 'warning' | 'danger' {
switch (status) {
case 0:
return 'info';
case 1:
return 'success';
case 2:
return 'warning';
default:
return 'info';
}
}
// 获取课程状态类型
function getCourseStatusType(progress?: boolean): 'success' | 'info' {
return progress ? 'success' : 'info';
}
// 获取资源状态类型
function getResourceStatusType(progress?: boolean): 'success' | 'info' {
return progress ? 'success' : 'info';
}
</script>
<style lang="scss" scoped>
.task-detail {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 60px;
}
.back-header {
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 0;
}
.loading {
padding: 24px;
background: #fff;
margin: 16px;
border-radius: 8px;
}
.task-content {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
display: flex;
align-items: center;
gap: 8px;
.header-icon {
font-size: 20px;
color: #409eff;
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
.item-count {
font-size: 14px;
color: #909399;
}
}
// 任务信息卡片
.task-info-card {
margin-bottom: 24px;
:deep(.el-card__header) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
.card-header {
.header-icon {
color: #fff;
}
.header-title {
color: #fff;
}
}
}
}
.task-info {
.task-title {
font-size: 28px;
font-weight: 600;
color: #303133;
margin: 0 0 24px 0;
line-height: 1.4;
}
.task-meta {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
padding: 20px;
background: #f5f7fa;
border-radius: 8px;
.meta-row {
display: flex;
gap: 40px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
color: #606266;
font-size: 14px;
.el-icon {
color: #909399;
font-size: 16px;
}
.meta-label {
color: #909399;
}
.meta-value {
color: #303133;
font-weight: 500;
}
}
}
.task-description {
margin-bottom: 24px;
.section-subtitle {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0 0 12px 0;
}
p {
color: #606266;
font-size: 14px;
line-height: 1.8;
margin: 0;
}
}
.task-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
.stat-item {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #e4e7ed;
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
.el-icon {
font-size: 24px;
}
&.total {
background: #e3f2fd;
color: #1976d2;
}
&.completed {
background: #e8f5e9;
color: #388e3c;
}
&.learning {
background: #fff3e0;
color: #f57c00;
}
&.pending {
background: #fce4ec;
color: #c2185b;
}
}
.stat-content {
flex: 1;
.stat-label {
font-size: 13px;
color: #909399;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
}
}
}
.learning-progress {
padding: 20px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 8px;
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
.progress-text {
color: #409eff;
font-weight: 600;
font-size: 16px;
}
}
}
}
// 课程卡片
.course-card {
margin-bottom: 24px;
:deep(.el-card__header) {
background: #fff9f0;
}
}
.course-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.course-item {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #e4e7ed;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: #f0f2f5;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateX(4px);
}
.course-index {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
flex-shrink: 0;
}
.course-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
.course-name-row {
display: flex;
align-items: center;
gap: 12px;
.course-name {
font-size: 16px;
font-weight: 500;
color: #303133;
}
}
.course-meta {
display: flex;
align-items: center;
gap: 12px;
.complete-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #67c23a;
.el-icon {
font-size: 16px;
}
}
}
}
.course-action {
flex-shrink: 0;
}
}
// 资源卡片
.resource-card {
margin-bottom: 24px;
:deep(.el-card__header) {
background: #f0f9ff;
}
}
.resource-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.resource-item {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #e4e7ed;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: #f0f2f5;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateX(4px);
}
.resource-index {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
flex-shrink: 0;
}
.resource-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
.resource-name-row {
display: flex;
align-items: center;
gap: 12px;
.resource-name {
font-size: 16px;
font-weight: 500;
color: #303133;
}
}
.resource-meta {
display: flex;
align-items: center;
gap: 12px;
.complete-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #67c23a;
.el-icon {
font-size: 16px;
}
}
}
}
.resource-action {
flex-shrink: 0;
}
}
// 空状态卡片
.empty-card {
margin-bottom: 24px;
}
.error-tip {
padding: 40px;
}
// 响应式设计
@media (max-width: 768px) {
.task-content {
padding: 16px;
}
.task-info {
.task-title {
font-size: 22px;
}
.task-meta {
.meta-row {
flex-direction: column;
gap: 12px;
}
}
.task-stats {
grid-template-columns: repeat(2, 1fr);
}
}
.course-item,
.resource-item {
flex-direction: column;
align-items: flex-start;
.course-action,
.resource-action {
width: 100%;
.el-button {
width: 100%;
}
}
}
}
</style>

View File

@@ -918,7 +918,6 @@ defineExpose({
} }
.btn-link { .btn-link {
background: none;
border: none; border: none;
padding: 4px 8px; padding: 4px 8px;
font-size: 13px; font-size: 13px;

View File

@@ -1,2 +1,3 @@
export { default as LearningTaskAdd } from './LearningTaskAdd.vue'; export { default as LearningTaskAdd } from './LearningTaskAdd.vue';
export { default as LearningTaskList } from './LearningTaskList.vue'; export { default as LearningTaskList } from './LearningTaskList.vue';
export { default as LearingTaskDetail } from './LearingTaskDetail.vue';

View File

@@ -58,7 +58,7 @@ export default defineConfig({
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 8080, port: 8080,
open: true, open: '/schoolNewsWeb/',
// 代理配置 // 代理配置
proxy: { proxy: {