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