Files
Academic-Affairs-System/web/src/views/ExperimentsView.vue
T

1535 lines
68 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, 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 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('')
const correctingPublishedProject = ref(false)
const projectForm = reactive({
teachingTaskIds: [] as string[],
scheduleEntryId: '',
code: '',
name: '',
projectNames: '',
nameMode: 'Sequence',
arrangementMode: 'Centralized',
description: '',
requirements: '',
dates: [] as string[],
})
const batchRenameDialog = ref(false)
const batchRenameProjectIds = ref<string[]>([])
const batchRenameNames = ref('')
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 selectedScheduleEntry = computed(() =>
options.scheduleEntries.find((entry) => entry.id === projectForm.scheduleEntryId),
)
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 batchRenameProjects = computed(() =>
projects.value.filter((project) => batchRenameProjectIds.value.includes(project.id)),
)
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
|| 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 = ''
correctingPublishedProject.value = false
Object.assign(projectForm, {
teachingTaskIds: [],
scheduleEntryId: '',
code: '',
name: '',
projectNames: '',
nameMode: 'Sequence',
arrangementMode: 'Centralized',
description: '',
requirements: '',
dates: [],
})
}
function onTaskChange() {
const task = selectedTask.value
if (!task) return
projectForm.dates = [task.termStartDate, task.termEndDate]
}
function onScheduleEntryChange() {
const entry = selectedScheduleEntry.value
if (!entry) return
projectForm.teachingTaskIds = [entry.teachingTaskId]
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
}
function openEditProject(project: any) {
editingProjectId.value = project.id
correctingPublishedProject.value = false
Object.assign(projectForm, {
teachingTaskIds: [project.teachingTaskId],
scheduleEntryId: project.scheduleEntryId ?? '',
code: project.code,
name: project.name,
projectNames: '',
nameMode: 'Sequence',
arrangementMode: project.arrangementMode,
description: project.description ?? '',
requirements: project.requirements ?? '',
dates: [project.startDate, project.endDate],
})
projectDialog.value = true
}
function openCorrectPublishedProject(project: any) {
openEditProject(project)
correctingPublishedProject.value = true
}
function projectNameLines(value: string) {
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
}
async function saveProject() {
const names = projectNameLines(projectForm.projectNames)
const hasValidName = correctingPublishedProject.value || editingProjectId.value
? !!projectForm.name.trim()
: projectForm.nameMode === 'InputNames'
? names.length > 0
: !!projectForm.name.trim()
if ((!correctingPublishedProject.value && !projectForm.teachingTaskIds.length) ||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
!projectForm.code.trim()
|| !hasValidName || projectForm.dates.length !== 2) {
ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value
? '请选择课表实验课,并填写项目编码、名称和开放日期'
: '请填写教学任务、项目编码、名称和开放日期')
return
}
const payload = {
teachingTaskId: projectForm.teachingTaskIds[0],
scheduleEntryId: projectForm.scheduleEntryId || null,
code: projectForm.code,
name: projectForm.nameMode === 'InputNames' && !editingProjectId.value ? names[0] : projectForm.name,
arrangementMode: projectForm.arrangementMode,
description: projectForm.description || null,
requirements: projectForm.requirements || null,
startDate: projectForm.dates[0],
endDate: projectForm.dates[1],
}
try {
if (correctingPublishedProject.value) {
await http.put(`/experiments/${editingProjectId.value}/published-details`, {
name: projectForm.name,
description: projectForm.description || null,
requirements: projectForm.requirements || null,
})
ElMessage.success('已修正已发布项目的教学内容,并通知学生查看')
} else if (editingProjectId.value) {
await http.put(`/experiments/${editingProjectId.value}`, payload)
ElMessage.success('实验项目已更新')
} else {
await http.post('/experiments/batch', {
...payload,
teachingTaskId: undefined,
teachingTaskIds: projectForm.teachingTaskIds,
names: projectForm.nameMode === 'InputNames' ? names : undefined,
generateSequentialNames: projectForm.nameMode === 'Sequence',
})
ElMessage.success(projectForm.arrangementMode === 'Centralized'
? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目`
: `已为 ${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))
}
}
function openBatchRename() {
batchRenameProjectIds.value = projects.value
.filter((project) => draftSelectedProjectIds.value.includes(project.id))
.map((project) => project.id)
batchRenameNames.value = batchRenameProjects.value.map((project) => project.name).join('\n')
batchRenameDialog.value = true
}
async function saveBatchRename() {
const names = projectNameLines(batchRenameNames.value)
if (names.length !== batchRenameProjectIds.value.length) {
ElMessage.warning(`已选 ${batchRenameProjectIds.value.length} 个项目,请粘贴 ${batchRenameProjectIds.value.length} 行项目名称`)
return
}
try {
await http.put('/experiments/batch/names', { projectIds: batchRenameProjectIds.value, names })
ElMessage.success('实验项目名称已批量更新')
batchRenameDialog.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
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' },
)
const { data } = await http.post('/experiments/batch/publish', { projectIds: ids })
ElMessage.success(`已提交 ${ids.length} 个实验项目的后台发布任务`)
selectedProjectIds.value = []
void pollPublishJob(data.jobId)
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function pollPublishJob(jobId: string) {
try {
const { data } = await http.get(`/experiments/batch/publish-jobs/${jobId}`)
if (data.status === 'Succeeded') {
ElMessage.success('实验项目已完成发布')
await load()
return
}
if (data.status === 'Failed') {
ElMessage.error(data.errorMessage || '实验项目发布失败')
await load()
return
}
window.setTimeout(() => { void pollPublishJob(jobId) }, 1200)
} catch (error) {
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(
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,
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))
}
}
async function load() {
loading.value = true
try {
if (isStudent.value) {
projects.value = (await http.get('/experiments/student', {
params: { academicTermId: termId.value || undefined },
})).data
total.value = projects.value.length
} else {
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,
},
})
projects.value = data.items
total.value = data.total
}
} catch (error) {
projects.value = []
total.value = 0
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
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
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 v-if="!isStudent" 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="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" />
</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">{{ isStudent ? `共 ${projectGroups.length} 门课程、${total} 个实验项目` : `共 ${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" :disabled="!draftSelectedProjectIds.length" @click="openBatchRename">批量改名</el-button>
<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">
<el-table v-if="!isStudent" :data="group.projects" class="project-table" size="small">
<el-table-column width="46" align="center">
<template #default="{ row }">
<el-checkbox
v-if="row.status === 'Draft'"
:model-value="selectedProjectIds.includes(row.id)"
@change="toggleProjectSelection(row.id, $event)"
aria-label="选择实验项目"
/>
</template>
</el-table-column>
<el-table-column label="实验项目" min-width="220">
<template #default="{ row }">
<div class="table-project-name">
<b>{{ row.code }} · {{ row.name }}</b>
<small v-if="row.description">{{ row.description }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="实验课次 / 场次" min-width="280">
<template #default="{ row }">
<div v-if="row.arrangementMode === 'Centralized' && row.scheduleEntry" class="table-schedule">
<b> {{ row.scheduleEntry.projectWeek ?? `${row.scheduleEntry.startWeek}${row.scheduleEntry.endWeek}` }} · 星期 {{ row.scheduleEntry.dayOfWeek }} · {{ row.scheduleEntry.startPeriod }}{{ row.scheduleEntry.startPeriod + row.scheduleEntry.periodCount - 1 }} </b>
<small>{{ row.scheduleEntry.campusName }} · {{ row.scheduleEntry.buildingName }} {{ row.scheduleEntry.classroomName }}</small>
</div>
<div v-else-if="row.sessions.length" class="table-schedule">
<b>{{ row.sessions.length }} 个开放场次</b>
<small>{{ row.sessions.map((session: any) => formatSessionTime(session)).join('') }}</small>
<el-popover placement="bottom" :width="520" trigger="click">
<template #reference><el-button size="small" text :icon="User">查看场次与名单</el-button></template>
<div class="student-session-menu">
<div v-for="session in row.sessions" :key="session.id" class="student-session-option">
<div><b>{{ formatSessionTime(session) }}</b><span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span></div>
<el-button size="small" text @click="showParticipants(row, session)">预约名单</el-button>
<el-button v-if="session.status !== 'Cancelled'" size="small" text type="danger" @click="cancelSession(row, session)">{{ row.status === 'Draft' ? '删除' : '取消' }}</el-button>
</div>
</div>
</el-popover>
</div>
<span v-else class="table-muted">尚未安排</span>
</template>
</el-table-column>
<el-table-column label="方式" width="100" align="center">
<template #default="{ row }"><el-tag :type="row.arrangementMode === 'Centralized' ? 'primary' : 'success'" effect="plain">{{ modeMeta[row.arrangementMode].label }}</el-tag></template>
</el-table-column>
<el-table-column label="状态" width="88" align="center">
<template #default="{ row }"><el-tag :type="row.status === 'Published' ? 'success' : row.status === 'Closed' ? 'info' : 'warning'">{{ statusLabels[row.status] }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="244" fixed="right" align="right">
<template #default="{ row }">
<template v-if="row.status === 'Draft'">
<el-button size="small" text @click="openEditProject(row)">编辑</el-button>
<el-button size="small" text type="danger" @click="deleteProject(row)">删除</el-button>
<el-button
size="small"
type="primary"
:disabled="row.arrangementMode === 'Centralized' ? !row.scheduleEntry : !row.sessions.some((item: any) => item.status !== 'Cancelled')"
@click="publishProject(row)"
>发布</el-button>
</template>
<template v-else-if="row.status === 'Published'">
<el-button size="small" text @click="openCorrectPublishedProject(row)">修正内容</el-button>
<el-button size="small" text @click="closeProject(row)">关闭项目</el-button>
</template>
<el-button
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
size="small"
text
type="primary"
@click="openSession(row)"
>安排场次</el-button>
</template>
</el-table-column>
</el-table>
<el-table v-else :data="group.projects" class="project-table student-project-table" size="small">
<el-table-column type="expand" width="44">
<template #default="{ row }">
<div class="student-project-detail">
<p v-if="row.description"><b>项目说明</b>{{ row.description }}</p>
<p v-if="row.requirements"><b>实验要求</b>{{ row.requirements }}</p>
<p><b>开放日期</b>{{ row.startDate }} {{ row.endDate }}</p>
<p><b>任课教师</b>{{ row.teacherNames.join('、') || '待定' }}</p>
</div>
</template>
</el-table-column>
<el-table-column label="实验项目" min-width="210">
<template #default="{ row }">
<div class="table-project-name">
<b>{{ row.code }} · {{ row.name }}</b>
<small>{{ row.description || modeMeta[row.arrangementMode].note }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="实验安排" min-width="300">
<template #default="{ row }">
<div v-if="row.arrangementMode === 'Centralized' && row.scheduleEntry" class="table-schedule">
<b> {{ row.scheduleEntry.projectWeek ?? `${row.scheduleEntry.startWeek}${row.scheduleEntry.endWeek}` }} · 星期 {{ row.scheduleEntry.dayOfWeek }} · {{ row.scheduleEntry.startPeriod }}{{ row.scheduleEntry.startPeriod + row.scheduleEntry.periodCount - 1 }} </b>
<small>{{ row.scheduleEntry.campusName }} · {{ row.scheduleEntry.buildingName }} {{ row.scheduleEntry.classroomName }}</small>
</div>
<div v-else-if="row.myBooking && selectedSession(row)" class="table-schedule is-booked">
<b>{{ formatSessionTime(selectedSession(row)) }}</b>
<small>{{ selectedSession(row).campusName }} · {{ selectedSession(row).buildingName }} {{ selectedSession(row).classroomName }}</small>
</div>
<div v-else-if="row.sessions.length" class="table-schedule">
<b>{{ row.sessions.length }} 个可选场次</b>
<small>尚未预约请在右侧选择实验时间</small>
</div>
<span v-else class="table-muted">暂无有效实验安排</span>
</template>
</el-table-column>
<el-table-column label="方式" width="100" align="center">
<template #default="{ row }"><el-tag :type="row.arrangementMode === 'Centralized' ? 'primary' : 'success'" effect="plain" size="small">{{ modeMeta[row.arrangementMode].label }}</el-tag></template>
</el-table-column>
<el-table-column label="状态" width="88" align="center">
<template #default="{ row }"><el-tag :type="row.status === 'Published' ? 'success' : 'info'" size="small">{{ statusLabels[row.status] }}</el-tag></template>
</el-table-column>
<el-table-column label="预约操作" width="190" fixed="right" align="right">
<template #default="{ row }">
<template v-if="row.arrangementMode === 'SelfScheduled'">
<el-popover placement="bottom-end" :width="540" trigger="click">
<template #reference>
<el-button size="small" :type="row.myBooking ? 'success' : 'primary'" plain>
{{ row.myBooking ? '查看场次' : '选择场次' }}
</el-button>
</template>
<div class="student-session-menu">
<header><b>{{ row.code }} · {{ row.name }}</b><span>每个实验项目限约一个场次</span></header>
<div v-for="session in row.sessions" :key="session.id" class="student-session-option" :class="{ 'is-selected': row.myBooking?.experimentSessionId === session.id }">
<div>
<b>{{ formatSessionTime(session) }}</b>
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span>
</div>
<el-tag :type="sessionTagType(session)" size="small" effect="plain"> {{ session.remainingCount }} / {{ session.capacity }}</el-tag>
<el-button v-if="row.myBooking?.experimentSessionId === session.id" size="small" type="success" :icon="Check" disabled>已预约</el-button>
<el-button v-else size="small" type="primary" plain :disabled="row.status !== 'Published' || !!row.myBooking || session.remainingCount <= 0" @click="book(row, session)">预约</el-button>
</div>
<el-empty v-if="!row.sessions.length" :image-size="42" description="暂无有效实验场次" />
</div>
</el-popover>
<el-button v-if="row.myBooking && row.status === 'Published'" size="small" text type="danger" @click="cancelBooking(row)">取消</el-button>
</template>
<span v-else class="table-muted">无需预约</span>
</template>
</el-table-column>
</el-table>
</div>
</section>
<el-empty
v-if="!loading && !projects.length"
:description="isStudent ? '当前学期没有已发布的实验项目' : '当前筛选条件下暂无实验项目'"
>
<el-button v-if="!isStudent" type="primary" @click="openCreateProject">
新建第一个实验项目
</el-button>
</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="correctingPublishedProject ? '修正已发布实验项目内容' : editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
width="720px"
top="5vh"
>
<el-form label-position="top" class="experiment-form">
<el-alert
v-if="correctingPublishedProject"
title="已发布项目仅可修正名称、实验内容和到场要求;编码、安排方式、课表绑定及开放日期保持不变。保存后将通知相关学生。"
type="info"
:closable="false"
/>
<div class="form-section">
<header><span>PROJECT</span><b>规定实验项目</b></header>
<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="formatScheduleEntry(entry)"
:value="entry.id"
/>
</el-select>
<small class="form-help">显示当前管理范围内全部已发布已安排实验室的实验课集中实验复用其时间和实验室不会再生成独立实验场次</small>
</el-form-item>
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学班(可多选)'" required>
<el-select
v-if="editingProjectId"
v-model="projectForm.teachingTaskIds"
filterable
disabled
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-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">
<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">
<el-form-item label="项目编码" required>
<el-input v-model="projectForm.code" :disabled="correctingPublishedProject" maxlength="40" placeholder="如 LAB-01" />
</el-form-item>
<el-form-item v-if="editingProjectId" label="项目名称" required>
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
</el-form-item>
</div>
<template v-if="!editingProjectId">
<el-form-item label="项目命名方式" required>
<el-radio-group v-model="projectForm.nameMode">
<el-radio-button value="Sequence">自动生成顺序号</el-radio-button>
<el-radio-button value="InputNames">逐行粘贴名称</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="projectForm.nameMode === 'Sequence'" label="项目名称前缀" required>
<el-input v-model="projectForm.name" maxlength="116" placeholder="如 数据库实验;将生成“数据库实验 01、数据库实验 02”" />
</el-form-item>
<el-form-item v-else label="实验项目名称" required>
<el-input
v-model="projectForm.projectNames"
type="textarea"
:rows="6"
maxlength="120000"
placeholder="每行一个名称,可直接从 Excel 粘贴;系统按实际生成顺序逐行对应"
/>
<small class="form-help">已输入 {{ projectNameLines(projectForm.projectNames).length }} 行有效名称</small>
</el-form-item>
</template>
</div>
<div class="form-section">
<header><span>ROUTE</span><b>选择运行轨道</b></header>
<el-radio-group v-model="projectForm.arrangementMode" class="mode-choice" :disabled="!!editingProjectId || correctingPublishedProject">
<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"
:disabled="correctingPublishedProject"
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">
{{ correctingPublishedProject
? '保存修正'
: editingProjectId
? '保存项目'
: projectForm.teachingTaskIds.length
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
: '创建项目' }}
</el-button>
</template>
</el-dialog>
<el-dialog v-model="batchRenameDialog" title="批量修改实验项目名称" width="680px">
<el-alert
:title="`已选择 ${batchRenameProjects.length} 个草稿项目。请按下方显示顺序,每行填写一个名称。`"
type="info"
:closable="false"
show-icon
/>
<ol class="batch-rename-preview">
<li v-for="project in batchRenameProjects" :key="project.id">{{ project.code }} · {{ project.name }}</li>
</ol>
<el-input v-model="batchRenameNames" type="textarea" :rows="Math.min(12, Math.max(5, batchRenameProjects.length))" maxlength="12000" placeholder="每行一个实验项目名称" />
<template #footer>
<el-button @click="batchRenameDialog = false">取消</el-button>
<el-button type="primary" @click="saveBatchRename">保存名称</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.filter((item: any) => (Number(item.teachingVenueNature) & (2 | 4 | 8 | 16)) !== 0)"
: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.filter((item: any) => (Number(item.teachingVenueNature) & (2 | 4 | 8 | 16)) !== 0)"
: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; }
.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; }
.batch-rename-preview { max-height: 180px; margin: 14px 0; overflow: auto; padding: 10px 12px 10px 32px; border: 1px solid #dce5e9; background: #f8fbfc; color: #526b78; font-size: 12px; line-height: 1.8; }
.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-table { --el-table-border-color: #dce5ea; --el-table-header-bg-color: #f7fafb; --el-table-row-hover-bg-color: #f6fafb; width: 100%; }
.project-table :deep(th.el-table__cell) { padding: 8px 0; color: #617986; font-size: 11px; font-weight: 650; }
.project-table :deep(td.el-table__cell) { padding: 9px 0; vertical-align: top; }
.student-project-table :deep(td.el-table__cell) { vertical-align: middle; }
.table-project-name, .table-schedule { display: grid; gap: 4px; min-width: 0; }
.table-project-name b { color: var(--lab-ink); font-size: 13px; }
.table-project-name small, .table-schedule small { overflow: hidden; color: #667f8d; font-size: 11px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.table-schedule b { color: #365364; font-size: 12px; font-weight: 600; }
.table-schedule.is-booked b { color: var(--lab-teal); }
.table-muted { color: #8b9aa4; font-size: 12px; }
.student-project-detail { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 7px 20px; padding: 11px 18px 13px; background: #f7fafb; }
.student-project-detail p { margin: 0; display: grid; grid-template-columns: 70px 1fr; gap: 8px; color: #5f7682; font-size: 11px; line-height: 1.55; }
.student-project-detail b { color: #304d5d; }
.student-session-menu { display: grid; gap: 7px; }
.student-session-menu > header { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid #dfe7ea; }
.student-session-menu > header b { color: var(--lab-ink); font-size: 12px; }
.student-session-menu > header span { color: #71858e; font-size: 10px; }
.student-session-option { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 9px; padding: 8px 9px; border: 1px solid #e0e7ea; background: #fff; }
.student-session-option.is-selected { border-color: #79afa5; box-shadow: inset 3px 0 0 var(--lab-teal); background: #f3faf8; }
.student-session-option > div { display: grid; gap: 2px; min-width: 0; }
.student-session-option > div b { color: #304d5d; font-size: 11px; }
.student-session-option > div span { overflow: hidden; color: #6a808b; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.project-card {
overflow: hidden;
border: 0;
border-top: 1px solid #dce5ea;
border-left: 4px solid var(--lab-blue);
background: #fff;
box-shadow: none;
}
.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; }
.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; }
.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; }
.student-project-detail { grid-template-columns: 1fr; }
.mode-choice { grid-template-columns: 1fr; }
.intro-actions { flex-wrap: wrap; justify-content: flex-end; }
}
</style>