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;
|
||||
}
|
||||
|
||||
/** 退出上课:删除未完成的授课记录(非已完成) */
|
||||
export function abandonLesson(id: number | string): Promise<any> {
|
||||
return http.post(`/v1/teacher/lessons/${id}/abandon`, {}) as any;
|
||||
}
|
||||
|
||||
// 保存学生评价记录(lessonId 使用 string 避免 Long 精度丢失)
|
||||
export function saveStudentRecord(
|
||||
lessonId: number | string,
|
||||
|
||||
@ -74,8 +74,10 @@
|
||||
<a-select
|
||||
v-model:value="formData.domainTags"
|
||||
mode="multiple"
|
||||
show-search
|
||||
placeholder="请选择核心发展目标(可多选)"
|
||||
style="width: 100%"
|
||||
:filter-option="filterDomainTagOption"
|
||||
@change="handleChange"
|
||||
>
|
||||
<a-select-opt-group label="健康">
|
||||
@ -156,6 +158,38 @@ const themesLoading = ref(false);
|
||||
const themes = ref<Theme[]>([]);
|
||||
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 = {
|
||||
name: [
|
||||
{ required: true, message: '请输入课程包名称' },
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
<div class="step2-course-intro">
|
||||
<div class="intro-header">
|
||||
<span class="title">课程介绍</span>
|
||||
<a-tag color="blue">已填写 {{ filledCount }} / 8</a-tag>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" type="card">
|
||||
@ -176,20 +175,6 @@ watch(
|
||||
// 计算当前tab索引
|
||||
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
|
||||
const prevTab = () => {
|
||||
const index = currentTabIndex.value;
|
||||
@ -221,7 +206,6 @@ const validate = () => {
|
||||
defineExpose({
|
||||
validate,
|
||||
formData,
|
||||
filledCount,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -120,15 +120,6 @@ const imagePreviewLoading = ref(false);
|
||||
const imagePreviewLoaded = 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 typeMap: Record<string, string> = {
|
||||
video: '上传视频',
|
||||
@ -181,6 +172,19 @@ const previewUrl = computed(() => {
|
||||
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 预览)
|
||||
const isPdfFile = computed(() => {
|
||||
if (props.fileType === 'ppt' && props.filePath) {
|
||||
|
||||
@ -25,7 +25,9 @@
|
||||
style="width: 100%"
|
||||
@change="handleChange"
|
||||
/>
|
||||
<a-form-item-rest>
|
||||
<span class="duration-unit">分钟</span>
|
||||
</a-form-item-rest>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@ -137,11 +139,13 @@
|
||||
</template>
|
||||
<div class="field-hint">至少添加一个环节,每个环节需填写名称、内容、目标</div>
|
||||
<a-form-item name="steps">
|
||||
<a-form-item-rest>
|
||||
<LessonStepsEditor
|
||||
v-model="lessonData.steps"
|
||||
:show-template="showTemplate"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</a-form-item-rest>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
|
||||
@ -18,8 +18,11 @@
|
||||
</a-page-header>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading" tip="正在加载课程数据...">
|
||||
<a-card :bordered="false" style="margin-top: 16px;">
|
||||
<!-- 编辑态:数据未就绪时单独展示加载,避免 a-spin 包裹整表在 spinning 切换时与大量子组件补丁竞态导致 __vnode 报错 -->
|
||||
<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-step title="基本信息" />
|
||||
@ -62,12 +65,11 @@
|
||||
@change="handleDataChange" />
|
||||
</div>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { message } from 'ant-design-vue';
|
||||
import Step1BasicInfo from '@/components/course-edit/Step1BasicInfo.vue';
|
||||
@ -94,7 +96,8 @@ const route = useRoute();
|
||||
const isEdit = computed(() => !!route.params.id);
|
||||
const courseId = computed(() => route.params.id as string | undefined);
|
||||
|
||||
const loading = ref(false);
|
||||
/** 编辑页:详情拉取完成后再挂载步骤表单,避免与 Spin 嵌套更新冲突 */
|
||||
const pageContentReady = ref(!isEdit.value);
|
||||
const saving = ref(false);
|
||||
const currentStep = ref(0);
|
||||
|
||||
@ -137,7 +140,7 @@ const formData = reactive({
|
||||
const fetchCourseDetail = async () => {
|
||||
if (!isEdit.value) return;
|
||||
|
||||
loading.value = true;
|
||||
pageContentReady.value = false;
|
||||
try {
|
||||
const res = await getCourse(courseId.value) as any;
|
||||
const course = res.data || res;
|
||||
@ -180,10 +183,12 @@ const fetchCourseDetail = async () => {
|
||||
|
||||
// 环创建设
|
||||
formData.environmentConstruction = course.environmentConstruction || '';
|
||||
|
||||
await nextTick();
|
||||
pageContentReady.value = true;
|
||||
} catch (error) {
|
||||
message.error('获取课程详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
pageContentReady.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
@ -486,4 +491,12 @@ provide('courseId', courseId);
|
||||
min-height: 400px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.course-edit-page-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 420px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -71,8 +71,10 @@
|
||||
<a-select
|
||||
v-model:value="formData.domainTags"
|
||||
mode="multiple"
|
||||
show-search
|
||||
placeholder="请选择核心发展目标(可多选)"
|
||||
style="width: 100%"
|
||||
:filter-option="filterDomainTagOption"
|
||||
@change="handleChange"
|
||||
>
|
||||
<a-select-opt-group label="健康">
|
||||
@ -152,6 +154,37 @@ const themesLoading = ref(false);
|
||||
const themes = ref<Theme[]>([]);
|
||||
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>({
|
||||
name: '',
|
||||
themeId: undefined,
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
<div class="step2-course-intro">
|
||||
<div class="intro-header">
|
||||
<span class="title">课程介绍</span>
|
||||
<a-tag color="blue">已填写 {{ filledCount }} / 8</a-tag>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" type="card">
|
||||
@ -176,20 +175,6 @@ watch(
|
||||
// 计算当前tab索引
|
||||
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
|
||||
const prevTab = () => {
|
||||
const index = currentTabIndex.value;
|
||||
@ -221,7 +206,6 @@ const validate = () => {
|
||||
defineExpose({
|
||||
validate,
|
||||
formData,
|
||||
filledCount,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -705,6 +705,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
@ -714,13 +715,16 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title-icon :deep(svg) {
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
}
|
||||
@ -1328,6 +1332,7 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
|
||||
@ -242,7 +242,8 @@
|
||||
|
||||
<!-- 学生表格 -->
|
||||
<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;">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'gender'">
|
||||
@ -255,11 +256,17 @@
|
||||
</a-table>
|
||||
|
||||
<!-- 选择关系并确认 -->
|
||||
<div class="select-footer" v-if="selectedStudent">
|
||||
<div class="select-footer" v-if="selectedStudentIds.length > 0">
|
||||
<div class="selected-info">
|
||||
<span>已选择:</span>
|
||||
<a-tag color="orange">{{ selectedStudent.name }}</a-tag>
|
||||
<span class="selected-class">{{ selectedStudent.className }}</span>
|
||||
<span class="selected-count">已选择 {{ selectedStudentIds.length }} 人:</span>
|
||||
<a-space wrap class="selected-tags">
|
||||
<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 class="select-actions">
|
||||
<a-select v-model:value="addChildForm.relationship" style="width: 100px; margin-right: 12px;">
|
||||
@ -302,7 +309,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue';
|
||||
import {
|
||||
IdcardOutlined,
|
||||
PlusOutlined,
|
||||
@ -378,7 +385,6 @@ const addChildLoading = ref(false);
|
||||
const studentsLoading = ref(false);
|
||||
|
||||
const addChildForm = reactive({
|
||||
studentId: undefined as number | undefined,
|
||||
relationship: 'FATHER',
|
||||
});
|
||||
|
||||
@ -386,7 +392,10 @@ const addChildForm = reactive({
|
||||
const selectStudentModalVisible = ref(false);
|
||||
const studentSearchKeyword = ref('');
|
||||
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 studentPagination = reactive({
|
||||
current: 1,
|
||||
@ -613,24 +622,53 @@ const studentTableColumns = [
|
||||
{ title: '班级', dataIndex: 'className', key: 'className', width: 120 },
|
||||
];
|
||||
|
||||
const studentRowSelection = computed(() => ({
|
||||
type: 'radio' as 'radio',
|
||||
selectedRowKeys: selectedStudent.value ? [selectedStudent.value.id] : [],
|
||||
onChange: (selectedRowKeys: (string | number)[]) => {
|
||||
if (selectedRowKeys.length > 0) {
|
||||
const student = studentTableData.value.find(s => s.id === selectedRowKeys[0]);
|
||||
selectedStudent.value = student || null;
|
||||
} else {
|
||||
selectedStudent.value = null;
|
||||
const studentIdNum = (s: Student) => Number(s.id);
|
||||
|
||||
/** 合并当前页勾选与其它页已选,实现表格多选跨页保留(id 统一为 number,避免与 rowKey 类型不一致导致无法回显) */
|
||||
const mergeStudentTableSelection = (keysFromThisPage: (string | number)[]) => {
|
||||
const currentPageIds = studentTableData.value.map(studentIdNum);
|
||||
const keysNum = keysFromThisPage.map(Number);
|
||||
const fromOtherPages = selectedStudentIds.value.filter((id) => !currentPageIds.includes(id));
|
||||
selectedStudentIds.value = [...new Set([...fromOtherPages, ...keysNum])];
|
||||
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 () => {
|
||||
selectStudentModalVisible.value = true;
|
||||
studentSearchKeyword.value = '';
|
||||
studentClassFilter.value = undefined;
|
||||
selectedStudent.value = null;
|
||||
selectedStudentIds.value = [];
|
||||
selectedStudentMeta.value = {};
|
||||
studentPagination.current = 1;
|
||||
await loadStudentsForSelect();
|
||||
await loadClassOptions();
|
||||
@ -652,6 +690,7 @@ const loadStudentsForSelect = async () => {
|
||||
studentTableData.value = [];
|
||||
} finally {
|
||||
studentsLoading.value = false;
|
||||
syncStudentTableRowSelectionKeys();
|
||||
}
|
||||
};
|
||||
|
||||
@ -677,26 +716,42 @@ const handleStudentTableChange = (pagination: any) => {
|
||||
|
||||
const cancelSelectStudent = () => {
|
||||
selectStudentModalVisible.value = false;
|
||||
selectedStudent.value = null;
|
||||
selectedStudentIds.value = [];
|
||||
selectedStudentMeta.value = {};
|
||||
};
|
||||
|
||||
const confirmAddChild = async () => {
|
||||
if (!selectedStudent.value || !currentParent.value) return;
|
||||
if (selectedStudentIds.value.length === 0 || !currentParent.value) return;
|
||||
|
||||
addChildLoading.value = true;
|
||||
let success = 0;
|
||||
let lastError = '';
|
||||
try {
|
||||
for (const studentId of selectedStudentIds.value) {
|
||||
try {
|
||||
await addChildToParent(currentParent.value.id, {
|
||||
studentId: selectedStudent.value.id,
|
||||
studentId,
|
||||
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;
|
||||
selectedStudent.value = null;
|
||||
selectedStudentIds.value = [];
|
||||
selectedStudentMeta.value = {};
|
||||
addChildForm.relationship = 'FATHER';
|
||||
await loadParentChildren(currentParent.value.id);
|
||||
loadParents(); // 刷新家长列表更新孩子数量
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || '添加失败');
|
||||
loadParents();
|
||||
} else {
|
||||
message.error(lastError || '添加失败');
|
||||
}
|
||||
} finally {
|
||||
addChildLoading.value = false;
|
||||
}
|
||||
@ -726,6 +781,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
@ -1153,8 +1209,10 @@ onMounted(() => {
|
||||
|
||||
.select-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #F8F9FA;
|
||||
@ -1163,8 +1221,20 @@ onMounted(() => {
|
||||
|
||||
.selected-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selected-tags {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selected-class {
|
||||
@ -1189,6 +1259,7 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
|
||||
@ -759,6 +759,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
@ -768,14 +769,17 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.title-icon :deep(svg) {
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@ -803,7 +807,7 @@ onMounted(() => {
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
@ -858,9 +862,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.filters :deep(.ant-select-selector),
|
||||
{
|
||||
border-radius: 12px;
|
||||
border: 2px solid #F0F0F0;
|
||||
.filters :deep(.ant-input-affix-wrapper) {
|
||||
border-radius: 12px;
|
||||
border: 2px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.filters :deep(.ant-select-selector:hover),
|
||||
@ -1319,6 +1323,7 @@ border: 2px solid #F0F0F0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
|
||||
@ -512,6 +512,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
@ -850,6 +851,7 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
|
||||
@ -922,11 +922,23 @@ const resetTimer = () => {
|
||||
const exitLesson = () => {
|
||||
Modal.confirm({
|
||||
title: '确认退出',
|
||||
content: '确定要退出上课吗?课堂记录将不会保存。',
|
||||
content: '确定要退出上课吗?当前未完成的授课记录将被删除,且无法恢复。',
|
||||
okText: '确认退出',
|
||||
cancelText: '继续上课',
|
||||
onOk: () => {
|
||||
onOk: async () => {
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -154,6 +154,16 @@ public class TeacherLessonController {
|
||||
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 = "获取今日课程")
|
||||
@GetMapping("/today")
|
||||
public Result<List<LessonResponse>> getTodayLessons() {
|
||||
|
||||
@ -36,6 +36,11 @@ public interface LessonService extends com.baomidou.mybatisplus.extension.servic
|
||||
|
||||
void cancelLesson(Long id);
|
||||
|
||||
/**
|
||||
* 教师退出上课:删除未完成的课时记录(非「已完成」),并回退课程包使用次数(与取消逻辑一致,已取消状态不再重复扣减)
|
||||
*/
|
||||
void abandonLessonByTeacher(Long lessonId, Long teacherId, Long tenantId);
|
||||
|
||||
List<Lesson> getTodayLessons(Long tenantId);
|
||||
|
||||
/**
|
||||
|
||||
@ -167,7 +167,7 @@ public class LessonServiceImpl extends ServiceImpl<LessonMapper, Lesson>
|
||||
if (endDate != null) {
|
||||
wrapper.le(Lesson::getLessonDate, endDate);
|
||||
}
|
||||
wrapper.orderByAsc(Lesson::getLessonDate, Lesson::getStartTime);
|
||||
wrapper.orderByDesc(Lesson::getCreatedAt, Lesson::getUpdatedAt);
|
||||
|
||||
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
|
||||
public List<Lesson> getTodayLessons(Long tenantId) {
|
||||
return lessonMapper.selectList(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user