feat: 排课/预约优化与国际化

- main.ts: dayjs 时间国际化使用中文
- 排课日期禁止选择过去时间(学校端、教师端、校本课程预约)
- 移除选择课程套餐,租户仅一个套餐直接展示课程包
- 教师端预约上课增加排课计划参考表格

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-19 18:31:54 +08:00
parent 824ce7ad80
commit ed9371b21f
6 changed files with 192 additions and 111 deletions

View File

@ -2,6 +2,10 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia';
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
import 'virtual:uno.css';
import App from './App.vue';
import router from './router';

View File

@ -137,7 +137,11 @@
</a-select>
</a-form-item>
<a-form-item label="排课日期" name="scheduledDate">
<a-date-picker v-model:value="formState.scheduledDate" style="width: 100%" />
<a-date-picker
v-model:value="formState.scheduledDate"
style="width: 100%"
:disabled-date="(current) => current && current < dayjs().startOf('day')"
/>
</a-form-item>
<a-form-item label="时间段" name="scheduledTimeRange">
<a-time-range-picker

View File

@ -16,28 +16,11 @@
</a-steps>
<div class="step-content">
<!-- 步骤1: 选择课程套餐和课程包 -->
<!-- 步骤1: 选择课程租户仅一个套餐直接展示套餐下课程包 -->
<div v-show="currentStep === 0" class="step-panel">
<h3>选择课程套餐</h3>
<a-select
v-model:value="formData.collectionId"
placeholder="请选择课程套餐"
style="width: 100%"
show-search
:filter-option="filterCollection"
@change="handleCollectionChange"
>
<a-select-option v-for="collection in collections" :key="collection.id" :value="collection.id">
<div class="collection-option">
<div class="collection-name">{{ collection.name }}</div>
<div class="collection-info">{{ collection.packageCount }} 个课程包 · {{ collection.gradeLevels?.join(', ') }}</div>
</div>
</a-select-option>
</a-select>
<!-- 选择课程包 -->
<h3>选择课程包</h3>
<a-spin :spinning="loadingPackages">
<div v-if="selectedCollection && selectedCollection.packages && selectedCollection.packages.length > 0" class="packages-section">
<h4>选择课程包</h4>
<div class="packages-grid">
<div
v-for="pkg in selectedCollection.packages"
@ -51,11 +34,13 @@
</div>
</div>
</div>
<!-- 调试信息如果没有课程包显示提示 -->
<div v-else-if="selectedCollection" class="packages-section">
<h4>选择课程包</h4>
<a-alert message="该套餐暂无课程包" type="warning" show-icon />
<div v-else-if="!loadingPackages && collections.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>
</a-spin>
<!-- 排课计划参考与管理端课程包详情一致 -->
<div v-if="scheduleRefDisplay.length > 0" class="schedule-ref-card">
@ -175,6 +160,7 @@
v-model:value="formData.scheduledDate"
style="width: 100%"
placeholder="选择日期"
:disabled-date="(current) => current && current < dayjs().startOf('day')"
/>
</a-form-item>
@ -275,6 +261,7 @@ const currentStep = ref(0);
//
const collections = ref<CourseCollection[]>([]);
const loadingPackages = ref(false);
const selectedGrade = ref('');
//
@ -410,13 +397,27 @@ 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('该套餐暂无课程包');
}
}
} catch (error) {
console.error('❌ 加载课程套餐失败:', error);
message.error('加载课程套餐失败');
} finally {
loadingPackages.value = false;
}
};
@ -439,35 +440,6 @@ const loadTeachers = async () => {
}
};
// -
const handleCollectionChange = async (collectionId: number) => {
//
formData.packageId = undefined;
formData.courseId = undefined;
scheduleRefData.value = [];
if (!collectionId) return;
try {
// API: GET /v1/school/packages/{collectionId}/packages
const packages = await getCourseCollectionPackages(collectionId);
if (!packages || packages.length === 0) {
message.warning('该套餐暂无课程包');
return;
}
//
const collection = collections.value.find(c => c.id === collectionId);
if (collection) {
collection.packages = packages;
}
} catch (error) {
console.error('❌ 加载课程包失败:', error);
message.error('加载课程包失败');
}
};
//
const selectPackage = async (packageId: number) => {
formData.packageId = packageId;
@ -536,12 +508,6 @@ const isClassSelected = (classId: number): boolean => {
return formData.classIds.includes(classId);
};
//
const filterCollection = (input: string, option: any) => {
const collection = collections.value.find(c => c.id === option.value);
return collection?.name?.toLowerCase().includes(input.toLowerCase()) || false;
};
//
const filterTeacher = (input: string, option: any) => {
const teacher = teachers.value.find(t => t.id === option.value);

View File

@ -220,6 +220,7 @@
format="YYYY-MM-DD HH:mm"
placeholder="选择预约时间"
style="width: 100%"
:disabled-date="(current) => current && current < dayjs().startOf('day')"
/>
</a-form-item>
<a-form-item label="备注">
@ -235,6 +236,7 @@
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import {
ArrowLeftOutlined,
CalendarOutlined,

View File

@ -180,6 +180,7 @@
format="YYYY-MM-DD HH:mm"
placeholder="选择预约时间"
style="width: 100%"
:disabled-date="(current) => current && current < dayjs().startOf('day')"
/>
</a-form-item>
<a-form-item label="备注">

View File

@ -16,27 +16,11 @@
</a-steps>
<div class="step-content">
<!-- 步骤1: 选择课程套餐和课程包 -->
<!-- 步骤1: 选择课程租户仅一个套餐直接展示套餐下课程包 -->
<div v-show="currentStep === 0" class="step-panel">
<h3>选择课程套餐</h3>
<a-select
v-model:value="formData.collectionId"
placeholder="请选择课程套餐"
style="width: 100%"
show-search
:filter-option="filterCollection"
@change="handleCollectionChange"
>
<a-select-option v-for="collection in collections" :key="collection.id" :value="collection.id">
<div class="collection-option">
<div class="collection-name">{{ collection.name }}</div>
<div class="collection-info">{{ collection.packageCount }} 个课程包 · {{ collection.gradeLevels?.join(', ') }}</div>
</div>
</a-select-option>
</a-select>
<h3>选择课程包</h3>
<a-spin :spinning="loadingPackages">
<div v-if="selectedCollection && selectedCollection.packages && selectedCollection.packages.length > 0" class="packages-section">
<h4>选择课程包</h4>
<div class="packages-grid">
<div
v-for="pkg in selectedCollection.packages"
@ -50,9 +34,39 @@
</div>
</div>
</div>
<div v-else-if="selectedCollection" class="packages-section">
<h4>选择课程包</h4>
<a-alert message="该套餐暂无课程包" type="warning" show-icon />
<div v-else-if="!loadingPackages && collections.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>
</a-spin>
<!-- 排课计划参考与学校端一致 -->
<div v-if="scheduleRefDisplay.length > 0" class="schedule-ref-card">
<div class="ref-header">
<CalendarOutlined class="ref-icon" />
<span class="ref-title">排课计划参考</span>
</div>
<a-table
:columns="scheduleRefColumns"
:data-source="scheduleRefDisplay"
:pagination="false"
size="small"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dayOfWeek'">
{{ formatDayOfWeek(record.dayOfWeek) }}
</template>
<template v-else-if="column.key === 'lessonType'">
<a-tag v-if="record.lessonType" size="small" :style="getLessonTagStyle(record.lessonType)">
{{ getLessonTypeName(record.lessonType) }}
</a-tag>
<span v-else>-</span>
</template>
</template>
</a-table>
</div>
</div>
@ -119,6 +133,7 @@
v-model:value="formData.scheduledDate"
style="width: 100%"
placeholder="选择日期"
:disabled-date="(current) => current && current < dayjs().startOf('day')"
/>
</a-form-item>
@ -164,6 +179,7 @@ import { message } from 'ant-design-vue';
import dayjs, { Dayjs } from 'dayjs';
import {
BookOutlined,
CalendarOutlined,
TeamOutlined,
HeartOutlined,
SoundOutlined,
@ -192,9 +208,70 @@ const loadingLessonTypes = ref(false);
const currentStep = ref(0);
const collections = ref<CourseCollection[]>([]);
const loadingPackages = ref(false);
const lessonTypes = ref<LessonTypeInfo[]>([]);
const myClasses = ref<TeacherClass[]>([]);
//
const scheduleRefData = ref<any[]>([]);
//
const scheduleRefColumns = [
{ title: '时间', dataIndex: 'dayOfWeek', key: 'dayOfWeek', width: 80 },
{ title: '课程类型', dataIndex: 'lessonType', key: 'lessonType', width: 100 },
{ title: '课程名称', dataIndex: 'lessonName', key: 'lessonName' },
{ title: '区域活动', dataIndex: 'activity', key: 'activity' },
{ title: '备注', dataIndex: 'note', key: 'note' },
];
const weekDayNames: Record<number, string> = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
0: '周日',
};
const formatDayOfWeek = (val: number | string | undefined): string => {
if (val === undefined || val === null) return '-';
if (typeof val === 'string') {
if (/^[一二三四五六日]/.test(val) || val.startsWith('周')) return val;
const n = parseInt(val, 10);
if (!isNaN(n)) return weekDayNames[n] ?? weekDayNames[n as 0] ?? '-';
}
if (typeof val === 'number') return weekDayNames[val as 0] ?? '-';
return '-';
};
const normalizeScheduleRefData = (raw: any[]): any[] => {
if (!Array.isArray(raw) || raw.length === 0) return [];
const first = raw[0];
const isWeeklyFormat = 'dayOfWeek' in first || 'lessonName' in first || 'activity' in first;
const isLessonMetaFormat = 'title' in first && ('suggestedOrder' in first || 'description' in first);
if (isWeeklyFormat && !isLessonMetaFormat) {
return raw.map((r, i) => ({
key: r.key ?? `row_${i}`,
dayOfWeek: r.dayOfWeek,
lessonType: r.lessonType,
lessonName: r.lessonName ?? r.title ?? '-',
activity: r.activity ?? r.description ?? '-',
note: r.note ?? r.tips ?? r.frequency ?? '-',
}));
}
return raw.map((r, i) => ({
key: r.key ?? `row_${i}`,
dayOfWeek: r.dayOfWeek ?? '-',
lessonType: r.lessonType,
lessonName: r.title ?? r.lessonName ?? '-',
activity: r.description ?? r.activity ?? (Array.isArray(r.keyPoints) ? r.keyPoints.join('') : '-'),
note: r.tips ?? r.frequency ?? r.note ?? '-',
}));
};
const scheduleRefDisplay = computed(() => normalizeScheduleRefData(scheduleRefData.value));
interface FormData {
collectionId?: number;
packageId?: number;
@ -261,15 +338,31 @@ const resetForm = () => {
formData.classId = undefined;
formData.scheduledDate = undefined;
formData.scheduledTimeRange = undefined;
scheduleRefData.value = [];
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('该套餐暂无课程包');
}
}
} catch (error) {
console.error('加载课程套餐失败:', error);
message.error('加载课程套餐失败');
} finally {
loadingPackages.value = false;
}
};
@ -281,29 +374,6 @@ const loadMyClasses = async () => {
}
};
const handleCollectionChange = async (collectionId: number) => {
formData.packageId = undefined;
formData.courseId = undefined;
formData.lessonType = undefined;
lessonTypes.value = [];
if (!collectionId) return;
try {
const packages = await getCourseCollectionPackages(collectionId);
const collection = collections.value.find(c => c.id === collectionId);
if (collection) {
collection.packages = packages as CoursePackageItem[];
}
if (!packages || packages.length === 0) {
message.warning('该套餐暂无课程包');
}
} catch (error) {
console.error('加载课程包失败:', error);
message.error('加载课程包失败');
}
};
const selectPackage = async (packageId: number) => {
formData.packageId = packageId;
@ -312,8 +382,24 @@ const selectPackage = async (packageId: number) => {
const pkg = collection.packages.find((p: any) => p.id === packageId);
if (pkg?.courses && pkg.courses.length > 0) {
formData.courseId = pkg.courses[0].id;
// scheduleRefData
const courseWithRef = pkg.courses.find((c: any) => c.scheduleRefData);
const rawRef = courseWithRef?.scheduleRefData ?? (pkg as any).scheduleRefData;
if (rawRef) {
try {
const parsed = typeof rawRef === 'string' ? JSON.parse(rawRef) : rawRef;
scheduleRefData.value = Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error('解析排课计划参考失败:', e);
scheduleRefData.value = [];
}
} else {
scheduleRefData.value = [];
}
} else {
formData.courseId = undefined;
scheduleRefData.value = [];
}
}
@ -335,11 +421,6 @@ const selectLessonType = (lessonType: string) => {
formData.lessonType = lessonType;
};
const filterCollection = (input: string, option: any) => {
const collection = collections.value.find(c => c.id === option.value);
return collection?.name?.toLowerCase().includes(input.toLowerCase()) || false;
};
const lessonTypeIconMap: Record<string, any> = {
INTRODUCTION: BookOutlined,
INTRO: BookOutlined,
@ -524,6 +605,29 @@ defineExpose({ open, openWithPreset });
.package-count { font-size: 11px; color: #722ed1; }
}
.schedule-ref-card {
margin-top: 24px;
padding: 16px;
background: #f9f0ff;
border-radius: 8px;
.ref-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.ref-icon {
color: #722ed1;
}
.ref-title {
font-weight: 600;
color: #2D3436;
}
}
}
.lesson-type-empty {
padding: 40px;
text-align: center;