教学任务页面优化

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
+30
View File
@@ -300,6 +300,12 @@ button { cursor: pointer; }
.task-summary span, .task-summary small { color: #b8c1de; font-size: 10px; }
.task-summary b { margin-left: auto; color: #58d1c0; font: 700 30px/1 Consolas, monospace; }
.task-summary p { margin: 0; padding-left: 28px; border-left: 1px solid rgba(255,255,255,.16); color: #d4daeb; font-size: 11px; letter-spacing: .03em; }
.batch-action-bar { min-height: 64px; padding: 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid #b9dcd7; background: #f1f9f7; }
.batch-action-bar > div:first-child { display: grid; gap: 4px; }
.batch-action-bar b { color: #244a49; font-size: 12px; }
.batch-action-bar span { color: #66807f; font-size: 10px; }
.batch-action-bar > div:last-child { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
.batch-action-bar .el-button + .el-button { margin-left: 0; }
.muted-action { color: #9aa1ae; font-size: 11px; }
.schedule-toolbar { min-height: 86px; padding: 12px 16px; display: flex; align-items: stretch; gap: 14px; border: 1px solid var(--line); background: white; }
.schedule-toolbar > .el-select { width: 245px; align-self: center; }
@@ -362,6 +368,18 @@ button { cursor: pointer; }
.generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
.generation-preview span, .generation-preview small { font-size: 10px; }
.field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; }
.option-filter-panel { margin: -3px 0 18px; padding: 11px 13px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; border: 1px solid #dce4eb; border-left: 3px solid var(--teal); background: #f8fafb; }
.option-filter-title { color: #3f5367; font-size: 10px; font-weight: 650; white-space: nowrap; }
.option-filter-panel > small { color: var(--muted); font-size: 9px; white-space: nowrap; }
.option-filter-grid { min-width: 0; display: grid; gap: 8px; }
.option-filter-grid.course { grid-template-columns: minmax(150px, 1fr) minmax(145px, .8fr) minmax(130px, .65fr); }
.option-filter-grid.course.compact { grid-template-columns: minmax(180px, 1fr) minmax(180px, .8fr); }
.option-filter-grid.classes { grid-template-columns: minmax(190px, 1.25fr) repeat(3, minmax(115px, .75fr)); }
.class-picker { width: 100%; margin-bottom: 8px; padding: 11px 12px 8px; border: 1px solid #dce4eb; background: #f8fafb; }
.class-picker-actions { min-height: 32px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.class-picker-actions > span { color: var(--muted); font-size: 9px; }
.class-picker-actions > div { display: flex; align-items: center; }
.class-picker-actions .el-button + .el-button { margin-left: 4px; }
.selection-page { min-width: 0; }
.selection-round-strip {
@@ -982,6 +1000,13 @@ button { cursor: pointer; }
.task-summary { align-items: flex-start; flex-direction: column; gap: 12px; }
.task-summary > div { width: 100%; }
.task-summary p { padding: 12px 0 0; border-left: none; border-top: 1px solid rgba(255,255,255,.16); line-height: 1.6; }
.batch-action-bar { align-items: flex-start; flex-direction: column; }
.batch-action-bar > div:last-child { justify-content: flex-start; }
.option-filter-panel { grid-template-columns: 1fr; }
.option-filter-grid.course,
.option-filter-grid.course.compact,
.option-filter-grid.classes { grid-template-columns: 1fr 1fr; }
.class-picker-actions { align-items: flex-start; flex-direction: column; gap: 2px; }
.schedule-toolbar { display: block; }
.schedule-toolbar > .el-select { width: 100%; }
.schedule-version-strip { margin-top: 10px; }
@@ -1025,6 +1050,11 @@ button { cursor: pointer; }
}
@media (max-width: 600px) {
.option-filter-grid.course,
.option-filter-grid.course.compact,
.option-filter-grid.classes { grid-template-columns: 1fr; }
.batch-action-bar > div:last-child { width: 100%; }
.batch-action-bar > div:last-child .el-button { flex: 1 1 auto; }
.selection-round-strip > button { flex-basis: 240px; }
.selection-window { display: block; }
.window-seal { min-height: 54px; padding: 10px; display: flex; border-right: none; border-bottom: 1px solid rgba(255,255,255,.13); }
+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">