同学期、同课程、同教学班的实验项目合并展示,组内连续排列。

管理列表改为数据库筛选、按教学任务服务端分页(10–100 条/页)。
增加开课学院、课程名称/编码、教学班、任课教师、教学任务号筛选。
增加当前页草稿项目的批量选中、批量发布、批量删除;后端会再次校验权限、状态和发布条件。
确认框改为固定居中白色底板与阴影,不再出现只有文字按钮、没有底色的情况。
This commit is contained in:
2026-08-09 19:44:45 +08:00 Unverified
parent 25a1480739
commit 97aa23bc64
12 changed files with 793 additions and 78 deletions
+23
View File
@@ -343,6 +343,29 @@ button { cursor: pointer; }
.form-grid.compact { align-items: center; }
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* MessageBox 的定位与底色不能依赖其伪元素:部分浏览器在遮罩层中会将其压到左上角。 */
.el-overlay.is-message-box {
display: flex !important;
align-items: center;
justify-content: center;
padding: 16px;
}
.el-overlay.is-message-box .el-overlay-message-box {
display: block !important;
width: min(420px, calc(100vw - 32px));
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.el-overlay.is-message-box .el-overlay-message-box::after { display: none !important; }
.el-overlay.is-message-box .el-message-box {
width: 100%;
margin: 0 !important;
color: var(--ink);
background: #fff !important;
box-shadow: 0 18px 48px rgba(18, 37, 63, .28);
}
.password-reset-form { margin-top: 18px; }
.registry-switch { display: grid; grid-template-columns: 1fr 1fr 180px; min-height: 112px; border: 1px solid var(--line); background: white; }
+279 -30
View File
@@ -16,14 +16,24 @@ const loading = ref(false)
const terms = ref<any[]>([])
const termId = ref('')
const projects = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const selectedProjectIds = ref<string[]>([])
const options = reactive({
tasks: [] as any[],
colleges: [] as any[],
scheduleEntries: [] as any[],
classrooms: [] as any[],
periods: [] as any[],
})
const modeFilter = ref('')
const statusFilter = ref('')
const offeringCollegeId = ref('')
const courseKeyword = ref('')
const classKeyword = ref('')
const teacherKeyword = ref('')
const taskKeyword = ref('')
const projectDialog = ref(false)
const editingProjectId = ref('')
@@ -68,6 +78,51 @@ const batchProjectOptions = computed(() =>
projects.value.filter((project) =>
project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'),
)
const taskGroups = computed(() => {
const groups = new Map<string, any>()
options.tasks.forEach((task) => {
const key = `${task.academicTermId}-${task.courseId}`
const group = groups.get(key) ?? {
key,
termName: task.termName,
courseCode: task.courseCode,
courseName: task.courseName,
tasks: [],
}
group.tasks.push(task)
groups.set(key, group)
})
return [...groups.values()]
})
const projectGroups = computed(() => {
const groups = new Map<string, any>()
projects.value.forEach((project) => {
const classKey = [...(project.classNames ?? [])].sort().join('|')
const key = `${project.academicTermId}-${project.courseCode}-${classKey}`
const group = groups.get(key) ?? {
key,
termName: project.termName,
courseCode: project.courseCode,
courseName: project.courseName,
classNames: project.classNames,
teacherNames: project.teacherNames,
taskNumbers: [],
projects: [],
}
if (!group.taskNumbers.includes(project.taskNumber)) group.taskNumbers.push(project.taskNumber)
group.projects.push(project)
groups.set(key, group)
})
return [...groups.values()]
})
const draftSelectedProjectIds = computed(() =>
selectedProjectIds.value.filter((id) =>
projects.value.some((project) => project.id === id && project.status === 'Draft')),
)
const allPageDraftSelected = computed(() => {
const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id)
return ids.length > 0 && ids.every((id) => selectedProjectIds.value.includes(id))
})
const activePeriods = computed(() =>
options.periods.filter((period) =>
!selectedProject.value
@@ -119,6 +174,11 @@ function onScheduleEntryChange() {
onTaskChange()
}
function formatScheduleEntry(entry: any) {
const classes = entry.classNames?.join('、') || '选课学生'
return `${entry.courseCode} · ${entry.courseName} · ${entry.taskNumber} · ${classes} · 星期 ${entry.dayOfWeek}${entry.startPeriod}${entry.startPeriod + entry.periodCount - 1} 节 · ${entry.campusName} ${entry.buildingName} ${entry.classroomName}`
}
function openCreateProject() {
resetProjectForm()
projectDialog.value = true
@@ -141,10 +201,10 @@ function openEditProject(project: any) {
async function saveProject() {
if (!projectForm.teachingTaskIds.length ||
(projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
!projectForm.code.trim()
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
ElMessage.warning(projectForm.arrangementMode === 'Centralized'
ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value
? '请选择课表实验课,并填写项目编码、名称和开放日期'
: '请填写教学任务、项目编码、名称和开放日期')
return
@@ -164,16 +224,15 @@ async function saveProject() {
if (editingProjectId.value) {
await http.put(`/experiments/${editingProjectId.value}`, payload)
ElMessage.success('实验项目已更新')
} else if (projectForm.arrangementMode === 'Centralized') {
await http.post('/experiments', payload)
ElMessage.success('实验项目已绑定课表实验课')
} else {
await http.post('/experiments/batch', {
...payload,
teachingTaskId: undefined,
teachingTaskIds: projectForm.teachingTaskIds,
})
ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
ElMessage.success(projectForm.arrangementMode === 'Centralized'
? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目`
: `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
}
projectDialog.value = false
await load()
@@ -336,6 +395,57 @@ async function deleteProject(project: any) {
}
}
function togglePageDraftSelection(value: unknown) {
const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id)
selectedProjectIds.value = Boolean(value)
? [...new Set([...selectedProjectIds.value, ...ids])]
: selectedProjectIds.value.filter((id) => !ids.includes(id))
}
function toggleProjectSelection(projectId: string, value: unknown) {
selectedProjectIds.value = Boolean(value)
? [...new Set([...selectedProjectIds.value, projectId])]
: selectedProjectIds.value.filter((id) => id !== projectId)
}
async function publishSelectedProjects() {
const ids = draftSelectedProjectIds.value
if (!ids.length) return
try {
await ElMessageBox.confirm(
`将发布选中的 ${ids.length} 个草稿实验项目,并通知对应学生。`,
'批量发布实验项目',
{ confirmButtonText: '确认发布', cancelButtonText: '取消', type: 'warning' },
)
await http.post('/experiments/batch/publish', { projectIds: ids })
ElMessage.success(`已发布 ${ids.length} 个实验项目`)
selectedProjectIds.value = []
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteSelectedProjects() {
const ids = draftSelectedProjectIds.value
if (!ids.length) return
try {
await ElMessageBox.confirm(
`将删除选中的 ${ids.length} 个草稿实验项目及其未发布场次。`,
'批量删除实验项目',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' },
)
await http.delete('/experiments/batch', { data: { projectIds: ids } })
ElMessage.success(`已删除 ${ids.length} 个实验项目`)
selectedProjectIds.value = []
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function cancelSession(project: any, session: any) {
try {
await ElMessageBox.confirm(
@@ -424,14 +534,20 @@ async function loadOptions() {
if (isStudent.value) return
try {
const { data } = await http.get('/experiments/options', {
params: { academicTermId: termId.value || undefined },
params: {
academicTermId: termId.value || undefined,
offeringCollegeId: offeringCollegeId.value || undefined,
courseKeyword: courseKeyword.value.trim() || undefined,
},
})
options.tasks = data.tasks
options.colleges = data.colleges
options.scheduleEntries = data.scheduleEntries
options.classrooms = data.classrooms
options.periods = data.periods
} catch (error) {
options.tasks = []
options.colleges = []
options.classrooms = []
options.periods = []
ElMessage.error(apiErrorMessage(error))
@@ -445,17 +561,28 @@ async function load() {
projects.value = (await http.get('/experiments/student', {
params: { academicTermId: termId.value || undefined },
})).data
total.value = projects.value.length
} else {
projects.value = (await http.get('/experiments/management', {
const { data } = await http.get('/experiments/management', {
params: {
academicTermId: termId.value || undefined,
arrangementMode: modeFilter.value || undefined,
status: statusFilter.value || undefined,
offeringCollegeId: offeringCollegeId.value || undefined,
courseKeyword: courseKeyword.value.trim() || undefined,
classKeyword: classKeyword.value.trim() || undefined,
teacherKeyword: teacherKeyword.value.trim() || undefined,
taskKeyword: taskKeyword.value.trim() || undefined,
page: page.value,
pageSize: pageSize.value,
},
})).data
})
projects.value = data.items
total.value = data.total
}
} catch (error) {
projects.value = []
total.value = 0
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
@@ -463,10 +590,30 @@ async function load() {
}
async function changeTerm() {
page.value = 1
selectedProjectIds.value = []
await loadOptions()
await load()
}
async function searchTaskOptions() {
page.value = 1
selectedProjectIds.value = []
await loadOptions()
await load()
}
async function changePage(nextPage: number) {
page.value = nextPage
selectedProjectIds.value = []
await load()
}
async function changePageSize(size: number) {
pageSize.value = size
await changePage(1)
}
onMounted(async () => {
try {
terms.value = (await http.get('/base-data/terms')).data
@@ -524,6 +671,13 @@ onMounted(async () => {
/>
</el-select>
<template v-if="!isStudent">
<el-select v-model="offeringCollegeId" clearable filterable placeholder="开课学院" @change="searchTaskOptions">
<el-option v-for="college in options.colleges" :key="college.collegeId" :label="college.collegeName" :value="college.collegeId" />
</el-select>
<el-input v-model="courseKeyword" clearable placeholder="课程名称或编码" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
<el-input v-model="classKeyword" clearable placeholder="教学班" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
<el-input v-model="teacherKeyword" clearable placeholder="任课教师" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
<el-input v-model="taskKeyword" clearable placeholder="教学任务号" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
<el-select v-model="modeFilter" clearable placeholder="全部安排方式" @change="load">
<el-option label="集中安排" value="Centralized" />
<el-option label="自行安排" value="SelfScheduled" />
@@ -534,12 +688,33 @@ onMounted(async () => {
<el-option label="已关闭" value="Closed" />
</el-select>
</template>
<span class="result-note"> {{ projects.length }} 实验项目</span>
<span class="result-note"> {{ total }} 教学班课程</span>
</section>
<section v-if="!isStudent" class="experiment-batch-bar">
<el-checkbox :model-value="allPageDraftSelected" :indeterminate="!!draftSelectedProjectIds.length && !allPageDraftSelected" @change="togglePageDraftSelection">
选中当前页全部草稿
</el-checkbox>
<span>已选 {{ draftSelectedProjectIds.length }} 个草稿项目</span>
<el-button size="small" type="primary" :disabled="!draftSelectedProjectIds.length" @click="publishSelectedProjects">批量发布</el-button>
<el-button size="small" type="danger" plain :disabled="!draftSelectedProjectIds.length" @click="deleteSelectedProjects">批量删除</el-button>
</section>
<section v-loading="loading" class="project-list">
<section v-for="group in projectGroups" :key="group.key" class="project-group">
<header class="project-group-head">
<div>
<span>{{ group.termName }}</span>
<h3>{{ group.courseCode }} · {{ group.courseName }}</h3>
</div>
<div class="project-group-task">
<b>{{ group.taskNumbers.join('、') }}</b>
<span>{{ group.classNames?.join('、') || '选课学生' }}</span>
</div>
</header>
<div class="project-group-projects">
<article
v-for="project in projects"
v-for="project in group.projects"
:key="project.id"
class="project-card"
:class="[
@@ -551,9 +726,15 @@ onMounted(async () => {
<div class="project-identity">
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span>
<h3>{{ project.name }}</h3>
<p>{{ project.courseName }} · {{ project.taskNumber }}</p>
<p>{{ project.arrangementMode === 'Centralized' ? '统一到场实验' : '开放预约实验' }}</p>
</div>
<div class="project-tags">
<el-checkbox
v-if="!isStudent && project.status === 'Draft'"
:model-value="selectedProjectIds.includes(project.id)"
@change="toggleProjectSelection(project.id, $event)"
aria-label="选择实验项目"
/>
<el-tag
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'"
effect="plain"
@@ -600,8 +781,8 @@ onMounted(async () => {
<article class="session-ticket">
<div class="ticket-date"><strong>课表</strong><span>固定</span></div>
<div class="ticket-body">
<b>星期 {{ project.scheduleEntry.dayOfWeek }} · {{ project.scheduleEntry.startPeriod }}{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} </b>
<span> {{ project.scheduleEntry.startWeek }}{{ project.scheduleEntry.endWeek }} · {{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
<b> {{ project.scheduleEntry.projectWeek ?? `${project.scheduleEntry.startWeek}${project.scheduleEntry.endWeek}` }} · 星期 {{ project.scheduleEntry.dayOfWeek }} · {{ project.scheduleEntry.startPeriod }}{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} </b>
<span>{{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
</div>
<div class="ticket-action"><el-tag type="primary" size="small" effect="plain">课表已安排</el-tag></div>
</article>
@@ -736,6 +917,8 @@ onMounted(async () => {
</el-button>
</footer>
</article>
</div>
</section>
<el-empty
v-if="!loading && !projects.length"
@@ -747,6 +930,19 @@ onMounted(async () => {
</el-empty>
</section>
<el-pagination
v-if="!isStudent && total > pageSize"
class="experiment-pagination"
background
layout="total, sizes, prev, pager, next"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="changePage"
@size-change="changePageSize"
/>
<el-dialog
v-model="projectDialog"
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
@@ -756,26 +952,24 @@ onMounted(async () => {
<el-form label-position="top" class="experiment-form">
<div class="form-section">
<header><span>PROJECT</span><b>规定实验项目</b></header>
<el-form-item v-if="projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
<el-select v-model="projectForm.scheduleEntryId" filterable :disabled="!!editingProjectId" placeholder="选择已安排实验室的实验课" @change="onScheduleEntryChange">
<el-option
v-for="entry in options.scheduleEntries"
:key="entry.id"
:label="`${entry.courseCode} · ${entry.courseName} · ${entry.taskNumber} · 星期 ${entry.dayOfWeek} 第 ${entry.startPeriod}${entry.startPeriod + entry.periodCount - 1} 节 · ${entry.campusName} ${entry.buildingName} ${entry.classroomName}`"
:label="formatScheduleEntry(entry)"
:value="entry.id"
/>
</el-select>
<small class="form-help">集中实验复用课表的时间和实验室不会再生成独立实验场次</small>
<small class="form-help">显示当前管理范围内全部已发布已安排实验室的实验课集中实验复用时间和实验室不会再生成独立实验场次</small>
</el-form-item>
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学任务(可多选)'" required>
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学(可多选)'" required>
<el-select
v-if="editingProjectId"
v-model="projectForm.teachingTaskIds"
filterable
:multiple="!editingProjectId"
collapse-tags
collapse-tags-tooltip
:disabled="!!editingProjectId"
placeholder="选择同一学期、同一课程的已发布教学任务"
disabled
placeholder="所属教学任务"
@change="onTaskChange"
>
<el-option
@@ -783,13 +977,45 @@ onMounted(async () => {
:key="task.id"
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
:value="task.id"
:disabled="!!selectedTask
&& (task.academicTermId !== selectedTask.academicTermId
|| task.courseId !== selectedTask.courseId)"
/>
</el-select>
<el-checkbox-group
v-else
v-model="projectForm.teachingTaskIds"
class="grouped-task-picker"
@change="onTaskChange"
>
<div class="task-option-filters">
<el-select v-model="offeringCollegeId" clearable filterable placeholder="开课学院" @change="searchTaskOptions">
<el-option v-for="college in options.colleges" :key="college.collegeId" :label="college.collegeName" :value="college.collegeId" />
</el-select>
<el-input v-model="courseKeyword" clearable placeholder="课程名称或代码" @keyup.enter="searchTaskOptions" />
<el-button @click="searchTaskOptions">查询</el-button>
</div>
<section v-for="group in taskGroups" :key="group.key" class="task-picker-course">
<header>
<span>{{ group.termName }}</span>
<b>{{ group.courseCode }} · {{ group.courseName }}</b>
</header>
<div>
<el-checkbox
v-for="task in group.tasks"
:key="task.id"
:value="task.id"
:disabled="projectForm.arrangementMode === 'SelfScheduled' && !!selectedTask
&& (task.academicTermId !== selectedTask.academicTermId
|| task.courseId !== selectedTask.courseId)"
>
<b>{{ task.taskNumber }}</b>
<span>{{ task.classNames.join('、') || task.name }}</span>
</el-checkbox>
</div>
</section>
<el-empty v-if="!taskGroups.length" :image-size="46" description="没有匹配的已发布教学班,请调整筛选条件" />
</el-checkbox-group>
<small v-if="!editingProjectId" class="form-help">
已选 {{ projectForm.teachingTaskIds.length }} 实验编码名称内容和开放日期将一次应用到这些教学任务
<template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班系统会为每个教学班的全部已发布实验课生成项目</template>
<template v-else>已选 {{ projectForm.teachingTaskIds.length }} 个教学班只能勾选同一学期同一课程实验编码名称内容和开放日期将一次应用到这些教学任务</template>
</small>
</el-form-item>
<div class="form-grid two">
@@ -1066,14 +1292,27 @@ onMounted(async () => {
.rail-switch i { width: 30px; height: 7px; border: 2px solid #7794a5; border-radius: 999px; background: #fff; }
.experiment-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; }
.experiment-toolbar .el-select { width: 210px; }
.experiment-toolbar .el-input { width: 172px; }
.result-note { margin-left: auto; color: var(--muted); font-size: 12px; }
.experiment-batch-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-height: 46px; margin: -6px 0 16px; padding: 8px 12px; border: 1px solid #cddce3; background: #f7fafb; }
.experiment-batch-bar > span { margin-right: auto; color: #5c7481; font-size: 12px; }
.experiment-pagination { justify-content: flex-end; margin-top: 4px; }
.project-list { display: grid; gap: 14px; min-height: 160px; }
.project-group { overflow: hidden; border: 1px solid #cddce3; background: #f6fafb; }
.project-group-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 17px; border-left: 5px solid var(--lab-blue); background: #edf4f6; }
.project-group-head > div:first-child { display: grid; gap: 3px; }
.project-group-head h3 { margin: 0; color: var(--lab-ink); font-size: 15px; }
.project-group-head > div:first-child span { color: #68808d; font: 700 10px/1 Consolas, monospace; letter-spacing: .06em; }
.project-group-task { display: grid; justify-items: end; gap: 3px; color: #5c7481; font-size: 11px; text-align: right; }
.project-group-task b { color: #365364; font-size: 12px; }
.project-group-projects { display: grid; gap: 0; padding: 0; }
.project-card {
overflow: hidden;
border: 1px solid #dce5ea;
border-top: 4px solid var(--lab-blue);
border: 0;
border-top: 1px solid #dce5ea;
border-left: 4px solid var(--lab-blue);
background: #fff;
box-shadow: 0 7px 22px rgb(35 67 85 / 5%);
box-shadow: none;
}
.project-card.is-flexible { border-top-color: var(--lab-teal); }
.project-card.is-closed { opacity: .82; }
@@ -1140,6 +1379,16 @@ onMounted(async () => {
.student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
.experiment-form { display: grid; gap: 13px; }
.grouped-task-picker { display: grid; gap: 10px; max-height: 310px; overflow: auto; padding: 10px; border: 1px solid #d8e3e8; background: #fff; }
.task-option-filters { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(180px, 1.2fr) auto; gap: 8px; }
.task-picker-course { overflow: hidden; border: 1px solid #e0e8eb; }
.task-picker-course header { display: flex; align-items: baseline; gap: 8px; padding: 8px 10px; background: #f3f7f8; }
.task-picker-course header span { color: #738994; font: 700 10px/1 Consolas, monospace; }
.task-picker-course header b { color: #314f60; font-size: 12px; }
.task-picker-course > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 2px 12px; padding: 8px 10px; }
.task-picker-course :deep(.el-checkbox) { height: auto; margin-right: 0; white-space: normal; }
.task-picker-course :deep(.el-checkbox__label) { display: inline-flex; gap: 6px; min-width: 0; color: #536b78; font-size: 12px; }
.task-picker-course :deep(.el-checkbox__label b) { flex: 0 0 auto; color: #284757; }
.form-help { display: block; margin-top: 7px; color: var(--muted); line-height: 1.5; }
.form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }