feat: 排课流程增加选择套餐步骤,课程详情预约跳过套餐选择

- 学校端/教师端排课:新增第一步「选择套餐」,支持租户一对多套餐
- 从课程详情预约上课:跳过套餐与课程包选择,从选择课程类型开始
- 课程详情页传递正确的 courseId/packageId 避免预约失败

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-23 10:20:24 +08:00
parent 1b1679585d
commit dc0ce2bf78
4 changed files with 215 additions and 65 deletions

View File

@ -39,6 +39,14 @@
margin-top: 24px;
}
.collections-grid {
.collection-card {
.package-count {
color: #FF8C42;
}
}
}
.packages-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));

View File

@ -9,6 +9,7 @@
destroy-on-close
>
<a-steps :current="currentStep" size="small" class="steps-navigator">
<a-step title="选择套餐" />
<a-step title="选择课程包" />
<a-step title="选择课程类型" />
<a-step title="选择班级" />
@ -16,8 +17,38 @@
</a-steps>
<div class="step-content">
<!-- 步骤1: 选择课程包租户仅一个套餐直接展示套餐下课程包 -->
<!-- 步骤1: 选择套餐租户可拥有多个套餐一对多 -->
<div v-show="currentStep === 0" class="step-panel">
<h3>选择套餐</h3>
<a-alert
message="请先选择要排课的套餐,再选择套餐下的课程包"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-spin :spinning="loadingPackages">
<div v-if="collections.length > 0" class="packages-section">
<div class="packages-grid collections-grid">
<div
v-for="coll in collections"
:key="coll.id"
:class="['package-card collection-card', { active: formData.collectionId === coll.id }]"
@click="selectCollection(coll)"
>
<div class="package-name">{{ coll.name }}</div>
<div class="package-grade">{{ Array.isArray(coll.gradeLevels) ? coll.gradeLevels.join(', ') : (coll.gradeLevels || '-') }}</div>
<div class="package-count">{{ coll.packageCount ?? 0 }} 个课程包</div>
</div>
</div>
</div>
<div v-else-if="!loadingPackages" class="packages-section">
<a-alert message="暂无课程套餐,请联系管理员" type="info" show-icon />
</div>
</a-spin>
</div>
<!-- 步骤2: 选择课程包 -->
<div v-show="currentStep === 1" class="step-panel">
<h3>选择课程包</h3>
<a-spin :spinning="loadingPackages">
<div v-if="selectedCollection && selectedCollection.packages && selectedCollection.packages.length > 0" class="packages-section">
@ -34,11 +65,11 @@
</div>
</div>
</div>
<div v-else-if="!loadingPackages && collections.length > 0" class="packages-section">
<a-alert message="暂无课程包" type="warning" show-icon />
<div v-else-if="!loadingPackages && selectedCollection && (!selectedCollection.packages || selectedCollection.packages.length === 0)" class="packages-section">
<a-alert message="该套餐暂无课程包" type="warning" show-icon />
</div>
<div v-else-if="!loadingPackages && collections.length === 0" class="packages-section">
<a-alert message="暂无课程套餐,请联系管理员" type="info" show-icon />
<div v-else-if="!loadingPackages && !selectedCollection" class="packages-section">
<a-alert message="请先选择套餐" type="info" show-icon />
</div>
</a-spin>
@ -70,8 +101,8 @@
</div>
</div>
<!-- 步骤2: 选择课程类型 -->
<div v-show="currentStep === 1" class="step-panel">
<!-- 步骤3: 选择课程类型 -->
<div v-show="currentStep === 2" class="step-panel">
<h3>选择课程类型</h3>
<a-alert
message="请选择一个课程类型进行排课"
@ -101,8 +132,8 @@
</a-spin>
</div>
<!-- 步骤3: 选择班级并分配教师 -->
<div v-show="currentStep === 2" class="step-panel">
<!-- 步骤4: 选择班级并分配教师 -->
<div v-show="currentStep === 3" class="step-panel">
<h3>选择班级并分配教师</h3>
<a-alert
message="选择班级后,为每个班级指定授课教师"
@ -151,8 +182,8 @@
</div>
</div>
<!-- 步骤4: 设置时间 -->
<div v-show="currentStep === 3" class="step-panel">
<!-- 步骤5: 设置时间 -->
<div v-show="currentStep === 4" class="step-panel">
<h3>设置时间</h3>
<a-form layout="vertical">
<a-form-item label="排课日期" required>
@ -212,7 +243,7 @@
<template #footer>
<div class="modal-footer">
<a-button v-if="currentStep > 0" @click="prevStep">上一步</a-button>
<a-button v-if="currentStep < 3" type="primary" @click="nextStep">下一步</a-button>
<a-button v-if="currentStep < 4" type="primary" @click="nextStep">下一步</a-button>
<a-button v-else type="primary" :loading="loading" @click="handleSubmit">创建排课</a-button>
<a-button @click="handleCancel">取消</a-button>
</div>
@ -397,21 +428,14 @@ const resetForm = () => {
classTeacherMap.value = {};
};
//
//
const loadCollections = async () => {
loadingPackages.value = true;
try {
collections.value = await getCourseCollections();
if (collections.value.length > 0) {
const first = collections.value[0];
formData.collectionId = first.id as number;
const packages = await getCourseCollectionPackages(first.id);
if (first) {
(first as any).packages = packages;
}
if (!packages || packages.length === 0) {
message.warning('该套餐暂无课程包');
}
//
if (collections.value.length === 1) {
await selectCollection(collections.value[0]);
}
} catch (error) {
console.error('❌ 加载课程套餐失败:', error);
@ -421,6 +445,30 @@ const loadCollections = async () => {
}
};
//
const selectCollection = async (coll: CourseCollection) => {
formData.collectionId = coll.id as number;
formData.packageId = undefined;
formData.courseId = undefined;
scheduleRefData.value = [];
lessonTypes.value = [];
if (!coll.id) return;
loadingPackages.value = true;
try {
const packages = await getCourseCollectionPackages(coll.id);
(coll as any).packages = packages || [];
if (!packages || packages.length === 0) {
message.warning('该套餐暂无课程包');
}
} catch (error) {
console.error('加载套餐课程包失败:', error);
message.error('加载课程包失败');
} finally {
loadingPackages.value = false;
}
};
//
const loadClasses = async () => {
try {
@ -568,7 +616,13 @@ const validateStep = (): boolean => {
switch (currentStep.value) {
case 0:
if (!formData.collectionId) {
message.warning('请选择课程套餐');
message.warning('请选择套餐');
return false;
}
break;
case 1:
if (!formData.collectionId) {
message.warning('请选择套餐');
return false;
}
if (!formData.packageId) {
@ -580,13 +634,13 @@ const validateStep = (): boolean => {
return false;
}
break;
case 1:
case 2:
if (!formData.lessonType) {
message.warning('请选择课程类型');
return false;
}
break;
case 2:
case 3:
if (formData.classIds.length === 0) {
message.warning('请至少选择一个班级');
return false;
@ -600,7 +654,7 @@ const validateStep = (): boolean => {
}
}
break;
case 3:
case 4:
if (!formData.scheduledDate) {
message.warning('请选择排课日期');
return false;

View File

@ -124,6 +124,10 @@ const lessons = ref<any[]>([]);
const classes = ref<any[]>([]);
const selectedClassId = ref<number>();
/** 预约上课时使用的 courseIdCourse.id和 packageId从加载数据中解析 */
const scheduleCourseId = ref<number>();
const schedulePackageId = ref<number>();
//
const scheduleModalRef = ref<InstanceType<typeof TeacherCreateScheduleModal>>();
@ -192,6 +196,10 @@ const loadCourseData = async () => {
const res = await getTeacherSchoolCourseFullDetail(courseId.value as any);
data = res.data || res;
// 使 sourceCourseId courseId
scheduleCourseId.value = data.sourceCourseId ?? data.sourceCourse?.id;
schedulePackageId.value = data.id;
//
course.value = {
id: data.id,
@ -246,6 +254,10 @@ const loadCourseData = async () => {
} else {
// id string Long
data = await teacherApi.getTeacherCourse(courseId.value);
// data.id Course.id courseId
scheduleCourseId.value = data.courseLessons?.[0]?.courseId ?? data.id;
schedulePackageId.value = data.id;
course.value = {
...data,
courseLessons: data.courseLessons || [],
@ -383,14 +395,15 @@ const showScheduleModal = () => {
message.warning('课程数据异常,暂无课程');
return;
}
const pkgId = parseInt(courseId.value, 10);
if (isNaN(pkgId)) {
message.warning('课程 ID 无效');
const pkgId = schedulePackageId.value ?? parseInt(courseId.value, 10);
const cid = scheduleCourseId.value ?? firstLesson.courseId ?? firstLesson.id;
if (!cid) {
message.warning('课程数据异常,无法预约');
return;
}
scheduleModalRef.value?.openWithPreset({
packageId: pkgId,
courseId: firstLesson.id,
courseId: cid,
lessonType: firstLesson.lessonType || 'INTRODUCTION',
classId: selectedClassId.value,
});

View File

@ -9,6 +9,7 @@
destroy-on-close
>
<a-steps :current="currentStep" size="small" class="steps-navigator">
<a-step title="选择套餐" />
<a-step title="选择课程包" />
<a-step title="选择课程类型" />
<a-step title="选择班级" />
@ -16,8 +17,38 @@
</a-steps>
<div class="step-content">
<!-- 步骤1: 选择课程包租户仅一个套餐直接展示套餐下课程包 -->
<!-- 步骤1: 选择套餐租户可拥有多个套餐一对多 -->
<div v-show="currentStep === 0" class="step-panel">
<h3>选择套餐</h3>
<a-alert
message="请先选择要预约的套餐,再选择套餐下的课程包"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-spin :spinning="loadingPackages">
<div v-if="collections.length > 0" class="packages-section">
<div class="packages-grid collections-grid">
<div
v-for="coll in collections"
:key="coll.id"
:class="['package-card collection-card', { active: formData.collectionId === coll.id }]"
@click="selectCollection(coll)"
>
<div class="package-name">{{ coll.name }}</div>
<div class="package-grade">{{ Array.isArray(coll.gradeLevels) ? coll.gradeLevels.join(', ') : (coll.gradeLevels || '-') }}</div>
<div class="package-count">{{ coll.packageCount ?? 0 }} 个课程包</div>
</div>
</div>
</div>
<div v-else-if="!loadingPackages" class="packages-section">
<a-alert message="暂无课程套餐,请联系管理员" type="info" show-icon />
</div>
</a-spin>
</div>
<!-- 步骤2: 选择课程包 -->
<div v-show="currentStep === 1" class="step-panel">
<h3>选择课程包</h3>
<a-spin :spinning="loadingPackages">
<div v-if="selectedCollection && selectedCollection.packages && selectedCollection.packages.length > 0" class="packages-section">
@ -34,11 +65,11 @@
</div>
</div>
</div>
<div v-else-if="!loadingPackages && collections.length > 0" class="packages-section">
<a-alert message="暂无课程包" type="warning" show-icon />
<div v-else-if="!loadingPackages && selectedCollection && (!selectedCollection.packages || selectedCollection.packages.length === 0)" class="packages-section">
<a-alert message="该套餐暂无课程包" type="warning" show-icon />
</div>
<div v-else-if="!loadingPackages && collections.length === 0" class="packages-section">
<a-alert message="暂无课程套餐,请联系管理员" type="info" show-icon />
<div v-else-if="!loadingPackages && !selectedCollection" class="packages-section">
<a-alert message="请先选择套餐" type="info" show-icon />
</div>
</a-spin>
@ -70,8 +101,8 @@
</div>
</div>
<!-- 步骤2: 选择课程类型 -->
<div v-show="currentStep === 1" class="step-panel">
<!-- 步骤3: 选择课程类型 -->
<div v-show="currentStep === 2" class="step-panel">
<h3>选择课程类型</h3>
<a-alert
message="请选择一个课程类型进行排课"
@ -101,8 +132,8 @@
</a-spin>
</div>
<!-- 步骤3: 选择班级教师端单选 -->
<div v-show="currentStep === 2" class="step-panel">
<!-- 步骤4: 选择班级教师端单选 -->
<div v-show="currentStep === 3" class="step-panel">
<h3>选择班级</h3>
<a-alert
message="请选择要排课的班级"
@ -124,8 +155,8 @@
<div v-if="myClasses.length === 0" class="empty-hint">暂无可用班级</div>
</div>
<!-- 步骤4: 设置时间 -->
<div v-show="currentStep === 3" class="step-panel">
<!-- 步骤5: 设置时间 -->
<div v-show="currentStep === 4" class="step-panel">
<h3>设置时间</h3>
<a-form layout="vertical">
<a-form-item label="排课日期" required>
@ -165,7 +196,7 @@
<template #footer>
<div class="modal-footer">
<a-button v-if="currentStep > 0" @click="prevStep">上一步</a-button>
<a-button v-if="currentStep < 3" type="primary" @click="nextStep">下一步</a-button>
<a-button v-if="currentStep < 4" type="primary" @click="nextStep">下一步</a-button>
<a-button v-else type="primary" :loading="loading" @click="handleSubmit">创建排课</a-button>
<a-button @click="handleCancel">取消</a-button>
</div>
@ -206,6 +237,8 @@ const visible = ref(false);
const loading = ref(false);
const loadingLessonTypes = ref(false);
const currentStep = ref(0);
/** 从课程详情进入时,跳过套餐与课程包选择 */
const isPresetMode = ref(false);
const collections = ref<CourseCollection[]>([]);
const loadingPackages = ref(false);
@ -301,9 +334,12 @@ export interface SchedulePreset {
courseId: number;
lessonType: string;
classId?: number;
/** 可选,若已知所属套餐可传入以节省请求 */
collectionId?: number;
}
const open = () => {
isPresetMode.value = false;
visible.value = true;
currentStep.value = 0;
resetForm();
@ -311,8 +347,9 @@ const open = () => {
loadMyClasses();
};
/** 从课程中心打开,预填课程包、课程类型、班级,直接进入选择班级或设置时间 */
/** 从课程详情打开:跳过套餐与课程包,从选择课程类型开始 */
const openWithPreset = async (preset: SchedulePreset) => {
isPresetMode.value = true;
visible.value = true;
resetForm();
formData.packageId = preset.packageId;
@ -321,13 +358,17 @@ const openWithPreset = async (preset: SchedulePreset) => {
formData.classId = preset.classId;
await loadMyClasses();
await loadLessonTypes(preset.packageId);
if (preset.classId) {
currentStep.value = 3; //
} else {
currentStep.value = 2; //
try {
await loadLessonTypes(preset.packageId);
} catch {
// 使
lessonTypes.value = preset.lessonType
? [{ lessonType: preset.lessonType, count: 1 }]
: [];
}
// 2
currentStep.value = 2;
};
const resetForm = () => {
@ -342,21 +383,14 @@ const resetForm = () => {
lessonTypes.value = [];
};
//
//
const loadCollections = async () => {
loadingPackages.value = true;
try {
collections.value = await getCourseCollections();
if (collections.value.length > 0) {
const first = collections.value[0];
formData.collectionId = first.id as number;
const packages = await getCourseCollectionPackages(first.id);
if (first) {
(first as any).packages = packages;
}
if (!packages || packages.length === 0) {
message.warning('该套餐暂无课程包');
}
//
if (collections.value.length === 1) {
await selectCollection(collections.value[0]);
}
} catch (error) {
console.error('加载课程套餐失败:', error);
@ -366,6 +400,30 @@ const loadCollections = async () => {
}
};
//
const selectCollection = async (coll: CourseCollection) => {
formData.collectionId = coll.id as number;
formData.packageId = undefined;
formData.courseId = undefined;
scheduleRefData.value = [];
lessonTypes.value = [];
if (!coll.id) return;
loadingPackages.value = true;
try {
const packages = await getCourseCollectionPackages(coll.id);
(coll as any).packages = packages || [];
if (!packages || packages.length === 0) {
message.warning('该套餐暂无课程包');
}
} catch (error) {
console.error('加载套餐课程包失败:', error);
message.error('加载课程包失败');
} finally {
loadingPackages.value = false;
}
};
const loadMyClasses = async () => {
try {
myClasses.value = await getTeacherClasses();
@ -456,7 +514,13 @@ const validateStep = (): boolean => {
switch (currentStep.value) {
case 0:
if (!formData.collectionId) {
message.warning('请选择课程套餐');
message.warning('请选择套餐');
return false;
}
break;
case 1:
if (!formData.collectionId) {
message.warning('请选择套餐');
return false;
}
if (!formData.packageId) {
@ -468,19 +532,19 @@ const validateStep = (): boolean => {
return false;
}
break;
case 1:
case 2:
if (!formData.lessonType) {
message.warning('请选择课程类型');
return false;
}
break;
case 2:
case 3:
if (!formData.classId) {
message.warning('请选择班级');
return false;
}
break;
case 3:
case 4:
if (!formData.scheduledDate) {
message.warning('请选择排课日期');
return false;
@ -499,6 +563,11 @@ const nextStep = () => {
};
const prevStep = () => {
// 2/
if (isPresetMode.value && currentStep.value === 2) {
handleCancel();
return;
}
currentStep.value--;
};
@ -580,6 +649,12 @@ defineExpose({ open, openWithPreset });
.packages-section { margin-top: 24px; }
.collections-grid {
.collection-card {
.package-count { color: #722ed1; }
}
}
.packages-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));