实验预约

This commit is contained in:
2026-07-28 21:04:18 +08:00 Unverified
parent 865378dbdf
commit ec979f640d
12 changed files with 8750 additions and 0 deletions
+15
View File
@@ -49,6 +49,10 @@ const isStatisticsViewer = computed(() =>
),
)
const canUseExperiments = computed(() =>
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student']),
)
function hasAnyRole(allowedRoles: string[]) {
return roles.value.some((role) => allowedRoles.includes(role))
}
@@ -72,6 +76,17 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
items: [{ path: '/statistics', label: '统计报表' }],
} as NavigationGroup]
: []),
...(canUseExperiments.value
? [{
key: 'experiments',
label: isStudent.value ? '我的实验' : '实验管理',
direct: true,
items: [{
path: '/experiments',
label: isStudent.value ? '我的实验' : '实验管理',
}],
} as NavigationGroup]
: []),
{
key: 'organization',
label: '组织与权限',
+8
View File
@@ -192,6 +192,14 @@ const router = createRouter({
name: 'classroom-reservations',
component: () => import('../views/ClassroomReservationsView.vue'),
},
{
path: 'experiments',
name: 'experiments',
component: () => import('../views/ExperimentsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'],
},
},
{
path: 'course-selections',
name: 'course-selections',
+940
View File
@@ -0,0 +1,940 @@
<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({
teachingTaskId: '',
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 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.teachingTaskId),
)
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, {
teachingTaskId: '',
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, {
teachingTaskId: 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.teachingTaskId || !projectForm.code.trim()
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
ElMessage.warning('请填写教学任务、项目编码、名称和开放日期')
return
}
const payload = {
teachingTaskId: projectForm.teachingTaskId,
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', payload)
ElMessage.success('实验项目已创建')
}
projectDialog.value = false
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" 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="所属教学任务" required>
<el-select
v-model="projectForm.teachingTaskId"
filterable
: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"
/>
</el-select>
</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">保存项目</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="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-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; }
@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; }
}
</style>