fix(admin): 课程包编辑页问题修复

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

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-23 15:15:46 +08:00
parent 36b8621060
commit 877acf33b8
9 changed files with 115 additions and 134 deletions

View File

@ -211,11 +211,18 @@ export const fileApi = {
/**
* URL
* OSS URL /
*/
getFileUrl: (filePath: string): string => {
// filePath 格式: /uploads/courses/covers/xxx.png
// 直接返回相对路径,由 nginx 或后端静态服务处理
return filePath;
getFileUrl: (filePath: string | null | undefined): string => {
if (!filePath) return '';
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return filePath;
}
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || '/api';
if (filePath.startsWith('/')) {
return `${SERVER_BASE}${filePath}`;
}
return `${SERVER_BASE}/uploads/${filePath}`;
},
};

View File

@ -105,8 +105,8 @@ export function createLesson(courseId: number, data: CreateLessonData) {
}
// 更新课程
export function updateLesson(lessonId: number, data: Partial<CreateLessonData>) {
return http.put(`/v1/admin/courses/0/lessons/${lessonId}`, data);
export function updateLesson(courseId: number, lessonId: number, data: Partial<CreateLessonData>) {
return http.put(`/v1/admin/courses/${courseId}/lessons/${lessonId}`, data);
}
// 删除课程
@ -132,8 +132,8 @@ export function createStep(courseId: number, lessonId: number, data: CreateStepD
}
// 更新环节
export function updateStep(stepId: number, data: Partial<CreateStepData>) {
return http.put(`/v1/admin/courses/0/lessons/steps/${stepId}`, data);
export function updateStep(courseId: number, stepId: number, data: Partial<CreateStepData>) {
return http.put(`/v1/admin/courses/${courseId}/lessons/steps/${stepId}`, data);
}
// 删除环节

View File

@ -126,7 +126,7 @@ import { ref, reactive, watch, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { getThemeList } from '@/api/theme';
import { uploadFile } from '@/api/file';
import { uploadFile, getFileUrl } from '@/api/file';
import type { Theme } from '@/api/theme';
interface BasicInfoData {
@ -206,19 +206,16 @@ watch(
if (newVal) {
Object.assign(formData, newVal);
//
if (newVal.coverImagePath && coverImages.value.length === 0) {
// URL
let imageUrl = newVal.coverImagePath;
if (!imageUrl.startsWith('http') && !imageUrl.startsWith('/uploads') && !imageUrl.includes('/uploads/')) {
imageUrl = `/uploads/${imageUrl}`;
}
//
if (newVal.coverImagePath) {
coverImages.value = [{
uid: '-1',
name: 'cover',
status: 'done',
url: imageUrl,
url: getFileUrl(newVal.coverImagePath),
}];
} else {
coverImages.value = [];
}
}
},
@ -255,16 +252,11 @@ const beforeCoverUpload = async (file: any) => {
try {
const result = await uploadFile(file, 'cover');
formData.coverImagePath = result.filePath;
// URL - filePath
let imageUrl = result.filePath;
if (!imageUrl.startsWith('http') && !imageUrl.startsWith('/uploads') && !imageUrl.includes('/uploads/')) {
imageUrl = `/uploads/${imageUrl}`;
}
coverImages.value = [{
uid: file.uid,
name: file.name,
status: 'done',
url: imageUrl,
url: getFileUrl(result.filePath),
}];
handleChange();
message.success('封面上传成功');

View File

@ -133,17 +133,20 @@ const tableData = ref<ScheduleRow[]>([]);
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
try {
const parsed = JSON.parse(newVal);
tableData.value = parsed.map((row: any, index: number) => ({
...row,
key: row.key || `row_${index}`,
}));
} catch (e) {
console.error('解析排课数据失败', e);
tableData.value = [];
}
if (!newVal || typeof newVal !== 'string') {
tableData.value = [];
return;
}
try {
const parsed = JSON.parse(newVal);
const rows = Array.isArray(parsed) ? parsed : [];
tableData.value = rows.map((row: any, index: number) => ({
...row,
key: row.key || `row_${index}`,
}));
} catch (e) {
console.error('解析排课数据失败', e);
tableData.value = [];
}
},
{ immediate: true }

View File

@ -60,6 +60,7 @@ import { PlusOutlined } from '@ant-design/icons-vue';
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
import { getLessonByType, createLesson, updateLesson, deleteLesson as deleteLessonApi } from '@/api/lesson';
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
interface Props {
courseId: number;
@ -100,7 +101,7 @@ const fetchLesson = async () => {
preparation: lesson.preparation || '',
extension: lesson.extension || '',
reflection: lesson.reflection || '',
assessmentData: lesson.assessmentData || '',
assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
useTemplate: lesson.useTemplate || false,
steps: lesson.steps || [],
isNew: false,
@ -159,10 +160,10 @@ const handleLessonChange = () => {
emit('change');
};
// formRules
//
const validate = async () => {
if (!lessonData.value) {
return { valid: true, errors: [] as string[], warnings: ['未配置导入课'] };
return { valid: false, errors: ['请配置导入课(至少一条)'] };
}
return configPanelRef.value?.validate() ?? { valid: true, errors: [] as string[] };
};

View File

@ -60,6 +60,7 @@ import { PlusOutlined } from '@ant-design/icons-vue';
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
import { getLessonByType, createLesson as createLessonApi, updateLesson, deleteLesson as deleteLessonApi } from '@/api/lesson';
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
interface Props {
courseId: number;
@ -101,7 +102,7 @@ const fetchLesson = async () => {
preparation: lesson.preparation || '',
extension: lesson.extension || '',
reflection: lesson.reflection || '',
assessmentData: lesson.assessmentData || '',
assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
useTemplate: lesson.useTemplate || false,
steps: lesson.steps || [],
isNew: false,
@ -160,10 +161,10 @@ const handleLessonChange = () => {
emit('change');
};
// formRules
//
const validate = async () => {
if (!lessonData.value) {
return { valid: true, errors: [] as string[], warnings: ['未配置集体课'] };
return { valid: false, errors: ['请配置集体课(至少一条)'] };
}
return configPanelRef.value?.validate() ?? { valid: true, errors: [] as string[] };
};

View File

@ -93,6 +93,7 @@ import {
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
import { getLessonList, createLesson, updateLesson, deleteLesson } from '@/api/lesson';
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
interface DomainConfig {
type: string;
@ -204,7 +205,7 @@ const fetchLessons = async () => {
preparation: lesson.preparation || '',
extension: lesson.extension || '',
reflection: lesson.reflection || '',
assessmentData: lesson.assessmentData || '',
assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
useTemplate: lesson.useTemplate || false,
steps: lesson.steps || [],
isNew: false,
@ -266,8 +267,13 @@ const handleLessonChange = () => {
emit('change');
};
// formRules
// formRules
const validate = async () => {
const saveData = getSaveData();
if (!saveData || saveData.length === 0) {
return { valid: false, errors: ['请配置领域课(至少一条)'] };
}
const enabledDomains = domains.filter((d) => d.enabled);
const allErrors: string[] = [];

View File

@ -0,0 +1,19 @@
/**
* assessmentData
* JSON "核心内容"
*/
export function parseAssessmentDataForDisplay(value: string | null | undefined): string {
if (value == null || value === '') return '';
const trimmed = value.trim();
if (!trimmed) return '';
// 若是 JSON 字符串格式(如 "核心内容"),解析后返回明文
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === 'string' ? parsed : trimmed;
} catch {
return trimmed;
}
}
return trimmed;
}

View File

@ -1,21 +1,13 @@
<template>
<div class="course-edit-view">
<div class="sticky-header">
<a-page-header
:title="isEdit ? '编辑课程包' : '创建课程包'"
@back="() => router.back()"
>
<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"
>
<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">
@ -48,62 +40,32 @@
<!-- 步骤内容 -->
<div class="step-content">
<!-- 步骤1基本信息 -->
<Step1BasicInfo
v-show="currentStep === 0"
ref="step1Ref"
v-model="formData.basic"
@change="handleDataChange"
/>
<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"
/>
<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"
/>
<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"
/>
<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"
/>
<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"
/>
<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"
/>
<Step7Environment v-show="currentStep === 6" ref="step7Ref" v-model="formData.environmentConstruction"
@change="handleDataChange" />
</div>
</a-card>
</a-spin>
@ -231,8 +193,8 @@ const fetchCourseDetail = async () => {
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.duration || 25;
formData.basic.domainTags = course.domainTags ? JSON.parse(course.domainTags) : [];
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 || '';
//
@ -280,18 +242,26 @@ const nextStep = async () => {
}
};
//
const validateAtLeastOneLesson = (): boolean => {
//
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 || hasCollective || hasDomain) {
return true;
if (!hasIntro) {
message.warning('请配置导入课(至少一节)');
return false;
}
message.warning('请至少配置一种课程:导入课、集体课或领域课(至少完成一个领域)');
return false;
if (!hasCollective) {
message.warning('请配置集体课(至少一节)');
return false;
}
if (!hasDomain) {
message.warning('请配置领域课(至少一节)');
return false;
}
return true;
};
// 7
@ -308,9 +278,9 @@ const validateCurrentStep = async (): Promise<boolean> => {
];
const ref = stepRefs[step]?.value;
if (!ref?.validate) {
// 5 6
// 5 6
if (step === 5 || step === 6) {
return validateAtLeastOneLesson();
return validateAllThreeLessons();
}
return true;
}
@ -321,9 +291,9 @@ const validateCurrentStep = async (): Promise<boolean> => {
return false;
}
// 5 6
// 5 6
if (step === 5 || step === 6) {
return validateAtLeastOneLesson();
return validateAllThreeLessons();
}
return true;
};
@ -384,13 +354,7 @@ const handleSave = async (isDraft = false) => {
console.log('Course updated successfully');
} else {
const res = await createCourse(courseData) as any;
console.log('🔍 创建课程返回结果:', JSON.stringify(res, null, 2));
savedCourseId = res?.id || res?.data?.id; // data.data
console.log('Course created with ID:', savedCourseId);
//
if (savedCourseId) {
router.replace(`/admin/packages/${savedCourseId}/edit`);
}
savedCourseId = res?.id ?? res?.data?.id; // data.data
}
if (!savedCourseId) {
@ -433,18 +397,10 @@ const handleSave = async (isDraft = false) => {
//
}
console.log('✅ 所有课程数据保存完成,准备显示成功提示...');
message.success(isDraft ? '草稿保存成功' : (isEdit.value ? '保存成功' : '创建成功'));
console.log('✅ 成功提示已显示,准备跳转...');
if (!isDraft) {
console.log('🚀 准备跳转到课程列表页面...');
console.log('🚀 isDraft =', isDraft, ', isEdit =', isEdit.value);
//
await new Promise(resolve => setTimeout(resolve, 500));
console.log('🚀 即将执行 router.push 跳转...');
await router.push('/admin/packages');
console.log('✅ 已执行 router.push 跳转');
await router.replace('/admin/packages');
}
} catch (error: any) {
console.error('Save failed:', error);
@ -457,7 +413,8 @@ const handleSave = async (isDraft = false) => {
};
//
const saveLesson = async (courseId: number, lessonData: any, lessonType: string) => {
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;
@ -486,15 +443,10 @@ const saveLesson = async (courseId: number, lessonData: any, lessonType: string)
try {
if (lessonData.isNew || !lessonData.id) {
//
console.log('Creating new lesson:', lessonType);
const res = await createLesson(courseId, lessonPayload) as any;
const res = await createLesson(cid, lessonPayload) as any;
lessonId = res.data?.id || res.id;
console.log('Lesson created with ID:', lessonId);
} else {
//
console.log('Updating lesson:', lessonId);
await updateLesson(lessonData.id, lessonPayload);
await updateLesson(cid, lessonData.id, lessonPayload);
}
//
@ -509,9 +461,9 @@ const saveLesson = async (courseId: number, lessonData: any, lessonType: string)
try {
if (step.isNew || !step.id) {
await createStep(courseId, lessonId, stepPayload);
await createStep(cid, lessonId, stepPayload);
} else {
await updateStep(step.id, stepPayload);
await updateStep(cid, step.id, stepPayload);
}
} catch (stepError: any) {
console.error('Failed to save step:', stepError);