1535 lines
68 KiB
Vue
1535 lines
68 KiB
Vue
<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>
|