feat(课程包): 表单校验增强与交互优化
- 顶部操作栏吸顶,下一步/保存草稿合并到顶部 - 导入课、集体课、领域课至少配置一种的校验 - 领域课 getSaveData 仅返回已填写教学目标的领域 - 教学环节添加必填标识(*) Made-with: Cursor
This commit is contained in:
parent
694fda88c0
commit
a72984c860
@ -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(() => {
|
||||
|
||||
@ -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: [] };
|
||||
};
|
||||
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
// 获取保存数据
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="course-edit-view">
|
||||
<div class="sticky-header">
|
||||
<a-page-header
|
||||
:title="isEdit ? '编辑课程包' : '创建课程包'"
|
||||
@back="() => router.back()"
|
||||
@ -7,12 +8,23 @@
|
||||
<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 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();
|
||||
// 校验:导入课、集体课、领域课至少配置一种
|
||||
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] || '请完成基本信息');
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user