教学任务页面优化

This commit is contained in:
2026-07-24 21:10:59 +08:00 Unverified
parent 6619179acc
commit 49b550560a
3 changed files with 486 additions and 29 deletions
+304 -9
View File
@@ -16,6 +16,7 @@ const dialogVisible = ref(false)
const generationDialog = ref(false)
const generating = ref(false)
const editingId = ref('')
const selectedRows = ref<any[]>([])
const terms = ref<any[]>([])
const courses = ref<any[]>([])
const teachers = ref<any[]>([])
@@ -30,6 +31,27 @@ const query = reactive({
})
const form = reactive<Record<string, any>>({})
const generationForm = reactive<Record<string, any>>({})
const manualCourseFilters = reactive({
keyword: '',
collegeId: undefined as string | undefined,
nature: undefined as string | undefined,
})
const manualClassFilters = reactive({
keyword: '',
collegeId: undefined as string | undefined,
majorId: undefined as string | undefined,
grade: undefined as number | undefined,
})
const generationCourseFilters = reactive({
keyword: '',
collegeId: undefined as string | undefined,
})
const generationClassFilters = reactive({
keyword: '',
collegeId: undefined as string | undefined,
majorId: undefined as string | undefined,
grade: undefined as number | undefined,
})
const eligibleTeachers = ref<any[]>([])
const manualEligibleTeachers = ref<any[]>([])
const statusLabels: Record<string, string> = {
@@ -37,12 +59,20 @@ const statusLabels: Record<string, string> = {
Published: '已发布',
Closed: '已结课',
}
const courseNatureLabels: Record<string, string> = {
GeneralRequired: '公共必修',
GeneralElective: '公共选修',
MajorRequired: '专业必修',
MajorElective: '专业选修',
Practice: '实践教学',
}
const availableClasses = computed(() => {
if (!auth.user?.roles.includes('CollegeAdmin') || !auth.user.collegeId) return classes.value
const enabled = classes.value.filter((item) => item.isEnabled)
if (!auth.user?.roles.includes('CollegeAdmin') || !auth.user.collegeId) return enabled
const ids = new Set(
majors.value.filter((item) => item.collegeId === auth.user!.collegeId).map((item) => item.id),
)
return classes.value.filter((item) => ids.has(item.majorId))
return enabled.filter((item) => ids.has(item.majorId))
})
const selectedCourse = computed(() =>
courses.value.find((item) => item.id === form.courseId),
@@ -72,6 +102,46 @@ const manageableCourses = computed(() => {
['MajorRequired', 'MajorElective', 'Practice'].includes(item.nature)
})
})
const courseColleges = computed(() => {
const result = new Map<string, string>()
courses.value.forEach((item) => result.set(item.collegeId, item.collegeName))
return [...result].map(([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name, 'zh-CN'),
)
})
const classColleges = computed(() => {
const result = new Map<string, string>()
majors.value.forEach((item) => result.set(item.collegeId, item.collegeName))
return [...result].map(([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name, 'zh-CN'),
)
})
const majorById = computed(() =>
new Map(majors.value.map((item) => [item.id, item])),
)
const classGrades = computed(() =>
[...new Set(availableClasses.value.map((item) => item.grade))].sort((a, b) => b - a),
)
const manualClassMajors = computed(() => filteredMajors(manualClassFilters.collegeId))
const generationClassMajors = computed(() => filteredMajors(generationClassFilters.collegeId))
const filteredManageableCourses = computed(() =>
manageableCourses.value.filter((item) => courseMatches(item, manualCourseFilters)),
)
const filteredPublicCourses = computed(() =>
publicCourses.value.filter((item) => courseMatches(item, generationCourseFilters)),
)
const filteredManualClasses = computed(() =>
availableClasses.value.filter((item) => classMatches(item, manualClassFilters)),
)
const filteredGenerationClasses = computed(() =>
availableClasses.value.filter((item) => classMatches(item, generationClassFilters)),
)
const selectedDraftRows = computed(() =>
selectedRows.value.filter((item) => item.status === 'Draft'),
)
const selectedPublishedRows = computed(() =>
selectedRows.value.filter((item) => item.status === 'Published'),
)
const responsibilityText = computed(() => {
if (isSuperAdmin.value) return '超级管理员可处理全部教学任务,并保留跨层级纠错权限。'
if (isAcademicAdmin.value && isCollegeAdmin.value) {
@@ -91,6 +161,70 @@ function canManageRow(row: any) {
isCollegeAdmin.value && !isPublic
}
function courseMatches(item: any, filters: Record<string, any>) {
const keyword = filters.keyword.trim().toLocaleLowerCase()
return (!keyword ||
item.code.toLocaleLowerCase().includes(keyword) ||
item.name.toLocaleLowerCase().includes(keyword)) &&
(!filters.collegeId || item.collegeId === filters.collegeId) &&
(!filters.nature || item.nature === filters.nature)
}
function filteredMajors(collegeId?: string) {
return majors.value
.filter((item) =>
item.isEnabled &&
(!collegeId || item.collegeId === collegeId) &&
availableClasses.value.some((administrativeClass) =>
administrativeClass.majorId === item.id),
)
.sort((a, b) => a.code.localeCompare(b.code))
}
function classMatches(item: any, filters: Record<string, any>) {
const keyword = filters.keyword.trim().toLocaleLowerCase()
const major = majorById.value.get(item.majorId)
return (!keyword ||
item.code.toLocaleLowerCase().includes(keyword) ||
item.name.toLocaleLowerCase().includes(keyword) ||
item.majorName.toLocaleLowerCase().includes(keyword)) &&
(!filters.collegeId || major?.collegeId === filters.collegeId) &&
(!filters.majorId || item.majorId === filters.majorId) &&
(!filters.grade || item.grade === filters.grade)
}
function resetOptionFilters(filters: Record<string, any>) {
Object.keys(filters).forEach((key) => {
filters[key] = key === 'keyword' ? '' : undefined
})
}
function onClassCollegeChanged(filters: Record<string, any>) {
if (filters.majorId &&
majorById.value.get(filters.majorId)?.collegeId !== filters.collegeId) {
filters.majorId = undefined
}
}
function selectFilteredClasses(target: Record<string, any>, filtered: any[]) {
const selected = new Set<string>(target.classIds ?? [])
filtered.forEach((item) => selected.add(item.id))
target.classIds = [...selected]
}
function clearFilteredClasses(target: Record<string, any>, filtered: any[]) {
const filteredIds = new Set(filtered.map((item) => item.id))
target.classIds = (target.classIds ?? []).filter((id: string) => !filteredIds.has(id))
}
function rowSelectable(row: any) {
return canManageRow(row) && ['Draft', 'Published'].includes(row.status)
}
function onTaskSelectionChanged(selection: any[]) {
selectedRows.value = selection
}
async function load() {
loading.value = true
try {
@@ -102,6 +236,7 @@ async function load() {
})
rows.value = data.items
total.value = data.total
selectedRows.value = []
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -141,6 +276,8 @@ function resetForm(detail?: any) {
function openCreate() {
editingId.value = ''
resetForm()
resetOptionFilters(manualCourseFilters)
resetOptionFilters(manualClassFilters)
dialogVisible.value = true
}
@@ -149,6 +286,8 @@ async function openEdit(row: any) {
const detail = (await http.get(`/teaching-tasks/${row.id}`)).data
editingId.value = row.id
resetForm(detail)
resetOptionFilters(manualCourseFilters)
resetOptionFilters(manualClassFilters)
await loadManualEligibleTeachers()
dialogVisible.value = true
} catch (error) {
@@ -238,6 +377,52 @@ async function closeTask(row: any) {
}
}
async function batchAction(
operation: 'Publish' | 'Delete' | 'Archive',
targets: any[],
) {
if (!targets.length) return
const descriptions = {
Publish: {
title: '批量发布教学任务',
message: `将发布选中的 ${targets.length} 个草稿。发布前会逐项校验教师资格、行政班和容量,确定继续吗?`,
confirmButtonText: '批量发布',
success: '教学任务已批量发布',
type: 'warning',
},
Delete: {
title: '批量删除教学任务',
message: `将永久删除选中的 ${targets.length} 个草稿,此操作不可撤销。确定继续吗?`,
confirmButtonText: '批量删除',
success: '教学任务草稿已批量删除',
type: 'error',
},
Archive: {
title: '批量归档教学任务',
message: `将选中的 ${targets.length} 个已发布任务结课并归档,后续将停止选课和排课调整。确定继续吗?`,
confirmButtonText: '批量归档',
success: '教学任务已批量归档',
type: 'warning',
},
} as const
const description = descriptions[operation]
try {
await ElMessageBox.confirm(description.message, description.title, {
type: description.type,
confirmButtonText: description.confirmButtonText,
cancelButtonText: '取消',
})
const { data } = await http.post('/teaching-tasks/batch', {
operation,
ids: targets.map((item) => item.id),
})
ElMessage.success(`${description.success},共 ${data.affectedCount}`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openGeneration() {
const currentTerm = terms.value.find((item) => item.isCurrent)
Object.assign(generationForm, {
@@ -250,6 +435,8 @@ function openGeneration() {
classIds: [],
})
eligibleTeachers.value = []
resetOptionFilters(generationCourseFilters)
resetOptionFilters(generationClassFilters)
generationDialog.value = true
}
@@ -347,7 +534,46 @@ onMounted(async () => {
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" class="data-table task-table">
<div v-if="selectedRows.length" class="batch-action-bar">
<div>
<b>已选择 {{ selectedRows.length }} 个教学任务</b>
<span>可操作{{ selectedDraftRows.length }} 个草稿{{ selectedPublishedRows.length }} 个已发布</span>
</div>
<div>
<el-button
type="success"
plain
:disabled="!selectedDraftRows.length"
@click="batchAction('Publish', selectedDraftRows)"
>
发布草稿{{ selectedDraftRows.length }}
</el-button>
<el-button
type="danger"
plain
:disabled="!selectedDraftRows.length"
@click="batchAction('Delete', selectedDraftRows)"
>
删除草稿{{ selectedDraftRows.length }}
</el-button>
<el-button
type="warning"
plain
:disabled="!selectedPublishedRows.length"
@click="batchAction('Archive', selectedPublishedRows)"
>
归档已发布{{ selectedPublishedRows.length }}
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="rows"
class="data-table task-table"
@selection-change="onTaskSelectionChanged"
>
<el-table-column type="selection" width="48" :selectable="rowSelectable" />
<el-table-column label="任务编号" width="155"><template #default="{ row }"><span class="registry-number">{{ row.taskNumber }}</span></template></el-table-column>
<el-table-column label="教学班 / 课程" min-width="230">
<template #default="{ row }">
@@ -371,7 +597,7 @@ onMounted(async () => {
<el-button v-if="canManageRow(row) && row.status === 'Draft'" link type="danger" @click="remove(row)">删除</el-button>
<el-button v-if="canManageRow(row) && row.status === 'Published'" link type="warning" @click="closeTask(row)">结课</el-button>
<span v-if="!canManageRow(row) && row.status !== 'Closed'" class="muted-action">只读</span>
<span v-if="row.status === 'Closed'" class="muted-action">已归档</span>
<span v-if="row.status === 'Closed'" class="muted-action">结课归档</span>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的教学任务" /></template>
@@ -390,7 +616,24 @@ onMounted(async () => {
</div>
<div class="form-grid">
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="课程" required><el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()"><el-option v-for="item in manageableCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" /></el-select></el-form-item>
<el-form-item label="课程" required>
<el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()">
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
</div>
<div class="option-filter-panel">
<span class="option-filter-title">筛选课程</span>
<div class="option-filter-grid course">
<el-input v-model="manualCourseFilters.keyword" clearable placeholder="课程代码或名称" />
<el-select v-model="manualCourseFilters.collegeId" clearable placeholder="全部开课单位">
<el-option v-for="item in courseColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="manualCourseFilters.nature" clearable placeholder="全部课程性质">
<el-option v-for="(label, value) in courseNatureLabels" :key="value" :label="label" :value="value" />
</el-select>
</div>
<small>当前显示 {{ filteredManageableCourses.length }} 门可维护课程</small>
</div>
<div class="form-grid three">
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" /></el-form-item>
@@ -410,8 +653,29 @@ onMounted(async () => {
</el-select>
</el-form-item>
<el-form-item label="关联行政班">
<el-select v-model="form.classIds" multiple filterable>
<el-option v-for="item in availableClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
<div class="class-picker">
<div class="option-filter-grid classes">
<el-input v-model="manualClassFilters.keyword" clearable placeholder="班级代码、名称或专业" />
<el-select v-model="manualClassFilters.collegeId" clearable placeholder="全部学院" @change="onClassCollegeChanged(manualClassFilters)">
<el-option v-for="item in classColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="manualClassFilters.majorId" clearable placeholder="全部专业">
<el-option v-for="item in manualClassMajors" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="manualClassFilters.grade" clearable placeholder="全部年级">
<el-option v-for="grade in classGrades" :key="grade" :label="`${grade} 级`" :value="grade" />
</el-select>
</div>
<div class="class-picker-actions">
<span>筛选到 {{ filteredManualClasses.length }} 个班已选择 {{ form.classIds?.length ?? 0 }} </span>
<div>
<el-button link type="primary" @click="selectFilteredClasses(form, filteredManualClasses)">选择筛选结果</el-button>
<el-button link @click="clearFilteredClasses(form, filteredManualClasses)">取消筛选结果</el-button>
</div>
</div>
</div>
<el-select v-model="form.classIds" multiple filterable collapse-tags collapse-tags-tooltip>
<el-option v-for="item in filteredManualClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
@@ -432,10 +696,20 @@ onMounted(async () => {
</el-form-item>
<el-form-item label="公共课程" required>
<el-select v-model="generationForm.courseId" filterable @change="loadEligibleTeachers">
<el-option v-for="item in publicCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
<el-option v-for="item in filteredPublicCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
</div>
<div class="option-filter-panel">
<span class="option-filter-title">筛选公共课程</span>
<div class="option-filter-grid course compact">
<el-input v-model="generationCourseFilters.keyword" clearable placeholder="课程代码或名称" />
<el-select v-model="generationCourseFilters.collegeId" clearable placeholder="全部开课单位">
<el-option v-for="item in courseColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</div>
<small>当前显示 {{ filteredPublicCourses.length }} 门公共课程</small>
</div>
<el-alert
:title="eligibleTeachers.length ? `已有 ${eligibleTeachers.length} 名教师审核通过` : '请选择课程查看可分配教师'"
:type="eligibleTeachers.length ? 'success' : 'warning'"
@@ -447,8 +721,29 @@ onMounted(async () => {
</template>
</el-alert>
<el-form-item label="参与合班的行政班" required style="margin-top: 16px">
<div class="class-picker">
<div class="option-filter-grid classes">
<el-input v-model="generationClassFilters.keyword" clearable placeholder="班级代码、名称或专业" />
<el-select v-model="generationClassFilters.collegeId" clearable placeholder="全部学院" @change="onClassCollegeChanged(generationClassFilters)">
<el-option v-for="item in classColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="generationClassFilters.majorId" clearable placeholder="全部专业">
<el-option v-for="item in generationClassMajors" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="generationClassFilters.grade" clearable placeholder="全部年级">
<el-option v-for="grade in classGrades" :key="grade" :label="`${grade} 级`" :value="grade" />
</el-select>
</div>
<div class="class-picker-actions">
<span>筛选到 {{ filteredGenerationClasses.length }} 个班已选择 {{ generationForm.classIds?.length ?? 0 }} </span>
<div>
<el-button link type="primary" @click="selectFilteredClasses(generationForm, filteredGenerationClasses)">选择筛选结果</el-button>
<el-button link @click="clearFilteredClasses(generationForm, filteredGenerationClasses)">取消筛选结果</el-button>
</div>
</div>
</div>
<el-select v-model="generationForm.classIds" multiple filterable collapse-tags>
<el-option v-for="item in availableClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
<el-option v-for="item in filteredGenerationClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<div class="form-grid three">