Files
Academic-Affairs-System/web/src/views/ExperimentsView.vue
T
biss 9d525826ce 支持为同一学期、同一课程的多个教学任务批量创建相同实验项目。
新增批量排课表,可为多个实验项目分别设置日期、节次、实验室和容量。
批量排课采用原子事务:任一项目发生教室、教师、班级或课表冲突,整批不写入。
已发布项目批量新增场次时,继续按原逻辑通知学生。
后端继续执行管理范围、教学任务状态、开放日期和容量校验。
页面在 390px 手机宽度下无横向溢出。
2026-08-01 16:08:03 +08:00

1143 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Calendar, Check, Plus, Refresh, Tickets, User } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import {
academicTermLabel,
academicTermOptionClass,
defaultAcademicTermId,
} from '../utils/academicTerms'
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const loading = ref(false)
const terms = ref<any[]>([])
const termId = ref('')
const projects = ref<any[]>([])
const options = reactive({
tasks: [] as any[],
classrooms: [] as any[],
periods: [] as any[],
})
const modeFilter = ref('')
const statusFilter = ref('')
const projectDialog = ref(false)
const editingProjectId = ref('')
const projectForm = reactive({
teachingTaskIds: [] as string[],
code: '',
name: '',
arrangementMode: 'Centralized',
description: '',
requirements: '',
dates: [] as string[],
})
const sessionDialog = ref(false)
const selectedProject = ref<any>(null)
const sessionForm = reactive({
classroomId: '',
sessionDate: '',
startPeriod: undefined as number | undefined,
periodCount: 2,
capacity: 30,
notes: '',
})
const batchSessionDialog = ref(false)
const batchSessionProjectIds = ref<string[]>([])
const batchSessionRows = ref<any[]>([])
const participantsDialog = ref(false)
const participantSession = ref<any>(null)
const participants = ref<any[]>([])
const participantsLoading = ref(false)
const selectedTask = computed(() =>
options.tasks.find((task) => task.id === projectForm.teachingTaskIds[0]),
)
const batchProjectOptions = computed(() =>
projects.value.filter((project) => project.status !== 'Closed'),
)
const activePeriods = computed(() =>
options.periods.filter((period) =>
!selectedProject.value
|| period.academicTermId === selectedProject.value.academicTermId,
),
)
const modeMeta: Record<string, { label: string; note: string }> = {
Centralized: {
label: '集中安排',
note: '像课程一样统一到场,无需学生预约',
},
SelfScheduled: {
label: '自行安排',
note: '规定实验项目,学生从开放场次中预约',
},
}
const statusLabels: Record<string, string> = {
Draft: '草稿',
Published: '进行中',
Closed: '已关闭',
}
function resetProjectForm() {
editingProjectId.value = ''
Object.assign(projectForm, {
teachingTaskIds: [],
code: '',
name: '',
arrangementMode: 'Centralized',
description: '',
requirements: '',
dates: [],
})
}
function onTaskChange() {
const task = selectedTask.value
if (!task) return
projectForm.dates = [task.termStartDate, task.termEndDate]
}
function openCreateProject() {
resetProjectForm()
projectDialog.value = true
}
function openEditProject(project: any) {
editingProjectId.value = project.id
Object.assign(projectForm, {
teachingTaskIds: [project.teachingTaskId],
code: project.code,
name: project.name,
arrangementMode: project.arrangementMode,
description: project.description ?? '',
requirements: project.requirements ?? '',
dates: [project.startDate, project.endDate],
})
projectDialog.value = true
}
async function saveProject() {
if (!projectForm.teachingTaskIds.length || !projectForm.code.trim()
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
ElMessage.warning('请填写教学任务、项目编码、名称和开放日期')
return
}
const payload = {
teachingTaskId: projectForm.teachingTaskIds[0],
code: projectForm.code,
name: projectForm.name,
arrangementMode: projectForm.arrangementMode,
description: projectForm.description || null,
requirements: projectForm.requirements || null,
startDate: projectForm.dates[0],
endDate: projectForm.dates[1],
}
try {
if (editingProjectId.value) {
await http.put(`/experiments/${editingProjectId.value}`, payload)
ElMessage.success('实验项目已更新')
} else {
await http.post('/experiments/batch', {
...payload,
teachingTaskId: undefined,
teachingTaskIds: projectForm.teachingTaskIds,
})
ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
}
projectDialog.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openBatchSession() {
batchSessionProjectIds.value = []
batchSessionRows.value = []
batchSessionDialog.value = true
}
function syncBatchSessionRows() {
const existing = new Map(batchSessionRows.value.map((row) => [row.projectId, row]))
batchSessionRows.value = batchSessionProjectIds.value.map((projectId) => {
const current = existing.get(projectId)
if (current) return current
const project = projects.value.find((item) => item.id === projectId)
const firstPeriod = options.periods.find((item) =>
item.academicTermId === project?.academicTermId,
)
return {
projectId,
classroomId: '',
sessionDate: project?.startDate ?? '',
startPeriod: firstPeriod?.periodNumber,
periodCount: 2,
capacity: 30,
notes: '',
}
})
}
function applyFirstBatchTime() {
const first = batchSessionRows.value[0]
if (!first) return
batchSessionRows.value.slice(1).forEach((row) => {
row.sessionDate = first.sessionDate
row.startPeriod = first.startPeriod
row.periodCount = first.periodCount
})
ElMessage.success('已套用首行的日期和节次,请分别选择不冲突的实验室')
}
function batchProject(projectId: string) {
return projects.value.find((project) => project.id === projectId)
}
function periodsForProject(projectId: string) {
const project = batchProject(projectId)
return options.periods.filter((period) =>
period.academicTermId === project?.academicTermId,
)
}
async function saveBatchSessions() {
if (!batchSessionRows.value.length) {
ElMessage.warning('请至少选择一个实验项目')
return
}
if (batchSessionRows.value.some((row) =>
!row.classroomId || !row.sessionDate || !row.startPeriod || !row.periodCount,
)) {
ElMessage.warning('请完整填写每个项目的日期、节次、实验室和容量')
return
}
try {
await http.post('/experiments/sessions/batch', {
items: batchSessionRows.value,
})
batchSessionDialog.value = false
ElMessage.success(`已批量安排 ${batchSessionRows.value.length} 个实验场次`)
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openSession(project: any) {
selectedProject.value = project
const firstPeriod = options.periods.find((item) =>
item.academicTermId === project.academicTermId,
)
Object.assign(sessionForm, {
classroomId: '',
sessionDate: project.startDate,
startPeriod: firstPeriod?.periodNumber,
periodCount: 2,
capacity: 30,
notes: '',
})
sessionDialog.value = true
}
async function saveSession() {
if (!selectedProject.value || !sessionForm.classroomId
|| !sessionForm.sessionDate || !sessionForm.startPeriod) {
ElMessage.warning('请选择实验日期、节次和教室')
return
}
try {
await http.post(`/experiments/${selectedProject.value.id}/sessions`, sessionForm)
sessionDialog.value = false
ElMessage.success('实验场次已安排')
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publishProject(project: any) {
try {
await ElMessageBox.confirm(
project.arrangementMode === 'Centralized'
? '发布后,教学任务名单中的学生将直接看到全部集中实验场次。'
: '发布后,教学任务名单中的学生可以立即预约开放场次。',
`发布“${project.name}”`,
{ confirmButtonText: '确认发布', cancelButtonText: '暂不发布', type: 'warning' },
)
await http.post(`/experiments/${project.id}/publish`)
ElMessage.success('实验项目已发布')
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function closeProject(project: any) {
try {
await ElMessageBox.confirm(
'关闭后保留已有安排和预约记录,但学生不能再预约新场次。',
`关闭“${project.name}”`,
{ confirmButtonText: '确认关闭', cancelButtonText: '取消', type: 'warning' },
)
await http.post(`/experiments/${project.id}/close`)
ElMessage.success('实验项目已关闭')
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteProject(project: any) {
try {
await ElMessageBox.confirm(
'草稿项目及其未发布场次将一并删除。',
`删除“${project.name}”`,
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' },
)
await http.delete(`/experiments/${project.id}`)
ElMessage.success('实验项目已删除')
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(
project.status === 'Draft'
? '该未发布场次将直接删除。'
: '该场次将被取消;已有学生预约也会取消并收到通知。',
`${project.status === 'Draft' ? '删除' : '取消'} ${formatSessionTime(session)}`,
{ confirmButtonText: '确认', cancelButtonText: '返回', type: 'warning' },
)
await http.delete(`/experiments/sessions/${session.id}`)
ElMessage.success(project.status === 'Draft' ? '场次已删除' : '场次已取消')
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function showParticipants(project: any, session: any) {
participantSession.value = { ...session, project }
participantsDialog.value = true
participantsLoading.value = true
try {
participants.value = (
await http.get(`/experiments/sessions/${session.id}/participants`)
).data
} catch (error) {
participants.value = []
ElMessage.error(apiErrorMessage(error))
} finally {
participantsLoading.value = false
}
}
async function book(project: any, session: any) {
try {
await ElMessageBox.confirm(
`${formatSessionTime(session)} · ${session.campusName} ${session.buildingName} ${session.classroomName}`,
`预约“${project.name}”`,
{ confirmButtonText: '确认预约', cancelButtonText: '再看看', type: 'info' },
)
await http.post(`/experiments/sessions/${session.id}/book`)
ElMessage.success('实验场次已预约')
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function cancelBooking(project: any) {
if (!project.myBooking) return
try {
await ElMessageBox.confirm(
'取消后名额会立即释放,您可以重新选择该项目的其他场次。',
`取消“${project.name}”预约`,
{ confirmButtonText: '取消预约', cancelButtonText: '保留预约', type: 'warning' },
)
await http.delete(`/experiments/bookings/${project.myBooking.id}`)
ElMessage.success('预约已取消')
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
function formatSessionTime(session: any) {
return `${session.sessionDate} · 第 ${session.startPeriod}${session.startPeriod + session.periodCount - 1} 节`
}
function selectedSession(project: any) {
return project.sessions.find((session: any) =>
session.id === project.myBooking?.experimentSessionId,
)
}
function sessionTagType(session: any) {
const remaining = session.capacity - session.reservedCount
if (remaining <= 0) return 'danger'
if (remaining <= Math.max(2, Math.floor(session.capacity * 0.2))) return 'warning'
return 'success'
}
async function loadOptions() {
if (isStudent.value) return
try {
const { data } = await http.get('/experiments/options', {
params: { academicTermId: termId.value || undefined },
})
options.tasks = data.tasks
options.classrooms = data.classrooms
options.periods = data.periods
} catch (error) {
options.tasks = []
options.classrooms = []
options.periods = []
ElMessage.error(apiErrorMessage(error))
}
}
async function load() {
loading.value = true
try {
if (isStudent.value) {
projects.value = (await http.get('/experiments/student', {
params: { academicTermId: termId.value || undefined },
})).data
} else {
projects.value = (await http.get('/experiments/management', {
params: {
academicTermId: termId.value || undefined,
arrangementMode: modeFilter.value || undefined,
status: statusFilter.value || undefined,
},
})).data
}
} catch (error) {
projects.value = []
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function changeTerm() {
await loadOptions()
await load()
}
onMounted(async () => {
try {
terms.value = (await http.get('/base-data/terms')).data
termId.value = defaultAcademicTermId(terms.value) ?? ''
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
await loadOptions()
await load()
})
</script>
<template>
<div class="page-stack experiment-page">
<section class="page-intro experiment-intro">
<div>
<span class="section-kicker">LABORATORY OPERATIONS</span>
<h2>{{ isStudent ? '我的实验' : '实验管理' }}</h2>
<p v-if="isStudent">查看统一安排的实验课次或为规定实验项目选择适合自己的开放时段</p>
<p v-else>把实验项目分成两条运行轨道集中排入固定课次或开放场次供学生自主预约</p>
</div>
<div class="intro-actions">
<el-button v-if="!isStudent" :icon="Calendar" @click="openBatchSession">
批量排课
</el-button>
<el-button v-if="!isStudent" type="primary" :icon="Plus" @click="openCreateProject">
批量设置实验任务
</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<section class="lab-rail" aria-label="实验安排方式说明">
<article class="rail-lane centralized">
<span class="rail-code">FIXED / 集中</span>
<b>统一课次</b>
<p>面向整个教学任务名单时间和实验室固定像上课一样按安排到场</p>
</article>
<div class="rail-switch" aria-hidden="true"><i /><i /></div>
<article class="rail-lane flexible">
<span class="rail-code">OPEN / 自主</span>
<b>开放预约</b>
<p>实验内容保持一致学生在容量允许的场次中选择时间每个项目限约一次</p>
</article>
</section>
<section class="experiment-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="changeTerm">
<el-option
v-for="term in terms"
:key="term.id"
:label="academicTermLabel(term)"
:value="term.id"
:class="academicTermOptionClass(term)"
/>
</el-select>
<template v-if="!isStudent">
<el-select v-model="modeFilter" clearable placeholder="全部安排方式" @change="load">
<el-option label="集中安排" value="Centralized" />
<el-option label="自行安排" value="SelfScheduled" />
</el-select>
<el-select v-model="statusFilter" clearable placeholder="全部状态" @change="load">
<el-option label="草稿" value="Draft" />
<el-option label="进行中" value="Published" />
<el-option label="已关闭" value="Closed" />
</el-select>
</template>
<span class="result-note"> {{ projects.length }} 个实验项目</span>
</section>
<section v-loading="loading" class="project-list">
<article
v-for="project in projects"
:key="project.id"
class="project-card"
:class="[
project.arrangementMode === 'Centralized' ? 'is-centralized' : 'is-flexible',
`is-${project.status.toLowerCase()}`,
]"
>
<header class="project-head">
<div class="project-identity">
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span>
<h3>{{ project.name }}</h3>
<p>{{ project.courseName }} · {{ project.taskNumber }}</p>
</div>
<div class="project-tags">
<el-tag
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'"
effect="plain"
>
{{ modeMeta[project.arrangementMode].label }}
</el-tag>
<el-tag
:type="project.status === 'Published' ? 'success' : project.status === 'Closed' ? 'info' : 'warning'"
>
{{ statusLabels[project.status] }}
</el-tag>
</div>
</header>
<div class="project-context">
<span><b>开放日期</b>{{ project.startDate }} {{ project.endDate }}</span>
<span><b>任课教师</b>{{ project.teacherNames.join('、') || '待定' }}</span>
<span v-if="!isStudent"><b>面向班级</b>{{ project.classNames.join('、') || '选课学生' }}</span>
</div>
<p v-if="project.description" class="project-description">{{ project.description }}</p>
<div v-if="project.requirements" class="requirement-strip">
<b>实验要求</b>
<span>{{ project.requirements }}</span>
</div>
<div class="session-board">
<div class="session-board-head">
<div>
<span>{{ project.arrangementMode === 'Centralized' ? '统一实验课次' : '可选实验场次' }}</span>
<b>{{ modeMeta[project.arrangementMode].note }}</b>
</div>
<el-button
v-if="!isStudent && project.status !== 'Closed'"
size="small"
:icon="Calendar"
@click="openSession(project)"
>
安排场次
</el-button>
</div>
<div v-if="project.sessions.length" class="session-grid">
<article
v-for="session in project.sessions"
:key="session.id"
class="session-ticket"
:class="{
'is-selected': project.myBooking?.experimentSessionId === session.id,
'is-cancelled': session.status === 'Cancelled',
}"
>
<div class="ticket-date">
<strong>{{ session.sessionDate.slice(8, 10) }}</strong>
<span>{{ session.sessionDate.slice(5, 7) }} </span>
</div>
<div class="ticket-body">
<b> {{ session.startPeriod }}{{ session.startPeriod + session.periodCount - 1 }} </b>
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span>
<small v-if="session.notes">{{ session.notes }}</small>
</div>
<div class="ticket-action">
<template v-if="project.arrangementMode === 'SelfScheduled'">
<el-tag
v-if="session.status !== 'Cancelled'"
:type="sessionTagType(session)"
size="small"
effect="plain"
>
{{ session.capacity - session.reservedCount }} / {{ session.capacity }}
</el-tag>
<el-tag v-else type="info" size="small">已取消</el-tag>
</template>
<el-tag v-else type="primary" size="small" effect="plain">
统一到场
</el-tag>
<template v-if="isStudent && project.arrangementMode === 'SelfScheduled'">
<el-button
v-if="project.myBooking?.experimentSessionId === session.id"
size="small"
type="success"
:icon="Check"
disabled
>
已预约
</el-button>
<el-button
v-else
size="small"
type="primary"
plain
:disabled="project.status !== 'Published' || !!project.myBooking || session.remainingCount <= 0"
@click="book(project, session)"
>
预约此场
</el-button>
</template>
<template v-if="!isStudent">
<el-button
size="small"
text
:icon="User"
@click="showParticipants(project, session)"
>
{{ project.arrangementMode === 'Centralized' ? '应到名单' : '预约名单' }}
</el-button>
<el-button
v-if="session.status !== 'Cancelled'"
size="small"
text
type="danger"
@click="cancelSession(project, session)"
>
{{ project.status === 'Draft' ? '删除' : '取消' }}
</el-button>
</template>
</div>
</article>
</div>
<el-empty
v-else
:image-size="58"
:description="project.status === 'Draft' ? '尚未安排场次,安排后才能发布' : '暂无有效实验场次'"
/>
</div>
<footer v-if="!isStudent" class="project-actions">
<template v-if="project.status === 'Draft'">
<el-button size="small" @click="openEditProject(project)">编辑项目</el-button>
<el-button size="small" type="danger" plain @click="deleteProject(project)">删除</el-button>
<el-button
size="small"
type="primary"
:disabled="!project.sessions.some((item: any) => item.status !== 'Cancelled')"
@click="publishProject(project)"
>
发布给学生
</el-button>
</template>
<el-button
v-else-if="project.status === 'Published'"
size="small"
@click="closeProject(project)"
>
关闭项目
</el-button>
</footer>
<footer
v-else-if="project.arrangementMode === 'SelfScheduled' && project.myBooking"
class="student-booking-summary"
>
<div>
<Tickets />
<span>
<b>当前预约</b>
{{ formatSessionTime(selectedSession(project)) }}
</span>
</div>
<el-button
v-if="project.status === 'Published'"
size="small"
type="danger"
plain
@click="cancelBooking(project)"
>
取消并重选
</el-button>
</footer>
</article>
<el-empty
v-if="!loading && !projects.length"
:description="isStudent ? '当前学期没有已发布的实验项目' : '当前筛选条件下暂无实验项目'"
>
<el-button v-if="!isStudent" type="primary" @click="openCreateProject">
新建第一个实验项目
</el-button>
</el-empty>
</section>
<el-dialog
v-model="projectDialog"
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
width="720px"
top="5vh"
>
<el-form label-position="top" class="experiment-form">
<div class="form-section">
<header><span>PROJECT</span><b>规定实验项目</b></header>
<el-form-item :label="editingProjectId ? '所属教学任务' : '适用教学任务(可多选)'" required>
<el-select
v-model="projectForm.teachingTaskIds"
filterable
multiple
collapse-tags
collapse-tags-tooltip
:disabled="!!editingProjectId"
placeholder="选择同一学期、同一课程的已发布教学任务"
@change="onTaskChange"
>
<el-option
v-for="task in options.tasks"
: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>
<small v-if="!editingProjectId" class="form-help">
已选 {{ projectForm.teachingTaskIds.length }} 实验编码名称内容和开放日期将一次应用到这些教学任务
</small>
</el-form-item>
<div class="form-grid two">
<el-form-item label="项目编码" required>
<el-input v-model="projectForm.code" maxlength="40" placeholder="如 LAB-01" />
</el-form-item>
<el-form-item label="项目名称" required>
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
</el-form-item>
</div>
</div>
<div class="form-section">
<header><span>ROUTE</span><b>选择运行轨道</b></header>
<el-radio-group v-model="projectForm.arrangementMode" class="mode-choice">
<el-radio-button value="Centralized">
<b>集中安排</b><small>统一时间像上课一样到场</small>
</el-radio-button>
<el-radio-button value="SelfScheduled">
<b>自行安排</b><small>开放多个场次学生自主预约</small>
</el-radio-button>
</el-radio-group>
<el-form-item label="项目开放日期" required>
<el-date-picker
v-model="projectForm.dates"
type="daterange"
value-format="YYYY-MM-DD"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
</div>
<div class="form-section">
<header><span>BRIEF</span><b>说明学生要完成什么</b></header>
<el-form-item label="实验内容">
<el-input
v-model="projectForm.description"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="说明实验目标、任务和成果"
/>
</el-form-item>
<el-form-item label="到场与准备要求">
<el-input
v-model="projectForm.requirements"
type="textarea"
:rows="2"
maxlength="1000"
show-word-limit
placeholder="如携带材料、安装软件、分组要求"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="projectDialog = false">取消</el-button>
<el-button type="primary" @click="saveProject">
{{ editingProjectId
? '保存项目'
: projectForm.teachingTaskIds.length
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
: '创建项目' }}
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="sessionDialog"
:title="`安排场次 · ${selectedProject?.name ?? ''}`"
width="650px"
>
<el-form label-position="top" class="experiment-form">
<el-alert
:title="selectedProject?.arrangementMode === 'Centralized'
? '系统会检查实验室、任课教师和行政班的课表冲突。'
: '系统会检查实验室占用;学生预约时再检查个人课表和其他实验。'"
type="info"
:closable="false"
show-icon
/>
<div class="form-grid two">
<el-form-item label="实验日期" required>
<el-date-picker
v-model="sessionForm.sessionDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
/>
</el-form-item>
<el-form-item label="起始节次" required>
<el-select v-model="sessionForm.startPeriod" placeholder="选择节次">
<el-option
v-for="period in activePeriods"
:key="period.periodNumber"
:label="period.startsAt
? `${period.name} · ${period.startsAt}${period.endsAt}`
: period.name"
:value="period.periodNumber"
/>
</el-select>
</el-form-item>
<el-form-item label="连续节数" required>
<el-input-number v-model="sessionForm.periodCount" :min="1" :max="12" />
</el-form-item>
<el-form-item label="场次容量" required>
<el-input-number v-model="sessionForm.capacity" :min="1" :max="10000" />
</el-form-item>
</div>
<el-form-item label="实验室" required>
<el-select v-model="sessionForm.classroomId" filterable placeholder="选择实验室或教学场所">
<el-option
v-for="room in options.classrooms"
:key="room.id"
:label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`"
:value="room.id"
/>
</el-select>
</el-form-item>
<el-form-item label="场次备注">
<el-input
v-model="sessionForm.notes"
maxlength="500"
placeholder="如分组、设备或材料说明"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sessionDialog = false">取消</el-button>
<el-button type="primary" @click="saveSession">保存场次</el-button>
</template>
</el-dialog>
<el-dialog
v-model="batchSessionDialog"
title="批量安排实验场次"
width="min(1180px, 94vw)"
top="4vh"
>
<el-alert
title="一次提交整批排课;系统会逐条检查管理范围、开放日期、实验室、课表、教师和班级冲突,任何一条失败都不会写入本批次。"
type="info"
:closable="false"
show-icon
/>
<el-form label-position="top" class="batch-session-form">
<el-form-item label="选择实验项目" required>
<el-select
v-model="batchSessionProjectIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="选择需要一起排课的实验项目"
@change="syncBatchSessionRows"
>
<el-option
v-for="project in batchProjectOptions"
:key="project.id"
:label="`${project.courseCode} · ${project.name} · ${project.taskNumber}`"
:value="project.id"
/>
</el-select>
</el-form-item>
<div v-if="batchSessionRows.length" class="batch-session-tools">
<span> {{ batchSessionRows.length }} 条排课</span>
<el-button size="small" @click="applyFirstBatchTime">套用首行日期与节次</el-button>
</div>
<div class="batch-session-table">
<article v-for="(row, index) in batchSessionRows" :key="row.projectId" class="batch-session-row">
<div class="batch-project-cell">
<span>{{ index + 1 }}</span>
<div>
<b>{{ batchProject(row.projectId)?.name }}</b>
<small>{{ batchProject(row.projectId)?.taskNumber }} · {{ batchProject(row.projectId)?.classNames.join('、') || '选课学生' }}</small>
</div>
</div>
<el-date-picker
v-model="row.sessionDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="实验日期"
/>
<el-select v-model="row.startPeriod" placeholder="起始节次">
<el-option
v-for="period in periodsForProject(row.projectId)"
:key="period.periodNumber"
:label="period.name"
:value="period.periodNumber"
/>
</el-select>
<el-input-number v-model="row.periodCount" :min="1" :max="12" controls-position="right" />
<el-select v-model="row.classroomId" filterable placeholder="实验室">
<el-option
v-for="room in options.classrooms"
:key="room.id"
:label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`"
:value="room.id"
/>
</el-select>
<el-input-number v-model="row.capacity" :min="1" :max="10000" controls-position="right" />
</article>
</div>
<el-empty v-if="!batchSessionRows.length" :image-size="60" description="选择实验项目后,在同一张表中完成排课" />
</el-form>
<template #footer>
<el-button @click="batchSessionDialog = false">取消</el-button>
<el-button type="primary" :disabled="!batchSessionRows.length" @click="saveBatchSessions">
提交整批排课
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="participantsDialog"
:title="`${participantSession?.project?.arrangementMode === 'Centralized' ? '应到名单' : '预约名单'} · ${participantSession ? formatSessionTime(participantSession) : ''}`"
width="680px"
>
<el-table v-loading="participantsLoading" :data="participants" max-height="480">
<el-table-column prop="studentNumber" label="学号" min-width="130" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="className" label="行政班" min-width="160" />
<el-table-column label="参与方式" width="110">
<template #default="{ row }">
<el-tag
:type="row.participationType === 'Centralized' ? 'primary' : 'success'"
size="small"
effect="plain"
>
{{ row.participationType === 'Centralized' ? '统一安排' : '学生预约' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!participantsLoading && !participants.length" description="当前没有学生名单" />
</el-dialog>
</div>
</template>
<style scoped>
.experiment-page { --lab-blue: #245d82; --lab-teal: #168276; --lab-ink: #173041; }
.experiment-intro { align-items: flex-start; }
.intro-actions { display: flex; gap: 8px; align-items: center; }
.lab-rail {
display: grid;
grid-template-columns: minmax(0, 1fr) 78px minmax(0, 1fr);
gap: 0;
margin-bottom: 16px;
border: 1px solid #ccdce5;
background: #f7fafc;
}
.rail-lane { position: relative; padding: 17px 20px 18px 24px; overflow: hidden; }
.rail-lane::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 5px;
background: var(--lab-blue);
}
.rail-lane.flexible::before { background: var(--lab-teal); }
.rail-lane b { display: block; margin: 4px 0; color: var(--lab-ink); font-size: 15px; }
.rail-lane p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
.rail-code { color: #607987; font: 700 10px/1.2 Consolas, monospace; letter-spacing: .08em; }
.rail-switch {
position: relative;
display: grid;
place-content: center;
gap: 8px;
background: linear-gradient(90deg, transparent 49%, #b9cbd5 49%, #b9cbd5 51%, transparent 51%);
}
.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; }
.result-note { margin-left: auto; color: var(--muted); font-size: 12px; }
.project-list { display: grid; gap: 14px; min-height: 160px; }
.project-card {
overflow: hidden;
border: 1px solid #dce5ea;
border-top: 4px solid var(--lab-blue);
background: #fff;
box-shadow: 0 7px 22px rgb(35 67 85 / 5%);
}
.project-card.is-flexible { border-top-color: var(--lab-teal); }
.project-card.is-closed { opacity: .82; }
.project-head { display: flex; justify-content: space-between; gap: 16px; padding: 18px 20px 10px; }
.project-code { color: #667f8d; font: 700 10px/1.2 Consolas, monospace; letter-spacing: .06em; text-transform: uppercase; }
.project-identity h3 { margin: 5px 0 4px; color: var(--lab-ink); font-size: 18px; }
.project-identity p { margin: 0; color: var(--muted); font-size: 12px; }
.project-tags { display: flex; gap: 6px; align-items: flex-start; flex-shrink: 0; }
.project-context { display: flex; flex-wrap: wrap; gap: 7px 22px; padding: 0 20px 12px; color: #5c7481; font-size: 12px; }
.project-context span { display: inline-flex; gap: 7px; }
.project-context b { color: #304b5a; font-weight: 600; }
.project-description { margin: 0; padding: 10px 20px; border-top: 1px solid #edf1f3; color: #415b69; font-size: 13px; line-height: 1.7; }
.requirement-strip {
display: grid;
grid-template-columns: 74px 1fr;
gap: 10px;
margin: 0 20px 14px;
padding: 10px 12px;
border-left: 3px solid #e3a13c;
background: #fff9ef;
color: #6d5b3e;
font-size: 12px;
line-height: 1.6;
}
.session-board { margin: 0 20px 16px; border: 1px solid #dfe8ec; background: #f8fafb; }
.session-board-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 13px; border-bottom: 1px solid #dfe8ec; }
.session-board-head div { display: grid; gap: 2px; }
.session-board-head span { color: #617986; font: 700 10px/1.2 Consolas, monospace; letter-spacing: .06em; text-transform: uppercase; }
.session-board-head b { color: #304d5d; font-size: 11px; font-weight: 500; }
.session-grid { display: grid; gap: 8px; padding: 10px; }
.session-ticket {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-height: 74px;
border: 1px solid #dce5e9;
background: #fff;
transition: border-color .18s ease, box-shadow .18s ease;
}
.session-ticket:hover { border-color: #9fb7c4; box-shadow: 0 5px 14px rgb(40 76 96 / 7%); }
.session-ticket.is-selected { border-color: var(--lab-teal); box-shadow: inset 4px 0 0 var(--lab-teal); }
.session-ticket.is-cancelled { opacity: .58; }
.ticket-date { display: grid; place-content: center; align-self: stretch; border-right: 1px dashed #cddbe2; background: #f0f5f7; text-align: center; }
.ticket-date strong { color: var(--lab-ink); font: 700 22px/1 Consolas, monospace; }
.ticket-date span { margin-top: 4px; color: #657d89; font-size: 10px; }
.ticket-body { display: grid; gap: 3px; min-width: 0; }
.ticket-body b { color: #243f4f; font-size: 13px; }
.ticket-body span { color: #5c7481; font-size: 12px; }
.ticket-body small { overflow: hidden; color: #81929b; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.ticket-action { display: flex; align-items: center; justify-content: flex-end; gap: 5px; padding-right: 10px; }
.project-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid #e5ecef; background: #fbfcfd; }
.student-booking-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid #cce3de;
background: #f0faf7;
}
.student-booking-summary > div { display: flex; align-items: center; gap: 10px; color: var(--lab-teal); }
.student-booking-summary svg { width: 22px; }
.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; }
.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; }
.form-section > header span { color: var(--lab-teal); font: 700 10px/1 Consolas, monospace; letter-spacing: .08em; }
.form-section > header b { color: var(--lab-ink); font-size: 14px; }
.mode-choice { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 16px; }
.mode-choice :deep(.el-radio-button__inner) { display: grid; gap: 5px; width: 100%; padding: 13px; }
.mode-choice b { font-size: 13px; }
.mode-choice small { font-size: 10px; font-weight: 400; }
.batch-session-form { display: grid; gap: 12px; margin-top: 16px; }
.batch-session-tools { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 12px; }
.batch-session-table { display: grid; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.batch-session-row {
display: grid;
grid-template-columns: minmax(230px, 1.4fr) 150px 120px 110px minmax(230px, 1.3fr) 110px;
gap: 8px;
align-items: center;
min-width: 1000px;
padding: 10px;
border: 1px solid #dce5e9;
background: #f9fbfc;
}
.batch-project-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
.batch-project-cell > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; background: #e5f0f5; color: var(--lab-blue); font: 700 11px/1 Consolas, monospace; }
.batch-project-cell > div { display: grid; gap: 3px; min-width: 0; }
.batch-project-cell b, .batch-project-cell small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.batch-project-cell b { color: var(--lab-ink); font-size: 12px; }
.batch-project-cell small { color: var(--muted); font-size: 10px; }
@media (prefers-reduced-motion: reduce) {
.session-ticket { transition: none; }
}
@media (max-width: 760px) {
.lab-rail { grid-template-columns: 1fr; }
.rail-switch { display: none; }
.rail-lane { border-bottom: 1px solid #d5e1e7; }
.experiment-toolbar .el-select { width: 100%; }
.result-note { width: 100%; margin-left: 0; }
.project-head { align-items: flex-start; padding: 15px 14px 9px; }
.project-context, .project-description { padding-inline: 14px; }
.requirement-strip, .session-board { margin-inline: 14px; }
.session-ticket { grid-template-columns: 54px minmax(0, 1fr); }
.ticket-action { grid-column: 1 / -1; justify-content: flex-start; padding: 0 10px 10px; }
.project-actions, .student-booking-summary { padding-inline: 14px; }
.mode-choice { grid-template-columns: 1fr; }
.intro-actions { flex-wrap: wrap; justify-content: flex-end; }
}
</style>