- 封面回显与保存:使用 getFileUrl 统一处理,修复 watch 逻辑 - 课程介绍/排课参考/环创建设回显:修复 API 字段映射和解析 - 测评内容 JSON 格式:新增 parseAssessmentDataForDisplay 前后端统一 - 保存后跳转列表:修复新建/编辑流程的 router.replace - 表单校验:导入课、集体课、领域课各必填一条,下一步时校验 - 保存按钮:修复 @click 将 event 误传为 isDraft 导致不跳转 - Lesson API:updateLesson/updateStep 传入正确的 courseId Made-with: Cursor
532 lines
16 KiB
Vue
532 lines
16 KiB
Vue
<template>
|
||
<div class="course-edit-view">
|
||
<div class="sticky-header">
|
||
<a-page-header :title="isEdit ? '编辑课程包' : '创建课程包'" @back="() => router.back()">
|
||
<template #extra>
|
||
<a-space>
|
||
<a-button @click="handleSaveDraft" :loading="saving">保存草稿</a-button>
|
||
<a-button v-if="currentStep > 0" @click="prevStep">上一步</a-button>
|
||
<a-button v-if="currentStep < 6" type="primary" @click="nextStep">下一步</a-button>
|
||
<a-button v-if="currentStep === 6" type="primary" :loading="saving" @click="() => handleSave(false)">
|
||
{{ isEdit ? '保存' : '创建' }}
|
||
</a-button>
|
||
<a-button v-else-if="isEdit" type="primary" @click="handleSaveAndSubmit" :loading="saving">
|
||
保存
|
||
</a-button>
|
||
</a-space>
|
||
</template>
|
||
</a-page-header>
|
||
</div>
|
||
|
||
<a-spin :spinning="loading" tip="正在加载课程数据...">
|
||
<a-card :bordered="false" style="margin-top: 16px;">
|
||
<!-- 步骤导航 -->
|
||
<a-steps :current="currentStep" size="small" @change="onStepChange">
|
||
<a-step title="基本信息" />
|
||
<a-step title="课程介绍" />
|
||
<a-step title="排课参考" />
|
||
<a-step title="导入课" />
|
||
<a-step title="集体课" />
|
||
<a-step title="领域课" />
|
||
<a-step title="环创建设" />
|
||
</a-steps>
|
||
|
||
<!-- 完成度进度条 -->
|
||
<div class="completion-bar">
|
||
<span>完成度</span>
|
||
<a-progress :percent="completionPercent" :status="completionStatus" size="small" />
|
||
</div>
|
||
|
||
<!-- 步骤内容 -->
|
||
<div class="step-content">
|
||
<!-- 步骤1:基本信息 -->
|
||
<Step1BasicInfo v-show="currentStep === 0" ref="step1Ref" v-model="formData.basic"
|
||
@change="handleDataChange" />
|
||
|
||
<!-- 步骤2:课程介绍 -->
|
||
<Step2CourseIntro v-show="currentStep === 1" ref="step2Ref" v-model="formData.intro"
|
||
@change="handleDataChange" />
|
||
|
||
<!-- 步骤3:排课参考 -->
|
||
<Step3ScheduleRef v-show="currentStep === 2" ref="step3Ref" v-model="formData.scheduleRefData"
|
||
@change="handleDataChange" />
|
||
|
||
<!-- 步骤4:导入课 -->
|
||
<Step4IntroLesson v-show="currentStep === 3" ref="step4Ref" :course-id="courseId"
|
||
@change="handleDataChange" />
|
||
|
||
<!-- 步骤5:集体课 -->
|
||
<Step5CollectiveLesson v-show="currentStep === 4" ref="step5Ref" :course-id="courseId"
|
||
:course-name="formData.basic.name" @change="handleDataChange" />
|
||
|
||
<!-- 步骤6:领域课 -->
|
||
<Step6DomainLessons v-show="currentStep === 5" ref="step6Ref" :course-id="courseId"
|
||
:course-name="formData.basic.name" @change="handleDataChange" />
|
||
|
||
<!-- 步骤7:环创建设 -->
|
||
<Step7Environment v-show="currentStep === 6" ref="step7Ref" v-model="formData.environmentConstruction"
|
||
@change="handleDataChange" />
|
||
</div>
|
||
</a-card>
|
||
</a-spin>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted, provide } from 'vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import { message } from 'ant-design-vue';
|
||
import Step1BasicInfo from '@/components/course-edit/Step1BasicInfo.vue';
|
||
import Step2CourseIntro from '@/components/course-edit/Step2CourseIntro.vue';
|
||
import Step3ScheduleRef from '@/components/course-edit/Step3ScheduleRef.vue';
|
||
import Step4IntroLesson from '@/components/course-edit/Step4IntroLesson.vue';
|
||
import Step5CollectiveLesson from '@/components/course-edit/Step5CollectiveLesson.vue';
|
||
import Step6DomainLessons from '@/components/course-edit/Step6DomainLessons.vue';
|
||
import Step7Environment from '@/components/course-edit/Step7Environment.vue';
|
||
import { getCourse, createCourse, updateCourse } from '@/api/course';
|
||
import {
|
||
getLessonList,
|
||
createLesson,
|
||
updateLesson,
|
||
deleteLesson,
|
||
createStep,
|
||
updateStep,
|
||
deleteStep,
|
||
} from '@/api/lesson';
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
|
||
const isEdit = computed(() => !!route.params.id);
|
||
const courseId = computed(() => route.params.id as string | undefined);
|
||
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
const currentStep = ref(0);
|
||
|
||
// 步骤组件引用
|
||
const step1Ref = ref();
|
||
const step2Ref = ref();
|
||
const step3Ref = ref();
|
||
const step4Ref = ref();
|
||
const step5Ref = ref();
|
||
const step6Ref = ref();
|
||
const step7Ref = ref();
|
||
|
||
// 表单数据
|
||
const formData = reactive({
|
||
basic: {
|
||
name: '',
|
||
themeId: undefined as number | undefined,
|
||
grades: [] as string[],
|
||
pictureBookName: '',
|
||
coreContent: '',
|
||
duration: 25,
|
||
domainTags: [] as string[],
|
||
coverImagePath: '',
|
||
},
|
||
intro: {
|
||
introSummary: '',
|
||
introHighlights: '',
|
||
introGoals: '',
|
||
introSchedule: '',
|
||
introKeyPoints: '',
|
||
introMethods: '',
|
||
introEvaluation: '',
|
||
introNotes: '',
|
||
},
|
||
scheduleRefData: '',
|
||
environmentConstruction: '',
|
||
});
|
||
|
||
// 计算完成度
|
||
const completionPercent = computed(() => {
|
||
let filled = 0;
|
||
let total = 0;
|
||
|
||
// 基本信息必填项
|
||
total += 4;
|
||
if (formData.basic.name) filled++;
|
||
if (formData.basic.themeId) filled++;
|
||
if (formData.basic.grades.length > 0) filled++;
|
||
if (formData.basic.coreContent) filled++;
|
||
|
||
// 课程介绍(8项可选)
|
||
total += 8;
|
||
if (formData.intro.introSummary) filled++;
|
||
if (formData.intro.introHighlights) filled++;
|
||
if (formData.intro.introGoals) filled++;
|
||
if (formData.intro.introSchedule) filled++;
|
||
if (formData.intro.introKeyPoints) filled++;
|
||
if (formData.intro.introMethods) filled++;
|
||
if (formData.intro.introEvaluation) filled++;
|
||
if (formData.intro.introNotes) filled++;
|
||
|
||
return Math.round((filled / total) * 100);
|
||
});
|
||
|
||
const completionStatus = computed(() => {
|
||
if (completionPercent.value >= 75) return 'success';
|
||
if (completionPercent.value >= 50) return 'normal';
|
||
return 'exception';
|
||
});
|
||
|
||
// 获取课程详情
|
||
const fetchCourseDetail = async () => {
|
||
if (!isEdit.value) return;
|
||
|
||
loading.value = true;
|
||
try {
|
||
const res = await getCourse(courseId.value) as any;
|
||
const course = res.data || res;
|
||
|
||
// 已发布的课程包不允许编辑
|
||
if (course?.status === 'PUBLISHED') {
|
||
message.warning('已发布的课程包不允许编辑,请使用「迭代版本」创建新版本');
|
||
router.push(`/admin/packages/${courseId.value}`);
|
||
return;
|
||
}
|
||
|
||
// 基本信息
|
||
formData.basic.name = course.name;
|
||
formData.basic.themeId = course.themeId;
|
||
formData.basic.grades = Array.isArray(course.gradeTags) ? course.gradeTags : (course.gradeTags ? JSON.parse(course.gradeTags) : []);
|
||
formData.basic.pictureBookName = course.pictureBookName || '';
|
||
formData.basic.coreContent = course.coreContent || '';
|
||
formData.basic.duration = course.durationMinutes ?? course.duration ?? 25;
|
||
formData.basic.domainTags = Array.isArray(course.domainTags) ? course.domainTags : (course.domainTags ? JSON.parse(course.domainTags || '[]') : []);
|
||
formData.basic.coverImagePath = course.coverImagePath || '';
|
||
|
||
// 课程介绍
|
||
formData.intro.introSummary = course.introSummary || '';
|
||
formData.intro.introHighlights = course.introHighlights || '';
|
||
formData.intro.introGoals = course.introGoals || '';
|
||
formData.intro.introSchedule = course.introSchedule || '';
|
||
formData.intro.introKeyPoints = course.introKeyPoints || '';
|
||
formData.intro.introMethods = course.introMethods || '';
|
||
formData.intro.introEvaluation = course.introEvaluation || '';
|
||
formData.intro.introNotes = course.introNotes || '';
|
||
|
||
// 排课参考
|
||
formData.scheduleRefData = course.scheduleRefData || '';
|
||
|
||
// 环创建设
|
||
formData.environmentConstruction = course.environmentConstruction || '';
|
||
} catch (error) {
|
||
message.error('获取课程详情失败');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
// 步骤导航
|
||
const onStepChange = async (step: number) => {
|
||
if (step > currentStep.value) {
|
||
const ok = await validateCurrentStep();
|
||
if (!ok) return;
|
||
}
|
||
currentStep.value = step;
|
||
};
|
||
|
||
const prevStep = () => {
|
||
if (currentStep.value > 0) {
|
||
currentStep.value--;
|
||
}
|
||
};
|
||
|
||
const nextStep = async () => {
|
||
const ok = await validateCurrentStep();
|
||
if (!ok) return;
|
||
if (currentStep.value < 6) {
|
||
currentStep.value++;
|
||
}
|
||
};
|
||
|
||
// 校验:导入课、集体课、领域课各必填一条
|
||
const validateAllThreeLessons = (): boolean => {
|
||
const hasIntro = !!step4Ref.value?.lessonData;
|
||
const hasCollective = !!step5Ref.value?.lessonData;
|
||
const domainData = step6Ref.value?.getSaveData?.() || [];
|
||
const hasDomain = Array.isArray(domainData) && domainData.length > 0;
|
||
|
||
if (!hasIntro) {
|
||
message.warning('请配置导入课(至少一节)');
|
||
return false;
|
||
}
|
||
if (!hasCollective) {
|
||
message.warning('请配置集体课(至少一节)');
|
||
return false;
|
||
}
|
||
if (!hasDomain) {
|
||
message.warning('请配置领域课(至少一节)');
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// 验证当前步骤(覆盖全部 7 步)
|
||
const validateCurrentStep = async (): Promise<boolean> => {
|
||
const step = currentStep.value;
|
||
const stepRefs = [
|
||
step1Ref,
|
||
step2Ref,
|
||
step3Ref,
|
||
step4Ref,
|
||
step5Ref,
|
||
step6Ref,
|
||
step7Ref,
|
||
];
|
||
const ref = stepRefs[step]?.value;
|
||
if (!ref?.validate) {
|
||
// 步骤 5(领域课)、步骤 6(环创建设)需额外校验「导入课、集体课、领域课各必填一条」
|
||
if (step === 5 || step === 6) {
|
||
return validateAllThreeLessons();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
const result = await ref.validate();
|
||
if (!result?.valid) {
|
||
message.warning(result?.errors?.[0] || '请完成当前步骤');
|
||
return false;
|
||
}
|
||
|
||
// 步骤 5(领域课)、步骤 6(环创建设)需额外校验「导入课、集体课、领域课各必填一条」
|
||
if (step === 5 || step === 6) {
|
||
return validateAllThreeLessons();
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// 处理数据变化
|
||
const handleDataChange = () => {
|
||
// 数据变化时可以自动保存草稿
|
||
};
|
||
|
||
// 保存草稿
|
||
const handleSaveDraft = async () => {
|
||
await handleSave(true);
|
||
};
|
||
|
||
// 保存
|
||
const handleSave = async (isDraft = false) => {
|
||
// 保存前校验当前步骤
|
||
const ok = await validateCurrentStep();
|
||
if (!ok) return;
|
||
|
||
// 防止重复提交
|
||
if (saving.value) return;
|
||
|
||
saving.value = true;
|
||
let savedCourseId = courseId.value;
|
||
|
||
try {
|
||
console.log('🔍 开始保存课程数据,课程 ID:', savedCourseId);
|
||
// 1. 保存课程包基本信息
|
||
const courseData = {
|
||
name: formData.basic.name,
|
||
themeId: formData.basic.themeId,
|
||
gradeTags: JSON.stringify(formData.basic.grades),
|
||
pictureBookName: formData.basic.pictureBookName,
|
||
coreContent: formData.basic.coreContent,
|
||
durationMinutes: formData.basic.duration,
|
||
domainTags: JSON.stringify(formData.basic.domainTags),
|
||
coverImagePath: formData.basic.coverImagePath,
|
||
// 课程介绍
|
||
introSummary: formData.intro.introSummary,
|
||
introHighlights: formData.intro.introHighlights,
|
||
introGoals: formData.intro.introGoals,
|
||
introSchedule: formData.intro.introSchedule,
|
||
introKeyPoints: formData.intro.introKeyPoints,
|
||
introMethods: formData.intro.introMethods,
|
||
introEvaluation: formData.intro.introEvaluation,
|
||
introNotes: formData.intro.introNotes,
|
||
// 排课参考
|
||
scheduleRefData: formData.scheduleRefData,
|
||
// 环创建设
|
||
environmentConstruction: formData.environmentConstruction,
|
||
};
|
||
|
||
console.log('Saving course data...', { isDraft, isEdit: isEdit.value });
|
||
|
||
if (isEdit.value) {
|
||
await updateCourse(courseId.value, courseData);
|
||
console.log('Course updated successfully');
|
||
} else {
|
||
const res = await createCourse(courseData) as any;
|
||
savedCourseId = res?.id ?? res?.data?.id; // 响应拦截器已返回 data.data,但也兼容直接返回完整响应
|
||
}
|
||
|
||
if (!savedCourseId) {
|
||
throw new Error('无法获取课程ID');
|
||
}
|
||
|
||
// 2. 保存导入课
|
||
try {
|
||
const introLessonData = step4Ref.value?.getSaveData();
|
||
if (introLessonData) {
|
||
console.log('Saving intro lesson...');
|
||
await saveLesson(savedCourseId, introLessonData, 'INTRO');
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Failed to save intro lesson:', error);
|
||
// 继续保存其他内容,不中断
|
||
}
|
||
|
||
// 3. 保存集体课
|
||
try {
|
||
const collectiveLessonData = step5Ref.value?.getSaveData();
|
||
if (collectiveLessonData) {
|
||
console.log('Saving collective lesson...');
|
||
await saveLesson(savedCourseId, collectiveLessonData, 'COLLECTIVE');
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Failed to save collective lesson:', error);
|
||
// 继续保存其他内容,不中断
|
||
}
|
||
|
||
// 4. 保存领域课
|
||
try {
|
||
const domainLessonsData = step6Ref.value?.getSaveData() || [];
|
||
for (const lessonData of domainLessonsData) {
|
||
console.log('Saving domain lesson:', lessonData.lessonType);
|
||
await saveLesson(savedCourseId, lessonData, lessonData.lessonType);
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Failed to save domain lessons:', error);
|
||
// 继续保存其他内容,不中断
|
||
}
|
||
|
||
message.success(isDraft ? '草稿保存成功' : (isEdit.value ? '保存成功' : '创建成功'));
|
||
|
||
if (!isDraft) {
|
||
await router.replace('/admin/packages');
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Save failed:', error);
|
||
const errorMsg = error.response?.data?.message || error.message || '未知错误';
|
||
const errorDetails = error.response?.data?.errors?.join(', ') || '';
|
||
message.error(`保存失败: ${errorMsg}${errorDetails ? ' - ' + errorDetails : ''}`);
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
};
|
||
|
||
// 保存单个课程
|
||
const saveLesson = async (courseId: number | string, lessonData: any, lessonType: string) => {
|
||
const cid = Number(courseId);
|
||
if (!lessonData) {
|
||
console.log('No lesson data to save for type:', lessonType);
|
||
return;
|
||
}
|
||
|
||
const lessonPayload = {
|
||
lessonType: lessonData.lessonType || lessonType,
|
||
name: lessonData.name || `${lessonType === 'INTRO' ? '导入课' : lessonType === 'COLLECTIVE' ? '集体课' : lessonType}`,
|
||
description: lessonData.description || '',
|
||
duration: lessonData.duration || 25,
|
||
videoPath: lessonData.videoPath || '',
|
||
videoName: lessonData.videoName || '',
|
||
pptPath: lessonData.pptPath || '',
|
||
pptName: lessonData.pptName || '',
|
||
pdfPath: lessonData.pdfPath || '',
|
||
pdfName: lessonData.pdfName || '',
|
||
objectives: lessonData.objectives || '',
|
||
preparation: lessonData.preparation || '',
|
||
extension: lessonData.extension || '',
|
||
reflection: lessonData.reflection || '',
|
||
assessmentData: lessonData.assessmentData || '',
|
||
useTemplate: lessonData.useTemplate || false,
|
||
};
|
||
|
||
let lessonId = lessonData.id;
|
||
|
||
try {
|
||
if (lessonData.isNew || !lessonData.id) {
|
||
const res = await createLesson(cid, lessonPayload) as any;
|
||
lessonId = res.data?.id || res.id;
|
||
} else {
|
||
await updateLesson(cid, lessonData.id, lessonPayload);
|
||
}
|
||
|
||
// 保存教学环节
|
||
if (lessonData.steps && lessonData.steps.length > 0 && lessonId) {
|
||
for (const step of lessonData.steps) {
|
||
const stepPayload = {
|
||
name: step.name || '教学环节',
|
||
content: step.content || '',
|
||
duration: step.duration || 5,
|
||
objective: step.objective || '',
|
||
};
|
||
|
||
try {
|
||
if (step.isNew || !step.id) {
|
||
await createStep(cid, lessonId, stepPayload);
|
||
} else {
|
||
await updateStep(cid, step.id, stepPayload);
|
||
}
|
||
} catch (stepError: any) {
|
||
console.error('Failed to save step:', stepError);
|
||
}
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Failed to save lesson:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// 保存并提交
|
||
const handleSaveAndSubmit = async () => {
|
||
await handleSave(false);
|
||
};
|
||
|
||
onMounted(() => {
|
||
fetchCourseDetail();
|
||
});
|
||
|
||
// 提供courseId给子组件
|
||
provide('courseId', courseId);
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.course-edit-view {
|
||
padding: 24px;
|
||
}
|
||
|
||
.sticky-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
margin: -24px -24px 0 -24px;
|
||
padding: 0 24px;
|
||
background: #fff;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||
}
|
||
|
||
.completion-bar {
|
||
margin-top: 16px;
|
||
padding: 12px 16px;
|
||
background: #fafafa;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
|
||
span {
|
||
font-size: 13px;
|
||
color: #666;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
:deep(.ant-progress) {
|
||
flex: 1;
|
||
}
|
||
}
|
||
|
||
.step-content {
|
||
min-height: 400px;
|
||
margin-top: 24px;
|
||
}
|
||
</style>
|