feat(课程包): 表单校验增强与交互优化

- 顶部操作栏吸顶,下一步/保存草稿合并到顶部
- 导入课、集体课、领域课至少配置一种的校验
- 领域课 getSaveData 仅返回已填写教学目标的领域
- 教学环节添加必填标识(*)

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-18 16:06:35 +08:00
parent 694fda88c0
commit a72984c860
7 changed files with 201 additions and 104 deletions

View File

@ -1,11 +1,13 @@
<template> <template>
<div class="step1-basic-info"> <div class="step1-basic-info">
<a-form <a-form
ref="formRef"
:model="formData" :model="formData"
:rules="formRules"
:label-col="{ span: 4 }" :label-col="{ span: 4 }"
:wrapper-col="{ span: 16 }" :wrapper-col="{ span: 16 }"
> >
<a-form-item label="课程包名称" required> <a-form-item label="课程包名称" name="name" required>
<a-input <a-input
v-model:value="formData.name" v-model:value="formData.name"
placeholder="请输入课程包名称" placeholder="请输入课程包名称"
@ -15,7 +17,7 @@
/> />
</a-form-item> </a-form-item>
<a-form-item label="关联主题" required> <a-form-item label="关联主题" name="themeId" required>
<a-select <a-select
v-model:value="formData.themeId" v-model:value="formData.themeId"
placeholder="请选择主题" placeholder="请选择主题"
@ -29,7 +31,7 @@
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="适用年级" required> <a-form-item label="适用年级" name="grades" required>
<a-checkbox-group v-model:value="formData.grades" @change="handleChange"> <a-checkbox-group v-model:value="formData.grades" @change="handleChange">
<a-checkbox value="小班">小班</a-checkbox> <a-checkbox value="小班">小班</a-checkbox>
<a-checkbox value="中班">中班</a-checkbox> <a-checkbox value="中班">中班</a-checkbox>
@ -37,15 +39,16 @@
</a-checkbox-group> </a-checkbox-group>
</a-form-item> </a-form-item>
<a-form-item label="关联绘本"> <a-form-item label="关联绘本" name="pictureBookName">
<a-input <a-input
v-model:value="formData.pictureBookName" v-model:value="formData.pictureBookName"
placeholder="请输入关联绘本名称(可选)" placeholder="请输入关联绘本名称(可选)"
:maxlength="100"
@change="handleChange" @change="handleChange"
/> />
</a-form-item> </a-form-item>
<a-form-item label="核心内容" required> <a-form-item label="核心内容" name="coreContent" required>
<a-textarea <a-textarea
v-model:value="formData.coreContent" v-model:value="formData.coreContent"
:rows="3" :rows="3"
@ -56,7 +59,7 @@
/> />
</a-form-item> </a-form-item>
<a-form-item label="课程时长"> <a-form-item label="课程时长" name="duration">
<a-input-number <a-input-number
v-model:value="formData.duration" v-model:value="formData.duration"
:min="5" :min="5"
@ -67,7 +70,7 @@
<span class="duration-unit">分钟</span> <span class="duration-unit">分钟</span>
</a-form-item> </a-form-item>
<a-form-item label="核心发展目标"> <a-form-item label="核心发展目标" name="domainTags">
<a-select <a-select
v-model:value="formData.domainTags" v-model:value="formData.domainTags"
mode="multiple" mode="multiple"
@ -148,10 +151,43 @@ const emit = defineEmits<{
(e: 'change'): void; (e: 'change'): void;
}>(); }>();
const formRef = ref();
const themesLoading = ref(false); const themesLoading = ref(false);
const themes = ref<Theme[]>([]); const themes = ref<Theme[]>([]);
const coverImages = ref<any[]>([]); const coverImages = ref<any[]>([]);
const formRules = {
name: [
{ required: true, message: '请输入课程包名称' },
{ max: 50, message: '课程包名称不能超过50个字' },
{
validator: (_: unknown, value: string) => {
if (value && value.trim().length === 0) {
return Promise.reject(new Error('请输入课程包名称'));
}
return Promise.resolve();
},
},
],
themeId: [{ required: true, message: '请选择关联主题' }],
grades: [
{
required: true,
message: '请至少选择一个适用年级',
type: 'array',
min: 1,
},
],
coreContent: [
{ required: true, message: '请输入核心内容' },
{ max: 200, message: '核心内容不能超过200个字' },
],
duration: [
{ type: 'number' as const, min: 5, max: 120, message: '课程时长需为 5-120 的整数' },
],
pictureBookName: [{ max: 100, message: '关联绘本名称不能超过100个字' }],
};
const formData = reactive<BasicInfoData>({ const formData = reactive<BasicInfoData>({
name: '', name: '',
themeId: undefined, themeId: undefined,
@ -252,21 +288,17 @@ const handleChange = () => {
}; };
// //
const validate = () => { const validate = async () => {
const errors: string[] = []; try {
if (!formData.name) { await formRef.value?.validate();
errors.push('请输入课程包名称'); return { valid: true, errors: [] as string[] };
} catch (err: any) {
const errorFields = err?.errorFields || [];
const errors = errorFields
.map((f: any) => f.errors?.[0])
.filter(Boolean) as string[];
return { valid: false, errors: errors.length ? errors : ['请完成基本信息'] };
} }
if (!formData.themeId) {
errors.push('请选择关联主题');
}
if (formData.grades.length === 0) {
errors.push('请选择适用年级');
}
if (!formData.coreContent) {
errors.push('请输入核心内容');
}
return { valid: errors.length === 0, errors };
}; };
onMounted(() => { onMounted(() => {

View File

@ -198,9 +198,18 @@ const handleChange = () => {
emit('change'); emit('change');
}; };
// //
const validate = () => { const validate = () => {
// const rows = tableData.value || [];
if (rows.length === 0) {
return { valid: true, errors: [] };
}
const invalidRow = rows.find(
(row) => row.lessonType && (!row.lessonName || !row.lessonName.trim())
);
if (invalidRow) {
return { valid: false, errors: ['请填写课程名称'] };
}
return { valid: true, errors: [] }; return { valid: true, errors: [] };
}; };

View File

@ -154,20 +154,29 @@ const handleLessonChange = () => {
emit('change'); emit('change');
}; };
// //
const validate = () => { const validate = () => {
if (!lessonData.value) { if (!lessonData.value) {
return { valid: true, errors: [], warnings: ['未配置导入课'] }; return { valid: true, errors: [] as string[], warnings: ['未配置导入课'] };
} }
if (!lessonData.value.objectives) { const errors: string[] = [];
return { valid: false, errors: ['请输入教学目标'] }; if (!lessonData.value.objectives?.trim()) {
errors.push('请输入教学目标');
} }
if (!lessonData.value.preparation) { if (!lessonData.value.preparation?.trim()) {
return { valid: false, errors: ['请输入教学准备'] }; errors.push('请输入教学准备');
}
const duration = lessonData.value.duration;
if (duration != null && (duration < 5 || duration > 15)) {
errors.push('导入课时长需在 5-15 分钟之间');
}
const steps = lessonData.value.steps || [];
if (steps.length < 1) {
errors.push('请至少添加一个教学环节');
} }
return { valid: true, errors: [] }; return { valid: errors.length === 0, errors };
}; };
// //

View File

@ -155,22 +155,26 @@ const handleLessonChange = () => {
emit('change'); emit('change');
}; };
// // 15-45
const validate = () => { const validate = () => {
if (!lessonData.value) { if (!lessonData.value) {
return { valid: true, errors: [], warnings: ['未配置集体课'] }; return { valid: true, errors: [] as string[], warnings: ['未配置集体课'] };
} }
const errors: string[] = []; const errors: string[] = [];
if (!lessonData.value.objectives) { if (!lessonData.value.objectives?.trim()) {
errors.push('请输入教学目标'); errors.push('请输入教学目标');
} }
if (!lessonData.value.preparation) { if (!lessonData.value.preparation?.trim()) {
errors.push('请输入教学准备'); errors.push('请输入教学准备');
} }
if (!lessonData.value.videoPath && !lessonData.value.pptPath && !lessonData.value.pdfPath) { if (!lessonData.value.videoPath && !lessonData.value.pptPath && !lessonData.value.pdfPath) {
errors.push('请至少上传一个核心资源(动画/课件/电子绘本)'); errors.push('请至少上传一个核心资源(动画/课件/电子绘本)');
} }
const duration = lessonData.value.duration;
if (duration != null && (duration < 15 || duration > 45)) {
errors.push('集体课时长需在 15-45 分钟之间');
}
return { valid: errors.length === 0, errors }; return { valid: errors.length === 0, errors };
}; };

View File

@ -253,25 +253,30 @@ const handleLessonChange = () => {
emit('change'); emit('change');
}; };
// // 15-45
const validate = () => { const validate = () => {
// const enabledDomains = domains.filter((d) => d.enabled);
const enabledDomains = domains.filter(d => d.enabled); const errors: string[] = [];
const warnings: string[] = [];
enabledDomains.forEach(domain => { enabledDomains.forEach((domain) => {
if (domain.lessonData && !domain.lessonData.objectives) { if (domain.lessonData) {
warnings.push(`${domain.name}未配置教学目标`); if (!domain.lessonData.objectives?.trim()) {
errors.push(`${domain.name}:请填写教学目标`);
}
const duration = domain.lessonData.duration;
if (duration != null && (duration < 15 || duration > 45)) {
errors.push(`${domain.name}:时长需在 15-45 分钟之间`);
}
} }
}); });
return { valid: true, errors: [], warnings }; return { valid: errors.length === 0, errors };
}; };
// //
const getSaveData = () => { const getSaveData = () => {
return domains return domains
.filter(d => d.enabled && d.lessonData) .filter(d => d.enabled && d.lessonData && d.lessonData.objectives?.trim())
.map(d => d.lessonData); .map(d => d.lessonData);
}; };

View File

@ -109,7 +109,10 @@
</a-card> </a-card>
<!-- 教学环节 --> <!-- 教学环节 -->
<a-card size="small" title="教学环节" class="section-card"> <a-card size="small" class="section-card">
<template #title>
<span>教学环节 <span class="required-mark">*</span></span>
</template>
<LessonStepsEditor <LessonStepsEditor
v-model="lessonData.steps" v-model="lessonData.steps"
:show-template="showTemplate" :show-template="showTemplate"
@ -306,6 +309,11 @@ defineExpose({
color: #666; color: #666;
} }
.required-mark {
color: #ff4d4f;
margin-left: 2px;
}
:deep(.ant-form-item) { :deep(.ant-form-item) {
margin-bottom: 12px; margin-bottom: 12px;

View File

@ -1,5 +1,6 @@
<template> <template>
<div class="course-edit-view"> <div class="course-edit-view">
<div class="sticky-header">
<a-page-header <a-page-header
:title="isEdit ? '编辑课程包' : '创建课程包'" :title="isEdit ? '编辑课程包' : '创建课程包'"
@back="() => router.back()" @back="() => router.back()"
@ -7,12 +8,23 @@
<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="isEdit" type="primary" @click="handleSaveAndSubmit" :loading="saving"> <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"
>
{{ isEdit ? '保存' : '创建' }}
</a-button>
<a-button v-else-if="isEdit" type="primary" @click="handleSaveAndSubmit" :loading="saving">
保存 保存
</a-button> </a-button>
</a-space> </a-space>
</template> </template>
</a-page-header> </a-page-header>
</div>
<a-spin :spinning="loading" tip="正在加载课程数据..."> <a-spin :spinning="loading" tip="正在加载课程数据...">
<a-card :bordered="false" style="margin-top: 16px;"> <a-card :bordered="false" style="margin-top: 16px;">
@ -93,19 +105,6 @@
@change="handleDataChange" @change="handleDataChange"
/> />
</div> </div>
<!-- 步骤导航按钮 -->
<div class="step-actions">
<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">
{{ isEdit ? '保存' : '创建' }}
</a-button>
</div>
</a-card> </a-card>
</a-spin> </a-spin>
</div> </div>
@ -252,12 +251,10 @@ const fetchCourseDetail = async () => {
}; };
// //
const onStepChange = (step: number) => { const onStepChange = async (step: number) => {
if (step > currentStep.value) { if (step > currentStep.value) {
// const ok = await validateCurrentStep();
if (!validateCurrentStep()) { if (!ok) return;
return;
}
} }
currentStep.value = step; currentStep.value = step;
}; };
@ -268,23 +265,58 @@ const prevStep = () => {
} }
}; };
const nextStep = () => { const nextStep = async () => {
if (!validateCurrentStep()) { const ok = await validateCurrentStep();
return; if (!ok) return;
}
if (currentStep.value < 6) { if (currentStep.value < 6) {
currentStep.value++; currentStep.value++;
} }
}; };
// //
const validateCurrentStep = () => { const validateAtLeastOneLesson = (): boolean => {
if (currentStep.value === 0) { const hasIntro = !!step4Ref.value?.lessonData;
const result = step1Ref.value?.validate(); const hasCollective = !!step5Ref.value?.lessonData;
const domainData = step6Ref.value?.getSaveData?.() || [];
const hasDomain = Array.isArray(domainData) && domainData.length > 0;
if (hasIntro || hasCollective || hasDomain) {
return true;
}
message.warning('请至少配置一种课程:导入课、集体课或领域课(至少完成一个领域)');
return false;
};
// 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 validateAtLeastOneLesson();
}
return true;
}
const result = await ref.validate();
if (!result?.valid) { if (!result?.valid) {
message.warning(result?.errors[0] || '请完成基本信息'); message.warning(result?.errors?.[0] || '请完成当前步骤');
return false; return false;
} }
// 5 6
if (step === 5 || step === 6) {
return validateAtLeastOneLesson();
} }
return true; return true;
}; };
@ -301,15 +333,12 @@ const handleSaveDraft = async () => {
// //
const handleSave = async (isDraft = false) => { const handleSave = async (isDraft = false) => {
console.log('🔍 handleSave 被调用isDraft =', isDraft); //
console.log('🔍 当前 courseId =', courseId.value); const ok = await validateCurrentStep();
console.log('🔍 当前 saving =', saving.value); if (!ok) return;
// //
if (saving.value) { if (saving.value) return;
console.log('⚠️ saving 标志为 true直接返回');
return;
}
saving.value = true; saving.value = true;
let savedCourseId = courseId.value; let savedCourseId = courseId.value;
@ -403,7 +432,7 @@ const handleSave = async (isDraft = false) => {
if (!isDraft) { if (!isDraft) {
console.log('🚀 准备跳转到课程列表页面...'); console.log('🚀 准备跳转到课程列表页面...');
console.log('🚀 isDraft =', isDraft.value, ', isEdit =', isEdit.value); console.log('🚀 isDraft =', isDraft, ', isEdit =', isEdit.value);
// //
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
console.log('🚀 即将执行 router.push 跳转...'); console.log('🚀 即将执行 router.push 跳转...');
@ -506,6 +535,16 @@ provide('courseId', courseId);
padding: 24px; 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 { .completion-bar {
margin-top: 16px; margin-top: 16px;
padding: 12px 16px; padding: 12px 16px;
@ -530,13 +569,4 @@ provide('courseId', courseId);
min-height: 400px; min-height: 400px;
margin-top: 24px; margin-top: 24px;
} }
.step-actions {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 12px;
justify-content: flex-end;
}
</style> </style>