kindergarten_java/reading-platform-frontend/src/views/admin/courses/CourseEditView.vue
zhonghua 877acf33b8 fix(admin): 课程包编辑页问题修复
- 封面回显与保存:使用 getFileUrl 统一处理,修复 watch 逻辑
- 课程介绍/排课参考/环创建设回显:修复 API 字段映射和解析
- 测评内容 JSON 格式:新增 parseAssessmentDataForDisplay 前后端统一
- 保存后跳转列表:修复新建/编辑流程的 router.replace
- 表单校验:导入课、集体课、领域课各必填一条,下一步时校验
- 保存按钮:修复 @click 将 event 误传为 isDraft 导致不跳转
- Lesson API:updateLesson/updateStep 传入正确的 courseId

Made-with: Cursor
2026-03-23 15:15:56 +08:00

532 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="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>