feat(课程包): 三课表单formRules校验与UI优化

- LessonConfigPanel: 添加a-form+formRules表单校验
- 导入课/集体课/领域课: 核心资源均必填校验
- 修复教学目标/教学准备/教学延伸等重复校验提示
- 核心资源校验错误提示可见展示
- 教学环节: 环节名称改为水平布局(标签左对齐)

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-18 16:59:42 +08:00
parent 17dc815030
commit 337dcc43d8
5 changed files with 258 additions and 142 deletions

View File

@ -12,7 +12,10 @@
style="margin-bottom: 16px"
>
<template #description>
导入课用于激发幼儿兴趣引入课程主题建议时长5-15分钟重点在于吸引注意力建立学习期待
<div>导入课用于激发幼儿兴趣引入课程主题建议时长 5-15 分钟重点在于吸引注意力建立学习期待</div>
<div class="form-hints">
<strong>必填项</strong>课程名称教学目标教学准备核心资源至少 1 教学环节至少 1 含环节名称内容目标<strong>时长</strong>5-15 分钟
</div>
</template>
</a-alert>
@ -27,6 +30,7 @@
<LessonConfigPanel
v-else
ref="configPanelRef"
v-model="lessonData"
lesson-type="INTRO"
:min-duration="5"
@ -69,6 +73,7 @@ const emit = defineEmits<{
const loading = ref(false);
const lessonData = ref<LessonData | null>(null);
const configPanelRef = ref<InstanceType<typeof LessonConfigPanel> | null>(null);
//
const fetchLesson = async () => {
@ -154,35 +159,12 @@ const handleLessonChange = () => {
emit('change');
};
//
const validate = () => {
// formRules
const validate = async () => {
if (!lessonData.value) {
return { valid: true, errors: [] as string[], warnings: ['未配置导入课'] };
}
const errors: string[] = [];
if (!lessonData.value.objectives?.trim()) {
errors.push('请输入教学目标');
}
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('请至少添加一个教学环节');
} else {
steps.forEach((step, i) => {
if (!step.name?.trim()) errors.push(`${i + 1}个环节:请填写环节名称`);
if (!step.content?.trim()) errors.push(`${i + 1}个环节:请填写环节内容`);
if (!step.objective?.trim()) errors.push(`${i + 1}个环节:请填写教学目标`);
});
}
return { valid: errors.length === 0, errors };
return configPanelRef.value?.validate() ?? { valid: true, errors: [] as string[] };
};
//
@ -232,5 +214,13 @@ defineExpose({
border-top: 1px solid #f0f0f0;
text-align: right;
}
.form-hints {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(24, 144, 255, 0.3);
font-size: 12px;
color: #595959;
}
}
</style>

View File

@ -12,7 +12,10 @@
style="margin-bottom: 16px"
>
<template #description>
集体课是课程包的核心教学活动全班幼儿共同参与建议时长20-30分钟包含绘本动画教学课件电子绘本等核心资源
<div>集体课是课程包的核心教学活动全班幼儿共同参与建议时长 20-30 分钟包含绘本动画教学课件电子绘本等核心资源</div>
<div class="form-hints">
<strong>必填项</strong>课程名称教学目标教学准备核心资源至少 1 教学环节至少 1 教学延伸<strong>时长</strong>15-45 分钟
</div>
</template>
</a-alert>
@ -27,6 +30,7 @@
<LessonConfigPanel
v-else
ref="configPanelRef"
v-model="lessonData"
lesson-type="COLLECTIVE"
:min-duration="15"
@ -70,6 +74,7 @@ const emit = defineEmits<{
const loading = ref(false);
const lessonData = ref<LessonData | null>(null);
const configPanelRef = ref<InstanceType<typeof LessonConfigPanel> | null>(null);
//
const fetchLesson = async () => {
@ -155,38 +160,12 @@ const handleLessonChange = () => {
emit('change');
};
// 15-45
const validate = () => {
// formRules
const validate = async () => {
if (!lessonData.value) {
return { valid: true, errors: [] as string[], warnings: ['未配置集体课'] };
}
const errors: string[] = [];
if (!lessonData.value.objectives?.trim()) {
errors.push('请输入教学目标');
}
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 分钟之间');
}
const steps = lessonData.value.steps || [];
if (steps.length < 1) {
errors.push('请至少添加一个教学环节');
} else {
steps.forEach((step, i) => {
if (!step.name?.trim()) errors.push(`${i + 1}个环节:请填写环节名称`);
if (!step.content?.trim()) errors.push(`${i + 1}个环节:请填写环节内容`);
if (!step.objective?.trim()) errors.push(`${i + 1}个环节:请填写教学目标`);
});
}
return { valid: errors.length === 0, errors };
return configPanelRef.value?.validate() ?? { valid: true, errors: [] as string[] };
};
//
@ -236,5 +215,13 @@ defineExpose({
border-top: 1px solid #f0f0f0;
text-align: right;
}
.form-hints {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(24, 144, 255, 0.3);
font-size: 12px;
color: #595959;
}
}
</style>

View File

@ -12,7 +12,10 @@
style="margin-bottom: 16px"
>
<template #description>
五大领域课为可选配置根据课程内容选择配置相关领域课程每个领域课独立配置包含教学目标准备环节等内容
<div>五大领域课为可选配置根据课程内容选择配置相关领域课程每个领域课独立配置包含教学目标准备环节等内容</div>
<div class="form-hints">
<strong>已启用领域必填</strong>课程名称教学目标教学准备核心资源至少 1 教学环节至少 1 教学延伸<strong>时长</strong>15-45 分钟
</div>
</template>
</a-alert>
@ -46,8 +49,9 @@
</a-button>
</div>
<div v-if="domain.enabled && domain.expanded" class="domain-content">
<div v-if="domain.enabled" v-show="domain.expanded" class="domain-content">
<LessonConfigPanel
:ref="(el: any) => setConfigPanelRef(domain.type, el)"
v-model="domain.lessonData"
:lesson-type="domain.type"
:min-duration="15"
@ -112,6 +116,15 @@ const emit = defineEmits<{
}>();
const loading = ref(false);
const configPanelRefs = reactive<Record<string, InstanceType<typeof LessonConfigPanel> | null>>({});
const setConfigPanelRef = (type: string, el: any) => {
if (el) {
configPanelRefs[type] = el;
} else {
configPanelRefs[type] = null;
}
};
const domains = reactive<DomainConfig[]>([
{
@ -253,34 +266,22 @@ const handleLessonChange = () => {
emit('change');
};
// 15-45
const validate = () => {
// formRules
const validate = async () => {
const enabledDomains = domains.filter((d) => d.enabled);
const errors: string[] = [];
const allErrors: string[] = [];
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 分钟之间`);
}
const steps = domain.lessonData.steps || [];
if (steps.length < 1) {
errors.push(`${domain.name}:请至少添加一个教学环节`);
} else {
steps.forEach((step, i) => {
if (!step.name?.trim()) errors.push(`${domain.name}:第${i + 1}个环节请填写环节名称`);
if (!step.content?.trim()) errors.push(`${domain.name}:第${i + 1}个环节请填写环节内容`);
if (!step.objective?.trim()) errors.push(`${domain.name}:第${i + 1}个环节请填写教学目标`);
});
for (const domain of enabledDomains) {
const panel = configPanelRefs[domain.type];
if (panel?.validate) {
const result = await panel.validate();
if (!result.valid && result.errors?.length) {
result.errors.forEach((e) => allErrors.push(`${domain.name}${e}`));
}
}
});
}
return { valid: errors.length === 0, errors };
return { valid: allErrors.length === 0, errors: allErrors };
};
//
@ -386,5 +387,13 @@ defineExpose({
gap: 4px;
}
}
.form-hints {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(24, 144, 255, 0.3);
font-size: 12px;
color: #595959;
}
}
</style>

View File

@ -1,15 +1,23 @@
<template>
<div class="lesson-config-panel">
<a-form
ref="formRef"
:model="lessonData"
:rules="formRules"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
layout="vertical"
>
<!-- 基本信息 -->
<a-card size="small" title="基本信息" class="section-card">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="课程名称">
<a-form-item label="课程名称" name="name">
<a-input v-model:value="lessonData.name" placeholder="请输入课程名称" @change="handleChange" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="课程时长">
<a-form-item label="课程时长" name="duration" :extra="`建议 ${minDuration}-${maxDuration} 分钟`">
<a-input-number
v-model:value="lessonData.duration"
:min="minDuration"
@ -36,50 +44,61 @@
<!-- 核心资源导入课/集体课/领域课显示 -->
<a-card v-if="showResources" size="small" class="section-card">
<template #title>
<span>核心资源 <span class="required-mark">*</span></span>
<span>
核心资源 <span class="required-mark">*</span>
</span>
</template>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="绘本动画">
<FileUploader
v-model:file-path="lessonData.videoPath"
v-model:file-name="lessonData.videoName"
file-type="video"
:max-size="200"
@change="handleChange"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="教学课件">
<FileUploader
v-model:file-path="lessonData.pptPath"
v-model:file-name="lessonData.pptName"
file-type="ppt"
:max-size="100"
@change="handleChange"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="电子绘本">
<FileUploader
v-model:file-path="lessonData.pdfPath"
v-model:file-name="lessonData.pdfName"
file-type="pdf"
:max-size="100"
@change="handleChange"
/>
</a-form-item>
</a-col>
</a-row>
<div class="field-hint">至少上传一个动画/课件/电子绘本</div>
<a-form-item
name="resourceCheck"
label=" "
:colon="false"
:label-col="{ span: 0 }"
:wrapper-col="{ span: 24 }"
>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="绘本动画" :name="undefined">
<FileUploader
v-model:file-path="lessonData.videoPath"
v-model:file-name="lessonData.videoName"
file-type="video"
:max-size="200"
@change="handleChange"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="教学课件" :name="undefined">
<FileUploader
v-model:file-path="lessonData.pptPath"
v-model:file-name="lessonData.pptName"
file-type="ppt"
:max-size="100"
@change="handleChange"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="电子绘本" :name="undefined">
<FileUploader
v-model:file-path="lessonData.pdfPath"
v-model:file-name="lessonData.pdfName"
file-type="pdf"
:max-size="100"
@change="handleChange"
/>
</a-form-item>
</a-col>
</a-row>
</a-form-item>
</a-card>
<!-- 教学目标 & 教学准备 -->
<a-card size="small" class="section-card">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="教学目标" required>
<a-form-item label="教学目标" name="objectives">
<a-textarea
v-model:value="lessonData.objectives"
:rows="4"
@ -94,7 +113,7 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="教学准备" required>
<a-form-item label="教学准备" name="preparation">
<a-textarea
v-model:value="lessonData.preparation"
:rows="4"
@ -116,24 +135,31 @@
<template #title>
<span>教学环节 <span class="required-mark">*</span></span>
</template>
<div class="field-hint">至少添加一个环节每个环节需填写名称内容目标</div>
<a-form-item name="steps">
<LessonStepsEditor
v-model="lessonData.steps"
:show-template="showTemplate"
@change="handleChange"
/>
</a-form-item>
</a-card>
<!-- 教学延伸 & 教学反思仅集体课/领域课显示 -->
<a-card v-if="showExtension" size="small" class="section-card">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="教学延伸">
<a-form-item
label="教学延伸"
:name="extensionRequired ? 'extension' : undefined"
:extra="extensionRequired ? '必填(集体课要求)' : '可选'"
>
<a-textarea
v-model:value="lessonData.extension"
:rows="3"
:maxlength="1500"
show-count
placeholder="请输入教学延伸活动建议(可选)"
:placeholder="extensionRequired ? '请输入教学延伸活动建议(必填)' : '请输入教学延伸活动建议(可选)'"
@change="handleChange"
/>
</a-form-item>
@ -166,11 +192,13 @@
/>
</a-form-item>
</a-card>
</a-form>
</div>
</template>
<script setup lang="ts">
import { reactive, watch, computed } from 'vue';
import { ref, reactive, watch, computed } from 'vue';
import type { FormInstance } from 'ant-design-vue';
import FileUploader from './FileUploader.vue';
import LessonStepsEditor from './LessonStepsEditor.vue';
import type { StepData } from './LessonStepsEditor.vue';
@ -196,6 +224,8 @@ export interface LessonData {
useTemplate: boolean;
steps: StepData[];
isNew?: boolean;
/** 仅用于表单校验,不持久化 */
resourceCheck?: string;
}
interface Props {
@ -242,6 +272,7 @@ const defaultLessonData: LessonData = {
assessmentData: '',
useTemplate: false,
steps: [],
resourceCheck: '',
};
const lessonData = reactive<LessonData>({
@ -249,37 +280,120 @@ const lessonData = reactive<LessonData>({
...props.modelValue,
});
//
const extensionRequired = computed(
() => props.lessonType === 'COLLECTIVE' || props.lessonType?.startsWith('DOMAIN_')
);
const formRef = ref<FormInstance>();
// formRules lessonType
const formRules = computed(() => {
const minD = props.minDuration ?? 5;
const maxD = props.maxDuration ?? 60;
const rules: Record<string, any[]> = {
name: [{ required: true, whitespace: true, message: '请输入课程名称' }],
objectives: [{ required: true, whitespace: true, message: '请输入教学目标' }],
preparation: [{ required: true, whitespace: true, message: '请输入教学准备' }],
duration: [
{ required: true, message: '请输入课程时长' },
{
type: 'number' as const,
min: minD,
max: maxD,
message: `课程时长需在 ${minD}-${maxD} 分钟之间`,
},
],
steps: [
{
validator: (_: unknown, value: StepData[] | undefined) => {
const steps = value || [];
if (steps.length < 1) {
return Promise.reject(new Error('请至少添加一个教学环节'));
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
if (!s.name?.trim()) {
return Promise.reject(new Error(`${i + 1}个环节:请填写环节名称`));
}
if (!s.content?.trim()) {
return Promise.reject(new Error(`${i + 1}个环节:请填写环节内容`));
}
if (!s.objective?.trim()) {
return Promise.reject(new Error(`${i + 1}个环节:请填写教学目标`));
}
}
return Promise.resolve();
},
},
],
};
if (props.showResources) {
rules.resourceCheck = [
{
validator: (_: unknown, _val: string) => {
const has = lessonData.videoPath || lessonData.pptPath || lessonData.pdfPath;
if (!has) {
return Promise.reject(new Error('请至少上传一个核心资源(动画/课件/电子绘本)'));
}
return Promise.resolve();
},
},
];
}
if (extensionRequired.value) {
rules.extension = [
{ required: true, whitespace: true, message: '请输入教学延伸活动建议' },
];
}
return rules;
});
// resourceCheck
watch(
() => [lessonData.videoPath, lessonData.pptPath, lessonData.pdfPath],
() => {
lessonData.resourceCheck = lessonData.videoPath || lessonData.pptPath || lessonData.pdfPath || '';
},
{ immediate: true }
);
//
watch(
() => props.modelValue,
(newVal) => {
Object.assign(lessonData, defaultLessonData, newVal);
lessonData.resourceCheck = lessonData.videoPath || lessonData.pptPath || lessonData.pdfPath || '';
},
{ deep: true }
);
//
//
const handleChange = () => {
emit('update:modelValue', { ...lessonData });
const { resourceCheck, ...toEmit } = lessonData;
emit('update:modelValue', toEmit as LessonData);
emit('change');
};
// Step4/5/6
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 : ['请完成表单必填项'] };
}
};
//
defineExpose({
lessonData,
validate: () => {
const errors: string[] = [];
if (!lessonData.name) {
errors.push('请输入课程名称');
}
if (!lessonData.objectives) {
errors.push('请输入教学目标');
}
if (!lessonData.preparation) {
errors.push('请输入教学准备');
}
return { valid: errors.length === 0, errors };
},
formRef,
validate,
});
</script>
@ -317,6 +431,18 @@ defineExpose({
margin-left: 2px;
}
.optional-tag {
font-size: 12px;
color: #8c8c8c;
margin-left: 4px;
}
.field-hint {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 12px;
}
:deep(.ant-form-item) {
margin-bottom: 12px;

View File

@ -278,11 +278,15 @@ defineExpose({
.step-name-field {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
flex-direction: row;
align-items: center;
gap: 8px;
min-width: 0;
.field-label {
flex-shrink: 0;
margin-bottom: 0;
white-space: nowrap;
}
}