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>
<div class="step1-basic-info">
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="课程包名称" required>
<a-form-item label="课程包名称" name="name" required>
<a-input
v-model:value="formData.name"
placeholder="请输入课程包名称"
@ -15,7 +17,7 @@
/>
</a-form-item>
<a-form-item label="关联主题" required>
<a-form-item label="关联主题" name="themeId" required>
<a-select
v-model:value="formData.themeId"
placeholder="请选择主题"
@ -29,7 +31,7 @@
</a-select>
</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 value="小班">小班</a-checkbox>
<a-checkbox value="中班">中班</a-checkbox>
@ -37,15 +39,16 @@
</a-checkbox-group>
</a-form-item>
<a-form-item label="关联绘本">
<a-form-item label="关联绘本" name="pictureBookName">
<a-input
v-model:value="formData.pictureBookName"
placeholder="请输入关联绘本名称(可选)"
:maxlength="100"
@change="handleChange"
/>
</a-form-item>
<a-form-item label="核心内容" required>
<a-form-item label="核心内容" name="coreContent" required>
<a-textarea
v-model:value="formData.coreContent"
:rows="3"
@ -56,7 +59,7 @@
/>
</a-form-item>
<a-form-item label="课程时长">
<a-form-item label="课程时长" name="duration">
<a-input-number
v-model:value="formData.duration"
:min="5"
@ -67,7 +70,7 @@
<span class="duration-unit">分钟</span>
</a-form-item>
<a-form-item label="核心发展目标">
<a-form-item label="核心发展目标" name="domainTags">
<a-select
v-model:value="formData.domainTags"
mode="multiple"
@ -148,10 +151,43 @@ const emit = defineEmits<{
(e: 'change'): void;
}>();
const formRef = ref();
const themesLoading = ref(false);
const themes = ref<Theme[]>([]);
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>({
name: '',
themeId: undefined,
@ -252,21 +288,17 @@ const handleChange = () => {
};
//
const validate = () => {
const errors: string[] = [];
if (!formData.name) {
errors.push('请输入课程包名称');
const validate = async () => {
try {
await formRef.value?.validate();
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(() => {

View File

@ -198,9 +198,18 @@ const handleChange = () => {
emit('change');
};
//
//
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: [] };
};

View File

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

View File

@ -253,25 +253,30 @@ const handleLessonChange = () => {
emit('change');
};
//
// 15-45
const validate = () => {
//
const enabledDomains = domains.filter(d => d.enabled);
const warnings: string[] = [];
const enabledDomains = domains.filter((d) => d.enabled);
const errors: string[] = [];
enabledDomains.forEach(domain => {
if (domain.lessonData && !domain.lessonData.objectives) {
warnings.push(`${domain.name}未配置教学目标`);
enabledDomains.forEach((domain) => {
if (domain.lessonData) {
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 = () => {
return domains
.filter(d => d.enabled && d.lessonData)
.filter(d => d.enabled && d.lessonData && d.lessonData.objectives?.trim())
.map(d => d.lessonData);
};

View File

@ -109,7 +109,10 @@
</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
v-model="lessonData.steps"
:show-template="showTemplate"
@ -306,6 +309,11 @@ defineExpose({
color: #666;
}
.required-mark {
color: #ff4d4f;
margin-left: 2px;
}
:deep(.ant-form-item) {
margin-bottom: 12px;

View File

@ -1,18 +1,30 @@
<template>
<div class="course-edit-view">
<a-page-header
:title="isEdit ? '编辑课程包' : '创建课程包'"
@back="() => router.back()"
>
<template #extra>
<a-space>
<a-button @click="handleSaveDraft" :loading="saving">保存草稿</a-button>
<a-button v-if="isEdit" type="primary" @click="handleSaveAndSubmit" :loading="saving">
保存
</a-button>
</a-space>
</template>
</a-page-header>
<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"
>
{{ 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;">
@ -93,19 +105,6 @@
@change="handleDataChange"
/>
</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-spin>
</div>
@ -252,12 +251,10 @@ const fetchCourseDetail = async () => {
};
//
const onStepChange = (step: number) => {
const onStepChange = async (step: number) => {
if (step > currentStep.value) {
//
if (!validateCurrentStep()) {
return;
}
const ok = await validateCurrentStep();
if (!ok) return;
}
currentStep.value = step;
};
@ -268,23 +265,58 @@ const prevStep = () => {
}
};
const nextStep = () => {
if (!validateCurrentStep()) {
return;
}
const nextStep = async () => {
const ok = await validateCurrentStep();
if (!ok) return;
if (currentStep.value < 6) {
currentStep.value++;
}
};
//
const validateCurrentStep = () => {
if (currentStep.value === 0) {
const result = step1Ref.value?.validate();
if (!result?.valid) {
message.warning(result?.errors[0] || '请完成基本信息');
return false;
//
const validateAtLeastOneLesson = (): 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;
}
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) {
message.warning(result?.errors?.[0] || '请完成当前步骤');
return false;
}
// 5 6
if (step === 5 || step === 6) {
return validateAtLeastOneLesson();
}
return true;
};
@ -301,15 +333,12 @@ const handleSaveDraft = async () => {
//
const handleSave = async (isDraft = false) => {
console.log('🔍 handleSave 被调用isDraft =', isDraft);
console.log('🔍 当前 courseId =', courseId.value);
console.log('🔍 当前 saving =', saving.value);
//
const ok = await validateCurrentStep();
if (!ok) return;
//
if (saving.value) {
console.log('⚠️ saving 标志为 true直接返回');
return;
}
if (saving.value) return;
saving.value = true;
let savedCourseId = courseId.value;
@ -403,7 +432,7 @@ const handleSave = async (isDraft = false) => {
if (!isDraft) {
console.log('🚀 准备跳转到课程列表页面...');
console.log('🚀 isDraft =', isDraft.value, ', isEdit =', isEdit.value);
console.log('🚀 isDraft =', isDraft, ', isEdit =', isEdit.value);
//
await new Promise(resolve => setTimeout(resolve, 500));
console.log('🚀 即将执行 router.push 跳转...');
@ -506,6 +535,16 @@ provide('courseId', courseId);
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;
@ -530,13 +569,4 @@ provide('courseId', courseId);
min-height: 400px;
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>