Merge remote-tracking branch 'origin/master'

This commit is contained in:
En 2026-03-24 18:09:27 +08:00
commit 4d97e1cbf9
16 changed files with 302 additions and 104 deletions

View File

@ -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,

View File

@ -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: '请输入课程包名称' },

View File

@ -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>

View File

@ -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 TDZCannot 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) {

View File

@ -25,7 +25,9 @@
style="width: 100%" style="width: 100%"
@change="handleChange" @change="handleChange"
/> />
<span class="duration-unit">分钟</span> <a-form-item-rest>
<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">
<LessonStepsEditor <a-form-item-rest>
v-model="lessonData.steps" <LessonStepsEditor
:show-template="showTemplate" v-model="lessonData.steps"
@change="handleChange" :show-template="showTemplate"
/> @change="handleChange"
/>
</a-form-item-rest>
</a-form-item> </a-form-item>
</a-card> </a-card>

View File

@ -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>

View File

@ -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,

View File

@ -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>

View File

@ -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 {

View File

@ -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 { try {
await addChildToParent(currentParent.value.id, { for (const studentId of selectedStudentIds.value) {
studentId: selectedStudent.value.id, try {
relationship: addChildForm.relationship, await addChildToParent(currentParent.value.id, {
}); studentId,
message.success('添加成功'); relationship: addChildForm.relationship,
selectStudentModalVisible.value = false; });
selectedStudent.value = null; success++;
addChildForm.relationship = 'FATHER'; } catch (e: any) {
await loadParentChildren(currentParent.value.id); lastError = e?.response?.data?.message || e?.message || '添加失败';
loadParents(); // }
} catch (error: any) { }
message.error(error?.response?.data?.message || '添加失败'); if (success > 0) {
const fail = selectedStudentIds.value.length - success;
message.success(
fail > 0 ? `成功关联 ${success} 个孩子,${fail} 个未添加(可能已关联)` : `成功关联 ${success} 个孩子`
);
selectStudentModalVisible.value = false;
selectedStudentIds.value = [];
selectedStudentMeta.value = {};
addChildForm.relationship = 'FATHER';
await loadParentChildren(currentParent.value.id);
loadParents();
} else {
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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 () => {
router.back(); if (!lessonId.value) {
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;
}
}, },
}); });
}; };

View File

@ -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() {

View File

@ -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);
/** /**

View File

@ -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(