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 * URL
* OSS URL /
*/ */
getFileUrl: (filePath: string): string => { getFileUrl: (filePath: string | null | undefined): string => {
// filePath 格式: /uploads/courses/covers/xxx.png if (!filePath) return '';
// 直接返回相对路径,由 nginx 或后端静态服务处理 if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return filePath; 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>) { export function updateLesson(courseId: number, lessonId: number, data: Partial<CreateLessonData>) {
return http.put(`/v1/admin/courses/0/lessons/${lessonId}`, data); 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>) { export function updateStep(courseId: number, stepId: number, data: Partial<CreateStepData>) {
return http.put(`/v1/admin/courses/0/lessons/steps/${stepId}`, data); 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 { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue'; import { PlusOutlined } from '@ant-design/icons-vue';
import { getThemeList } from '@/api/theme'; import { getThemeList } from '@/api/theme';
import { uploadFile } from '@/api/file'; import { uploadFile, getFileUrl } from '@/api/file';
import type { Theme } from '@/api/theme'; import type { Theme } from '@/api/theme';
interface BasicInfoData { interface BasicInfoData {
@ -206,19 +206,16 @@ watch(
if (newVal) { if (newVal) {
Object.assign(formData, newVal); Object.assign(formData, newVal);
// //
if (newVal.coverImagePath && coverImages.value.length === 0) { if (newVal.coverImagePath) {
// URL
let imageUrl = newVal.coverImagePath;
if (!imageUrl.startsWith('http') && !imageUrl.startsWith('/uploads') && !imageUrl.includes('/uploads/')) {
imageUrl = `/uploads/${imageUrl}`;
}
coverImages.value = [{ coverImages.value = [{
uid: '-1', uid: '-1',
name: 'cover', name: 'cover',
status: 'done', status: 'done',
url: imageUrl, url: getFileUrl(newVal.coverImagePath),
}]; }];
} else {
coverImages.value = [];
} }
} }
}, },
@ -255,16 +252,11 @@ const beforeCoverUpload = async (file: any) => {
try { try {
const result = await uploadFile(file, 'cover'); const result = await uploadFile(file, 'cover');
formData.coverImagePath = result.filePath; 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 = [{ coverImages.value = [{
uid: file.uid, uid: file.uid,
name: file.name, name: file.name,
status: 'done', status: 'done',
url: imageUrl, url: getFileUrl(result.filePath),
}]; }];
handleChange(); handleChange();
message.success('封面上传成功'); message.success('封面上传成功');

View File

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

View File

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

View File

@ -93,6 +93,7 @@ import {
import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue'; import LessonConfigPanel from '@/components/course/LessonConfigPanel.vue';
import type { LessonData } from '@/components/course/LessonConfigPanel.vue'; import type { LessonData } from '@/components/course/LessonConfigPanel.vue';
import { getLessonList, createLesson, updateLesson, deleteLesson } from '@/api/lesson'; import { getLessonList, createLesson, updateLesson, deleteLesson } from '@/api/lesson';
import { parseAssessmentDataForDisplay } from '@/utils/assessmentData';
interface DomainConfig { interface DomainConfig {
type: string; type: string;
@ -204,7 +205,7 @@ const fetchLessons = async () => {
preparation: lesson.preparation || '', preparation: lesson.preparation || '',
extension: lesson.extension || '', extension: lesson.extension || '',
reflection: lesson.reflection || '', reflection: lesson.reflection || '',
assessmentData: lesson.assessmentData || '', assessmentData: parseAssessmentDataForDisplay(lesson.assessmentData),
useTemplate: lesson.useTemplate || false, useTemplate: lesson.useTemplate || false,
steps: lesson.steps || [], steps: lesson.steps || [],
isNew: false, isNew: false,
@ -266,8 +267,13 @@ const handleLessonChange = () => {
emit('change'); emit('change');
}; };
// formRules // formRules
const validate = async () => { const validate = async () => {
const saveData = getSaveData();
if (!saveData || saveData.length === 0) {
return { valid: false, errors: ['请配置领域课(至少一条)'] };
}
const enabledDomains = domains.filter((d) => d.enabled); const enabledDomains = domains.filter((d) => d.enabled);
const allErrors: string[] = []; 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> <template>
<div class="course-edit-view"> <div class="course-edit-view">
<div class="sticky-header"> <div class="sticky-header">
<a-page-header <a-page-header :title="isEdit ? '编辑课程包' : '创建课程包'" @back="() => router.back()">
:title="isEdit ? '编辑课程包' : '创建课程包'"
@back="() => router.back()"
>
<template #extra> <template #extra>
<a-space> <a-space>
<a-button @click="handleSaveDraft" :loading="saving">保存草稿</a-button> <a-button @click="handleSaveDraft" :loading="saving">保存草稿</a-button>
<a-button v-if="currentStep > 0" @click="prevStep">上一步</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" @click="nextStep">下一步</a-button>
<a-button <a-button v-if="currentStep === 6" type="primary" :loading="saving" @click="() => handleSave(false)">
v-if="currentStep === 6"
type="primary"
:loading="saving"
@click="handleSave"
>
{{ isEdit ? '保存' : '创建' }} {{ isEdit ? '保存' : '创建' }}
</a-button> </a-button>
<a-button v-else-if="isEdit" type="primary" @click="handleSaveAndSubmit" :loading="saving"> <a-button v-else-if="isEdit" type="primary" @click="handleSaveAndSubmit" :loading="saving">
@ -48,62 +40,32 @@
<!-- 步骤内容 --> <!-- 步骤内容 -->
<div class="step-content"> <div class="step-content">
<!-- 步骤1基本信息 --> <!-- 步骤1基本信息 -->
<Step1BasicInfo <Step1BasicInfo v-show="currentStep === 0" ref="step1Ref" v-model="formData.basic"
v-show="currentStep === 0" @change="handleDataChange" />
ref="step1Ref"
v-model="formData.basic"
@change="handleDataChange"
/>
<!-- 步骤2课程介绍 --> <!-- 步骤2课程介绍 -->
<Step2CourseIntro <Step2CourseIntro v-show="currentStep === 1" ref="step2Ref" v-model="formData.intro"
v-show="currentStep === 1" @change="handleDataChange" />
ref="step2Ref"
v-model="formData.intro"
@change="handleDataChange"
/>
<!-- 步骤3排课参考 --> <!-- 步骤3排课参考 -->
<Step3ScheduleRef <Step3ScheduleRef v-show="currentStep === 2" ref="step3Ref" v-model="formData.scheduleRefData"
v-show="currentStep === 2" @change="handleDataChange" />
ref="step3Ref"
v-model="formData.scheduleRefData"
@change="handleDataChange"
/>
<!-- 步骤4导入课 --> <!-- 步骤4导入课 -->
<Step4IntroLesson <Step4IntroLesson v-show="currentStep === 3" ref="step4Ref" :course-id="courseId"
v-show="currentStep === 3" @change="handleDataChange" />
ref="step4Ref"
:course-id="courseId"
@change="handleDataChange"
/>
<!-- 步骤5集体课 --> <!-- 步骤5集体课 -->
<Step5CollectiveLesson <Step5CollectiveLesson v-show="currentStep === 4" ref="step5Ref" :course-id="courseId"
v-show="currentStep === 4" :course-name="formData.basic.name" @change="handleDataChange" />
ref="step5Ref"
:course-id="courseId"
:course-name="formData.basic.name"
@change="handleDataChange"
/>
<!-- 步骤6领域课 --> <!-- 步骤6领域课 -->
<Step6DomainLessons <Step6DomainLessons v-show="currentStep === 5" ref="step6Ref" :course-id="courseId"
v-show="currentStep === 5" :course-name="formData.basic.name" @change="handleDataChange" />
ref="step6Ref"
:course-id="courseId"
:course-name="formData.basic.name"
@change="handleDataChange"
/>
<!-- 步骤7环创建设 --> <!-- 步骤7环创建设 -->
<Step7Environment <Step7Environment v-show="currentStep === 6" ref="step7Ref" v-model="formData.environmentConstruction"
v-show="currentStep === 6" @change="handleDataChange" />
ref="step7Ref"
v-model="formData.environmentConstruction"
@change="handleDataChange"
/>
</div> </div>
</a-card> </a-card>
</a-spin> </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.grades = Array.isArray(course.gradeTags) ? course.gradeTags : (course.gradeTags ? JSON.parse(course.gradeTags) : []);
formData.basic.pictureBookName = course.pictureBookName || ''; formData.basic.pictureBookName = course.pictureBookName || '';
formData.basic.coreContent = course.coreContent || ''; formData.basic.coreContent = course.coreContent || '';
formData.basic.duration = course.duration || 25; formData.basic.duration = course.durationMinutes ?? course.duration ?? 25;
formData.basic.domainTags = course.domainTags ? JSON.parse(course.domainTags) : []; formData.basic.domainTags = Array.isArray(course.domainTags) ? course.domainTags : (course.domainTags ? JSON.parse(course.domainTags || '[]') : []);
formData.basic.coverImagePath = course.coverImagePath || ''; formData.basic.coverImagePath = course.coverImagePath || '';
// //
@ -280,18 +242,26 @@ const nextStep = async () => {
} }
}; };
// //
const validateAtLeastOneLesson = (): boolean => { const validateAllThreeLessons = (): boolean => {
const hasIntro = !!step4Ref.value?.lessonData; const hasIntro = !!step4Ref.value?.lessonData;
const hasCollective = !!step5Ref.value?.lessonData; const hasCollective = !!step5Ref.value?.lessonData;
const domainData = step6Ref.value?.getSaveData?.() || []; const domainData = step6Ref.value?.getSaveData?.() || [];
const hasDomain = Array.isArray(domainData) && domainData.length > 0; const hasDomain = Array.isArray(domainData) && domainData.length > 0;
if (hasIntro || hasCollective || hasDomain) { if (!hasIntro) {
return true; message.warning('请配置导入课(至少一节)');
}
message.warning('请至少配置一种课程:导入课、集体课或领域课(至少完成一个领域)');
return false; return false;
}
if (!hasCollective) {
message.warning('请配置集体课(至少一节)');
return false;
}
if (!hasDomain) {
message.warning('请配置领域课(至少一节)');
return false;
}
return true;
}; };
// 7 // 7
@ -308,9 +278,9 @@ const validateCurrentStep = async (): Promise<boolean> => {
]; ];
const ref = stepRefs[step]?.value; const ref = stepRefs[step]?.value;
if (!ref?.validate) { if (!ref?.validate) {
// 5 6 // 5 6
if (step === 5 || step === 6) { if (step === 5 || step === 6) {
return validateAtLeastOneLesson(); return validateAllThreeLessons();
} }
return true; return true;
} }
@ -321,9 +291,9 @@ const validateCurrentStep = async (): Promise<boolean> => {
return false; return false;
} }
// 5 6 // 5 6
if (step === 5 || step === 6) { if (step === 5 || step === 6) {
return validateAtLeastOneLesson(); return validateAllThreeLessons();
} }
return true; return true;
}; };
@ -384,13 +354,7 @@ const handleSave = async (isDraft = false) => {
console.log('Course updated successfully'); console.log('Course updated successfully');
} else { } else {
const res = await createCourse(courseData) as any; const res = await createCourse(courseData) as any;
console.log('🔍 创建课程返回结果:', JSON.stringify(res, null, 2)); savedCourseId = res?.id ?? res?.data?.id; // data.data
savedCourseId = res?.id || res?.data?.id; // data.data
console.log('Course created with ID:', savedCourseId);
//
if (savedCourseId) {
router.replace(`/admin/packages/${savedCourseId}/edit`);
}
} }
if (!savedCourseId) { if (!savedCourseId) {
@ -433,18 +397,10 @@ const handleSave = async (isDraft = false) => {
// //
} }
console.log('✅ 所有课程数据保存完成,准备显示成功提示...');
message.success(isDraft ? '草稿保存成功' : (isEdit.value ? '保存成功' : '创建成功')); message.success(isDraft ? '草稿保存成功' : (isEdit.value ? '保存成功' : '创建成功'));
console.log('✅ 成功提示已显示,准备跳转...');
if (!isDraft) { if (!isDraft) {
console.log('🚀 准备跳转到课程列表页面...'); await router.replace('/admin/packages');
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 跳转');
} }
} catch (error: any) { } catch (error: any) {
console.error('Save failed:', error); 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) { if (!lessonData) {
console.log('No lesson data to save for type:', lessonType); console.log('No lesson data to save for type:', lessonType);
return; return;
@ -486,15 +443,10 @@ const saveLesson = async (courseId: number, lessonData: any, lessonType: string)
try { try {
if (lessonData.isNew || !lessonData.id) { if (lessonData.isNew || !lessonData.id) {
// const res = await createLesson(cid, lessonPayload) as any;
console.log('Creating new lesson:', lessonType);
const res = await createLesson(courseId, lessonPayload) as any;
lessonId = res.data?.id || res.id; lessonId = res.data?.id || res.id;
console.log('Lesson created with ID:', lessonId);
} else { } else {
// await updateLesson(cid, lessonData.id, lessonPayload);
console.log('Updating lesson:', lessonId);
await updateLesson(lessonData.id, lessonPayload);
} }
// //
@ -509,9 +461,9 @@ const saveLesson = async (courseId: number, lessonData: any, lessonType: string)
try { try {
if (step.isNew || !step.id) { if (step.isNew || !step.id) {
await createStep(courseId, lessonId, stepPayload); await createStep(cid, lessonId, stepPayload);
} else { } else {
await updateStep(step.id, stepPayload); await updateStep(cid, step.id, stepPayload);
} }
} catch (stepError: any) { } catch (stepError: any) {
console.error('Failed to save step:', stepError); console.error('Failed to save step:', stepError);