Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
4d97e1cbf9
@ -272,6 +272,11 @@ export function cancelLesson(id: number | string): Promise<any> {
|
|||||||
return http.post(`/v1/teacher/lessons/${id}/cancel`) as any;
|
return http.post(`/v1/teacher/lessons/${id}/cancel`) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 退出上课:删除未完成的授课记录(非已完成) */
|
||||||
|
export function abandonLesson(id: number | string): Promise<any> {
|
||||||
|
return http.post(`/v1/teacher/lessons/${id}/abandon`, {}) as any;
|
||||||
|
}
|
||||||
|
|
||||||
// 保存学生评价记录(lessonId 使用 string 避免 Long 精度丢失)
|
// 保存学生评价记录(lessonId 使用 string 避免 Long 精度丢失)
|
||||||
export function saveStudentRecord(
|
export function saveStudentRecord(
|
||||||
lessonId: number | string,
|
lessonId: number | string,
|
||||||
|
|||||||
@ -74,8 +74,10 @@
|
|||||||
<a-select
|
<a-select
|
||||||
v-model:value="formData.domainTags"
|
v-model:value="formData.domainTags"
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
|
show-search
|
||||||
placeholder="请选择核心发展目标(可多选)"
|
placeholder="请选择核心发展目标(可多选)"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
|
:filter-option="filterDomainTagOption"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
>
|
>
|
||||||
<a-select-opt-group label="健康">
|
<a-select-opt-group label="健康">
|
||||||
@ -156,6 +158,38 @@ const themesLoading = ref(false);
|
|||||||
const themes = ref<Theme[]>([]);
|
const themes = ref<Theme[]>([]);
|
||||||
const coverImages = ref<any[]>([]);
|
const coverImages = ref<any[]>([]);
|
||||||
|
|
||||||
|
/** 核心发展目标:叶子项 + 父级领域名,用于搜索时同时匹配子项与分组 */
|
||||||
|
const DOMAIN_TAG_OPTIONS: { group: string; value: string; label: string }[] = [
|
||||||
|
{ group: '健康', value: 'health_motor', label: '身体动作发展' },
|
||||||
|
{ group: '健康', value: 'health_hygiene', label: '生活习惯与能力' },
|
||||||
|
{ group: '语言', value: 'lang_listen', label: '倾听与表达' },
|
||||||
|
{ group: '语言', value: 'lang_read', label: '早期阅读' },
|
||||||
|
{ group: '社会', value: 'social_interact', label: '人际交往' },
|
||||||
|
{ group: '社会', value: 'social_adapt', label: '社会适应' },
|
||||||
|
{ group: '科学', value: 'science_explore', label: '科学探究' },
|
||||||
|
{ group: '科学', value: 'math_cog', label: '数学认知' },
|
||||||
|
{ group: '艺术', value: 'art_music', label: '音乐表现' },
|
||||||
|
{ group: '艺术', value: 'art_create', label: '美术创作' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterDomainTagOption = (input: string, option: any) => {
|
||||||
|
if (!input?.trim()) return true;
|
||||||
|
const q = input.trim();
|
||||||
|
const key = option?.value ?? option?.key;
|
||||||
|
const row = DOMAIN_TAG_OPTIONS.find((o) => o.value === key);
|
||||||
|
if (row) {
|
||||||
|
return row.label.includes(q) || row.group.includes(q);
|
||||||
|
}
|
||||||
|
const label =
|
||||||
|
typeof option?.label === 'string'
|
||||||
|
? option.label
|
||||||
|
: option?.children?.[0]?.children ?? option?.children;
|
||||||
|
if (typeof label === 'string') {
|
||||||
|
return label.includes(q);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: '请输入课程包名称' },
|
{ required: true, message: '请输入课程包名称' },
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
<div class="step2-course-intro">
|
<div class="step2-course-intro">
|
||||||
<div class="intro-header">
|
<div class="intro-header">
|
||||||
<span class="title">课程介绍</span>
|
<span class="title">课程介绍</span>
|
||||||
<a-tag color="blue">已填写 {{ filledCount }} / 8</a-tag>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-tabs v-model:activeKey="activeTab" type="card">
|
<a-tabs v-model:activeKey="activeTab" type="card">
|
||||||
@ -176,20 +175,6 @@ watch(
|
|||||||
// 计算当前tab索引
|
// 计算当前tab索引
|
||||||
const currentTabIndex = computed(() => tabKeys.indexOf(activeTab.value));
|
const currentTabIndex = computed(() => tabKeys.indexOf(activeTab.value));
|
||||||
|
|
||||||
// 计算已填写的数量
|
|
||||||
const filledCount = computed(() => {
|
|
||||||
let count = 0;
|
|
||||||
if (formData.introSummary) count++;
|
|
||||||
if (formData.introHighlights) count++;
|
|
||||||
if (formData.introGoals) count++;
|
|
||||||
if (formData.introSchedule) count++;
|
|
||||||
if (formData.introKeyPoints) count++;
|
|
||||||
if (formData.introMethods) count++;
|
|
||||||
if (formData.introEvaluation) count++;
|
|
||||||
if (formData.introNotes) count++;
|
|
||||||
return count;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 上一个tab
|
// 上一个tab
|
||||||
const prevTab = () => {
|
const prevTab = () => {
|
||||||
const index = currentTabIndex.value;
|
const index = currentTabIndex.value;
|
||||||
@ -221,7 +206,6 @@ const validate = () => {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
validate,
|
validate,
|
||||||
formData,
|
formData,
|
||||||
filledCount,
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -120,15 +120,6 @@ const imagePreviewLoading = ref(false);
|
|||||||
const imagePreviewLoaded = ref(false);
|
const imagePreviewLoaded = ref(false);
|
||||||
const imagePreviewError = ref(false);
|
const imagePreviewError = ref(false);
|
||||||
|
|
||||||
// 监听 previewUrl 变化,重置加载状态
|
|
||||||
watch(() => previewUrl.value, (newUrl) => {
|
|
||||||
if (newUrl && props.fileType === 'image') {
|
|
||||||
imagePreviewLoading.value = true;
|
|
||||||
imagePreviewLoaded.value = false;
|
|
||||||
imagePreviewError.value = false;
|
|
||||||
}
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
const buttonText = computed(() => {
|
const buttonText = computed(() => {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
video: '上传视频',
|
video: '上传视频',
|
||||||
@ -181,6 +172,19 @@ const previewUrl = computed(() => {
|
|||||||
return `/uploads/${props.filePath}`;
|
return `/uploads/${props.filePath}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 须在 previewUrl 定义之后:watch 回调会读 previewUrl,避免 TDZ(Cannot access before initialization)
|
||||||
|
watch(
|
||||||
|
() => previewUrl.value,
|
||||||
|
(newUrl) => {
|
||||||
|
if (newUrl && props.fileType === 'image') {
|
||||||
|
imagePreviewLoading.value = true;
|
||||||
|
imagePreviewLoaded.value = false;
|
||||||
|
imagePreviewError.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
// 检查是否为 PDF 文件(用于 ppt 类型也支持 PDF 预览)
|
// 检查是否为 PDF 文件(用于 ppt 类型也支持 PDF 预览)
|
||||||
const isPdfFile = computed(() => {
|
const isPdfFile = computed(() => {
|
||||||
if (props.fileType === 'ppt' && props.filePath) {
|
if (props.fileType === 'ppt' && props.filePath) {
|
||||||
|
|||||||
@ -25,7 +25,9 @@
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
/>
|
/>
|
||||||
|
<a-form-item-rest>
|
||||||
<span class="duration-unit">分钟</span>
|
<span class="duration-unit">分钟</span>
|
||||||
|
</a-form-item-rest>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
@ -137,11 +139,13 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="field-hint">至少添加一个环节,每个环节需填写名称、内容、目标</div>
|
<div class="field-hint">至少添加一个环节,每个环节需填写名称、内容、目标</div>
|
||||||
<a-form-item name="steps">
|
<a-form-item name="steps">
|
||||||
|
<a-form-item-rest>
|
||||||
<LessonStepsEditor
|
<LessonStepsEditor
|
||||||
v-model="lessonData.steps"
|
v-model="lessonData.steps"
|
||||||
:show-template="showTemplate"
|
:show-template="showTemplate"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
/>
|
/>
|
||||||
|
</a-form-item-rest>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
|
|||||||
@ -18,8 +18,11 @@
|
|||||||
</a-page-header>
|
</a-page-header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-spin :spinning="loading" tip="正在加载课程数据...">
|
<!-- 编辑态:数据未就绪时单独展示加载,避免 a-spin 包裹整表在 spinning 切换时与大量子组件补丁竞态导致 __vnode 报错 -->
|
||||||
<a-card :bordered="false" style="margin-top: 16px;">
|
<div v-if="!pageContentReady" class="course-edit-page-loading">
|
||||||
|
<a-spin size="large" tip="正在加载课程数据..." />
|
||||||
|
</div>
|
||||||
|
<a-card v-else :bordered="false" style="margin-top: 16px;">
|
||||||
<!-- 步骤导航 -->
|
<!-- 步骤导航 -->
|
||||||
<a-steps :current="currentStep" size="small" @change="onStepChange">
|
<a-steps :current="currentStep" size="small" @change="onStepChange">
|
||||||
<a-step title="基本信息" />
|
<a-step title="基本信息" />
|
||||||
@ -62,12 +65,11 @@
|
|||||||
@change="handleDataChange" />
|
@change="handleDataChange" />
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-spin>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, provide } from 'vue';
|
import { ref, reactive, computed, onMounted, provide, nextTick } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import Step1BasicInfo from '@/components/course-edit/Step1BasicInfo.vue';
|
import Step1BasicInfo from '@/components/course-edit/Step1BasicInfo.vue';
|
||||||
@ -94,7 +96,8 @@ const route = useRoute();
|
|||||||
const isEdit = computed(() => !!route.params.id);
|
const isEdit = computed(() => !!route.params.id);
|
||||||
const courseId = computed(() => route.params.id as string | undefined);
|
const courseId = computed(() => route.params.id as string | undefined);
|
||||||
|
|
||||||
const loading = ref(false);
|
/** 编辑页:详情拉取完成后再挂载步骤表单,避免与 Spin 嵌套更新冲突 */
|
||||||
|
const pageContentReady = ref(!isEdit.value);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const currentStep = ref(0);
|
const currentStep = ref(0);
|
||||||
|
|
||||||
@ -137,7 +140,7 @@ const formData = reactive({
|
|||||||
const fetchCourseDetail = async () => {
|
const fetchCourseDetail = async () => {
|
||||||
if (!isEdit.value) return;
|
if (!isEdit.value) return;
|
||||||
|
|
||||||
loading.value = true;
|
pageContentReady.value = false;
|
||||||
try {
|
try {
|
||||||
const res = await getCourse(courseId.value) as any;
|
const res = await getCourse(courseId.value) as any;
|
||||||
const course = res.data || res;
|
const course = res.data || res;
|
||||||
@ -180,10 +183,12 @@ const fetchCourseDetail = async () => {
|
|||||||
|
|
||||||
// 环创建设
|
// 环创建设
|
||||||
formData.environmentConstruction = course.environmentConstruction || '';
|
formData.environmentConstruction = course.environmentConstruction || '';
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
pageContentReady.value = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取课程详情失败');
|
message.error('获取课程详情失败');
|
||||||
} finally {
|
pageContentReady.value = true;
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -486,4 +491,12 @@ provide('courseId', courseId);
|
|||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.course-edit-page-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 420px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -71,8 +71,10 @@
|
|||||||
<a-select
|
<a-select
|
||||||
v-model:value="formData.domainTags"
|
v-model:value="formData.domainTags"
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
|
show-search
|
||||||
placeholder="请选择核心发展目标(可多选)"
|
placeholder="请选择核心发展目标(可多选)"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
|
:filter-option="filterDomainTagOption"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
>
|
>
|
||||||
<a-select-opt-group label="健康">
|
<a-select-opt-group label="健康">
|
||||||
@ -152,6 +154,37 @@ const themesLoading = ref(false);
|
|||||||
const themes = ref<Theme[]>([]);
|
const themes = ref<Theme[]>([]);
|
||||||
const coverImages = ref<any[]>([]);
|
const coverImages = ref<any[]>([]);
|
||||||
|
|
||||||
|
const DOMAIN_TAG_OPTIONS: { group: string; value: string; label: string }[] = [
|
||||||
|
{ group: '健康', value: 'health_motor', label: '身体动作发展' },
|
||||||
|
{ group: '健康', value: 'health_hygiene', label: '生活习惯与能力' },
|
||||||
|
{ group: '语言', value: 'lang_listen', label: '倾听与表达' },
|
||||||
|
{ group: '语言', value: 'lang_read', label: '早期阅读' },
|
||||||
|
{ group: '社会', value: 'social_interact', label: '人际交往' },
|
||||||
|
{ group: '社会', value: 'social_adapt', label: '社会适应' },
|
||||||
|
{ group: '科学', value: 'science_explore', label: '科学探究' },
|
||||||
|
{ group: '科学', value: 'math_cog', label: '数学认知' },
|
||||||
|
{ group: '艺术', value: 'art_music', label: '音乐表现' },
|
||||||
|
{ group: '艺术', value: 'art_create', label: '美术创作' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterDomainTagOption = (input: string, option: any) => {
|
||||||
|
if (!input?.trim()) return true;
|
||||||
|
const q = input.trim();
|
||||||
|
const key = option?.value ?? option?.key;
|
||||||
|
const row = DOMAIN_TAG_OPTIONS.find((o) => o.value === key);
|
||||||
|
if (row) {
|
||||||
|
return row.label.includes(q) || row.group.includes(q);
|
||||||
|
}
|
||||||
|
const label =
|
||||||
|
typeof option?.label === 'string'
|
||||||
|
? option.label
|
||||||
|
: option?.children?.[0]?.children ?? option?.children;
|
||||||
|
if (typeof label === 'string') {
|
||||||
|
return label.includes(q);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const formData = reactive<BasicInfoData>({
|
const formData = reactive<BasicInfoData>({
|
||||||
name: '',
|
name: '',
|
||||||
themeId: undefined,
|
themeId: undefined,
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
<div class="step2-course-intro">
|
<div class="step2-course-intro">
|
||||||
<div class="intro-header">
|
<div class="intro-header">
|
||||||
<span class="title">课程介绍</span>
|
<span class="title">课程介绍</span>
|
||||||
<a-tag color="blue">已填写 {{ filledCount }} / 8</a-tag>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-tabs v-model:activeKey="activeTab" type="card">
|
<a-tabs v-model:activeKey="activeTab" type="card">
|
||||||
@ -176,20 +175,6 @@ watch(
|
|||||||
// 计算当前tab索引
|
// 计算当前tab索引
|
||||||
const currentTabIndex = computed(() => tabKeys.indexOf(activeTab.value));
|
const currentTabIndex = computed(() => tabKeys.indexOf(activeTab.value));
|
||||||
|
|
||||||
// 计算已填写的数量
|
|
||||||
const filledCount = computed(() => {
|
|
||||||
let count = 0;
|
|
||||||
if (formData.introSummary) count++;
|
|
||||||
if (formData.introHighlights) count++;
|
|
||||||
if (formData.introGoals) count++;
|
|
||||||
if (formData.introSchedule) count++;
|
|
||||||
if (formData.introKeyPoints) count++;
|
|
||||||
if (formData.introMethods) count++;
|
|
||||||
if (formData.introEvaluation) count++;
|
|
||||||
if (formData.introNotes) count++;
|
|
||||||
return count;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 上一个tab
|
// 上一个tab
|
||||||
const prevTab = () => {
|
const prevTab = () => {
|
||||||
const index = currentTabIndex.value;
|
const index = currentTabIndex.value;
|
||||||
@ -221,7 +206,6 @@ const validate = () => {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
validate,
|
validate,
|
||||||
formData,
|
formData,
|
||||||
filledCount,
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -705,6 +705,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
@ -714,13 +715,16 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title-icon {
|
.title-icon {
|
||||||
width: 56px;
|
width: 48px;
|
||||||
height: 56px;
|
height: 48px;
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.1) 100%);
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.1) 100%);
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-icon :deep(svg) {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@ -1328,6 +1332,7 @@ onMounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-stats {
|
.header-stats {
|
||||||
|
|||||||
@ -242,7 +242,8 @@
|
|||||||
|
|
||||||
<!-- 学生表格 -->
|
<!-- 学生表格 -->
|
||||||
<a-table :columns="studentTableColumns" :data-source="studentTableData" :loading="studentsLoading"
|
<a-table :columns="studentTableColumns" :data-source="studentTableData" :loading="studentsLoading"
|
||||||
:pagination="studentPagination" :row-selection="studentRowSelection" row-key="id" size="small"
|
:pagination="studentPagination" :row-selection="studentRowSelection"
|
||||||
|
:row-key="(r: Student) => studentIdNum(r)" size="small"
|
||||||
@change="handleStudentTableChange" style="margin-top: 16px;">
|
@change="handleStudentTableChange" style="margin-top: 16px;">
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.dataIndex === 'gender'">
|
<template v-if="column.dataIndex === 'gender'">
|
||||||
@ -255,11 +256,17 @@
|
|||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
<!-- 选择关系并确认 -->
|
<!-- 选择关系并确认 -->
|
||||||
<div class="select-footer" v-if="selectedStudent">
|
<div class="select-footer" v-if="selectedStudentIds.length > 0">
|
||||||
<div class="selected-info">
|
<div class="selected-info">
|
||||||
<span>已选择:</span>
|
<span class="selected-count">已选择 {{ selectedStudentIds.length }} 人:</span>
|
||||||
<a-tag color="orange">{{ selectedStudent.name }}</a-tag>
|
<a-space wrap class="selected-tags">
|
||||||
<span class="selected-class">{{ selectedStudent.className }}</span>
|
<a-tag v-for="sid in selectedStudentIds" :key="sid" color="orange">
|
||||||
|
{{ selectedStudentMeta[sid]?.name ?? sid }}
|
||||||
|
<span v-if="selectedStudentMeta[sid]?.className" class="selected-class">
|
||||||
|
{{ selectedStudentMeta[sid]?.className }}
|
||||||
|
</span>
|
||||||
|
</a-tag>
|
||||||
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
<div class="select-actions">
|
<div class="select-actions">
|
||||||
<a-select v-model:value="addChildForm.relationship" style="width: 100px; margin-right: 12px;">
|
<a-select v-model:value="addChildForm.relationship" style="width: 100px; margin-right: 12px;">
|
||||||
@ -302,7 +309,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, watch, onMounted } from 'vue';
|
||||||
import {
|
import {
|
||||||
IdcardOutlined,
|
IdcardOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@ -378,7 +385,6 @@ const addChildLoading = ref(false);
|
|||||||
const studentsLoading = ref(false);
|
const studentsLoading = ref(false);
|
||||||
|
|
||||||
const addChildForm = reactive({
|
const addChildForm = reactive({
|
||||||
studentId: undefined as number | undefined,
|
|
||||||
relationship: 'FATHER',
|
relationship: 'FATHER',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -386,7 +392,10 @@ const addChildForm = reactive({
|
|||||||
const selectStudentModalVisible = ref(false);
|
const selectStudentModalVisible = ref(false);
|
||||||
const studentSearchKeyword = ref('');
|
const studentSearchKeyword = ref('');
|
||||||
const studentClassFilter = ref<number | undefined>();
|
const studentClassFilter = ref<number | undefined>();
|
||||||
const selectedStudent = ref<Student | null>(null);
|
/** 跨页保留:已勾选的学生 id */
|
||||||
|
const selectedStudentIds = ref<number[]>([]);
|
||||||
|
/** 用于底部展示姓名、班级(在勾选时写入) */
|
||||||
|
const selectedStudentMeta = ref<Record<number, { name: string; className?: string | null }>>({});
|
||||||
const studentTableData = ref<Student[]>([]);
|
const studentTableData = ref<Student[]>([]);
|
||||||
const studentPagination = reactive({
|
const studentPagination = reactive({
|
||||||
current: 1,
|
current: 1,
|
||||||
@ -613,24 +622,53 @@ const studentTableColumns = [
|
|||||||
{ title: '班级', dataIndex: 'className', key: 'className', width: 120 },
|
{ title: '班级', dataIndex: 'className', key: 'className', width: 120 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const studentRowSelection = computed(() => ({
|
const studentIdNum = (s: Student) => Number(s.id);
|
||||||
type: 'radio' as 'radio',
|
|
||||||
selectedRowKeys: selectedStudent.value ? [selectedStudent.value.id] : [],
|
/** 合并当前页勾选与其它页已选,实现表格多选跨页保留(id 统一为 number,避免与 rowKey 类型不一致导致无法回显) */
|
||||||
onChange: (selectedRowKeys: (string | number)[]) => {
|
const mergeStudentTableSelection = (keysFromThisPage: (string | number)[]) => {
|
||||||
if (selectedRowKeys.length > 0) {
|
const currentPageIds = studentTableData.value.map(studentIdNum);
|
||||||
const student = studentTableData.value.find(s => s.id === selectedRowKeys[0]);
|
const keysNum = keysFromThisPage.map(Number);
|
||||||
selectedStudent.value = student || null;
|
const fromOtherPages = selectedStudentIds.value.filter((id) => !currentPageIds.includes(id));
|
||||||
} else {
|
selectedStudentIds.value = [...new Set([...fromOtherPages, ...keysNum])];
|
||||||
selectedStudent.value = null;
|
studentTableData.value.forEach((s) => {
|
||||||
|
const sid = studentIdNum(s);
|
||||||
|
if (keysNum.includes(sid)) {
|
||||||
|
selectedStudentMeta.value[sid] = { name: s.name, className: s.className };
|
||||||
|
} else if (currentPageIds.includes(sid)) {
|
||||||
|
delete selectedStudentMeta.value[sid];
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
syncStudentTableRowSelectionKeys();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 当前页应勾选的 keys,须与表格 row-key 字段类型一致,否则复选框不回显 */
|
||||||
|
const syncStudentTableRowSelectionKeys = () => {
|
||||||
|
const ids = new Set(selectedStudentIds.value);
|
||||||
|
/** 与 :row-key 一致使用 number,否则受控勾选无法与行匹配 */
|
||||||
|
studentRowSelection.selectedRowKeys = studentTableData.value
|
||||||
|
.filter((s) => ids.has(studentIdNum(s)))
|
||||||
|
.map((s) => studentIdNum(s));
|
||||||
|
};
|
||||||
|
|
||||||
|
const studentRowSelection = reactive({
|
||||||
|
type: 'checkbox' as const,
|
||||||
|
selectedRowKeys: [] as (string | number)[],
|
||||||
|
onChange: (selectedRowKeys: (string | number)[]) => {
|
||||||
|
mergeStudentTableSelection(selectedRowKeys);
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
/** 翻页、筛选后数据源变化时同步复选框回显 */
|
||||||
|
watch(studentTableData, () => {
|
||||||
|
syncStudentTableRowSelectionKeys();
|
||||||
|
});
|
||||||
|
|
||||||
const openSelectStudentModal = async () => {
|
const openSelectStudentModal = async () => {
|
||||||
selectStudentModalVisible.value = true;
|
selectStudentModalVisible.value = true;
|
||||||
studentSearchKeyword.value = '';
|
studentSearchKeyword.value = '';
|
||||||
studentClassFilter.value = undefined;
|
studentClassFilter.value = undefined;
|
||||||
selectedStudent.value = null;
|
selectedStudentIds.value = [];
|
||||||
|
selectedStudentMeta.value = {};
|
||||||
studentPagination.current = 1;
|
studentPagination.current = 1;
|
||||||
await loadStudentsForSelect();
|
await loadStudentsForSelect();
|
||||||
await loadClassOptions();
|
await loadClassOptions();
|
||||||
@ -652,6 +690,7 @@ const loadStudentsForSelect = async () => {
|
|||||||
studentTableData.value = [];
|
studentTableData.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
studentsLoading.value = false;
|
studentsLoading.value = false;
|
||||||
|
syncStudentTableRowSelectionKeys();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -677,26 +716,42 @@ const handleStudentTableChange = (pagination: any) => {
|
|||||||
|
|
||||||
const cancelSelectStudent = () => {
|
const cancelSelectStudent = () => {
|
||||||
selectStudentModalVisible.value = false;
|
selectStudentModalVisible.value = false;
|
||||||
selectedStudent.value = null;
|
selectedStudentIds.value = [];
|
||||||
|
selectedStudentMeta.value = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmAddChild = async () => {
|
const confirmAddChild = async () => {
|
||||||
if (!selectedStudent.value || !currentParent.value) return;
|
if (selectedStudentIds.value.length === 0 || !currentParent.value) return;
|
||||||
|
|
||||||
addChildLoading.value = true;
|
addChildLoading.value = true;
|
||||||
|
let success = 0;
|
||||||
|
let lastError = '';
|
||||||
|
try {
|
||||||
|
for (const studentId of selectedStudentIds.value) {
|
||||||
try {
|
try {
|
||||||
await addChildToParent(currentParent.value.id, {
|
await addChildToParent(currentParent.value.id, {
|
||||||
studentId: selectedStudent.value.id,
|
studentId,
|
||||||
relationship: addChildForm.relationship,
|
relationship: addChildForm.relationship,
|
||||||
});
|
});
|
||||||
message.success('添加成功');
|
success++;
|
||||||
|
} catch (e: any) {
|
||||||
|
lastError = e?.response?.data?.message || e?.message || '添加失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (success > 0) {
|
||||||
|
const fail = selectedStudentIds.value.length - success;
|
||||||
|
message.success(
|
||||||
|
fail > 0 ? `成功关联 ${success} 个孩子,${fail} 个未添加(可能已关联)` : `成功关联 ${success} 个孩子`
|
||||||
|
);
|
||||||
selectStudentModalVisible.value = false;
|
selectStudentModalVisible.value = false;
|
||||||
selectedStudent.value = null;
|
selectedStudentIds.value = [];
|
||||||
|
selectedStudentMeta.value = {};
|
||||||
addChildForm.relationship = 'FATHER';
|
addChildForm.relationship = 'FATHER';
|
||||||
await loadParentChildren(currentParent.value.id);
|
await loadParentChildren(currentParent.value.id);
|
||||||
loadParents(); // 刷新家长列表更新孩子数量
|
loadParents();
|
||||||
} catch (error: any) {
|
} else {
|
||||||
message.error(error?.response?.data?.message || '添加失败');
|
message.error(lastError || '添加失败');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
addChildLoading.value = false;
|
addChildLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -726,6 +781,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
@ -1153,8 +1209,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
.select-footer {
|
.select-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: #F8F9FA;
|
background: #F8F9FA;
|
||||||
@ -1163,8 +1221,20 @@ onMounted(() => {
|
|||||||
|
|
||||||
.selected-info {
|
.selected-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-tags {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-class {
|
.selected-class {
|
||||||
@ -1189,6 +1259,7 @@ onMounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-stats {
|
.header-stats {
|
||||||
|
|||||||
@ -759,6 +759,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
@ -768,14 +769,17 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title-icon {
|
.title-icon {
|
||||||
width: 64px;
|
width: 48px;
|
||||||
height: 64px;
|
height: 48px;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 32px;
|
}
|
||||||
|
|
||||||
|
.title-icon :deep(svg) {
|
||||||
|
font-size: 28px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -803,7 +807,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 28px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@ -858,9 +862,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filters :deep(.ant-select-selector),
|
.filters :deep(.ant-select-selector),
|
||||||
{
|
.filters :deep(.ant-input-affix-wrapper) {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 2px solid #F0F0F0;
|
border: 2px solid #F0F0F0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters :deep(.ant-select-selector:hover),
|
.filters :deep(.ant-select-selector:hover),
|
||||||
@ -1319,6 +1323,7 @@ border: 2px solid #F0F0F0;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-stats {
|
.header-stats {
|
||||||
|
|||||||
@ -512,6 +512,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
@ -850,6 +851,7 @@ onMounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-stats {
|
.header-stats {
|
||||||
|
|||||||
@ -922,11 +922,23 @@ const resetTimer = () => {
|
|||||||
const exitLesson = () => {
|
const exitLesson = () => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认退出',
|
title: '确认退出',
|
||||||
content: '确定要退出上课吗?课堂记录将不会保存。',
|
content: '确定要退出上课吗?当前未完成的授课记录将被删除,且无法恢复。',
|
||||||
okText: '确认退出',
|
okText: '确认退出',
|
||||||
cancelText: '继续上课',
|
cancelText: '继续上课',
|
||||||
onOk: () => {
|
onOk: async () => {
|
||||||
|
if (!lessonId.value) {
|
||||||
router.back();
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await teacherApi.abandonLesson(lessonId.value);
|
||||||
|
pauseTimer();
|
||||||
|
message.success('已退出上课');
|
||||||
|
router.back();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || error?.response?.data?.message || '退出失败');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -154,6 +154,16 @@ public class TeacherLessonController {
|
|||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "退出上课(删除未完成的课时记录)")
|
||||||
|
@Log(module = LogModule.LESSON, type = LogOperationType.DELETE, description = "教师退出上课并删除课时")
|
||||||
|
@PostMapping("/{id}/abandon")
|
||||||
|
public Result<Void> abandonLesson(@PathVariable Long id) {
|
||||||
|
Long teacherId = SecurityUtils.getCurrentUserId();
|
||||||
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
|
lessonService.abandonLessonByTeacher(id, teacherId, tenantId);
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取今日课程")
|
@Operation(summary = "获取今日课程")
|
||||||
@GetMapping("/today")
|
@GetMapping("/today")
|
||||||
public Result<List<LessonResponse>> getTodayLessons() {
|
public Result<List<LessonResponse>> getTodayLessons() {
|
||||||
|
|||||||
@ -36,6 +36,11 @@ public interface LessonService extends com.baomidou.mybatisplus.extension.servic
|
|||||||
|
|
||||||
void cancelLesson(Long id);
|
void cancelLesson(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师退出上课:删除未完成的课时记录(非「已完成」),并回退课程包使用次数(与取消逻辑一致,已取消状态不再重复扣减)
|
||||||
|
*/
|
||||||
|
void abandonLessonByTeacher(Long lessonId, Long teacherId, Long tenantId);
|
||||||
|
|
||||||
List<Lesson> getTodayLessons(Long tenantId);
|
List<Lesson> getTodayLessons(Long tenantId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -167,7 +167,7 @@ public class LessonServiceImpl extends ServiceImpl<LessonMapper, Lesson>
|
|||||||
if (endDate != null) {
|
if (endDate != null) {
|
||||||
wrapper.le(Lesson::getLessonDate, endDate);
|
wrapper.le(Lesson::getLessonDate, endDate);
|
||||||
}
|
}
|
||||||
wrapper.orderByAsc(Lesson::getLessonDate, Lesson::getStartTime);
|
wrapper.orderByDesc(Lesson::getCreatedAt, Lesson::getUpdatedAt);
|
||||||
|
|
||||||
return lessonMapper.selectPage(page, wrapper);
|
return lessonMapper.selectPage(page, wrapper);
|
||||||
}
|
}
|
||||||
@ -221,6 +221,33 @@ public class LessonServiceImpl extends ServiceImpl<LessonMapper, Lesson>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void abandonLessonByTeacher(Long lessonId, Long teacherId, Long tenantId) {
|
||||||
|
Lesson lesson = getLessonById(lessonId);
|
||||||
|
if (!tenantId.equals(lesson.getTenantId())) {
|
||||||
|
throw new BusinessException(ErrorCode.FORBIDDEN, "无权操作该课时");
|
||||||
|
}
|
||||||
|
if (!teacherId.equals(lesson.getTeacherId())) {
|
||||||
|
throw new BusinessException(ErrorCode.FORBIDDEN, "无权操作该课时");
|
||||||
|
}
|
||||||
|
String st = lesson.getStatus();
|
||||||
|
if (LessonStatus.COMPLETED.getCode().equals(st)) {
|
||||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "已完成的课程不可删除");
|
||||||
|
}
|
||||||
|
studentRecordMapper.delete(
|
||||||
|
new LambdaQueryWrapper<StudentRecord>().eq(StudentRecord::getLessonId, lessonId));
|
||||||
|
lessonFeedbackMapper.delete(
|
||||||
|
new LambdaQueryWrapper<LessonFeedback>().eq(LessonFeedback::getLessonId, lessonId));
|
||||||
|
// 与 cancelLesson 一致:仅当尚未处于「已取消」时回退使用次数(避免重复扣减)
|
||||||
|
if (lesson.getCourseId() != null && !LessonStatus.CANCELLED.getCode().equals(st)) {
|
||||||
|
coursePackageMapper.decrementUsageCount(lesson.getCourseId());
|
||||||
|
log.info("退出上课删除课时,课程包使用次数 -1: courseId={}", lesson.getCourseId());
|
||||||
|
}
|
||||||
|
lessonMapper.deleteById(lessonId);
|
||||||
|
log.info("教师退出上课,已删除课时记录 lessonId={}", lessonId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Lesson> getTodayLessons(Long tenantId) {
|
public List<Lesson> getTodayLessons(Long tenantId) {
|
||||||
return lessonMapper.selectList(
|
return lessonMapper.selectList(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user