大家好,我是Java1234_小锋老师,分享一套锋哥原创的基于Python的实验室预约管理系统(FastAPI+Vue3)

项目介绍

随着高校教学与科研规模的不断扩大,各类实验室已成为高校开展实践教学、科学研究的重要资源。然而,传统的实验室预约方式仍以人工登记、电话预约或纸质表单为主,普遍存在信息不透明、预约冲突频繁、审批流程繁琐、统计困难等问题,难以满足现代高校对实验室资源高效管理与合理调配的需求。为解决上述问题,本文设计并实现了一个基于 Python 的实验室预约管理系统,采用 FastAPI 与 Vue3 前后端分离架构。

本系统后端采用 Python 语言与高性能异步 Web 框架 FastAPI 进行开发,使用 Pydantic 完成数据校验,通过 SQLAlchemy 操作 MySQL 数据库,并基于 JWT 实现登录认证与权限控制;前端采用 Vue3 框架,结合 Vite 构建工具、Element Plus 组件库、Vue Router 路由、Pinia 状态管理以及 Axios 网络请求,构建了响应式、组件化的用户交互界面。系统按照角色划分为普通用户端与管理员后台,实现了用户注册登录、个人信息管理、实验室浏览查询、在线预约申请、预约审批、我的预约管理、公告管理以及预约统计分析等核心功能。

论文按照软件工程的开发流程,依次完成了系统的需求分析、可行性分析、总体架构设计、功能模块设计、数据库设计以及各功能模块的编码实现与系统测试。测试结果表明,本系统运行稳定、功能完善、界面友好、响应迅速,能够有效规范实验室预约流程,提高实验室资源利用率与管理效率,具有较好的实用价值。

源码下载 

链接: https://pan.baidu.com/s/1Rdq9ttTp_9Hdn4fOZUyYsg?pwd=1234
提取码: 1234

系统展示

核心代码

"""
预约管理路由
提供预约提交、查询、取消和审批接口
"""
from datetime import datetime

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session

from database import get_db
from models.user import User
from models.lab import Lab
from models.time_slot import TimeSlot
from models.reservation import Reservation
from schemas.reservation import ReservationCreate, ReservationReview, ReservationResponse
from schemas.common import ResponseModel, PageResult
from utils.security import get_current_user, get_admin_user

router = APIRouter(prefix="/api/reservation", tags=["预约管理"])


def _build_reservation_response(r: Reservation) -> ReservationResponse:
    """构建预约响应对象,填充关联信息"""
    resp = ReservationResponse.model_validate(r)
    if r.user:
        resp.username = r.user.username
        resp.real_name = r.user.real_name
    if r.lab:
        resp.lab_name = r.lab.name
        resp.lab_no = r.lab.lab_no
    if r.time_slot:
        resp.slot_name = r.time_slot.name
    return resp


@router.post("/create", summary="提交预约")
def create_reservation(
    req: ReservationCreate,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    用户提交实验室预约
    校验实验室状态和时间段冲突
    """
    # 检查实验室是否存在且可用
    lab = db.query(Lab).filter(Lab.id == req.lab_id).first()
    if not lab:
        raise HTTPException(status_code=404, detail="实验室不存在")
    if lab.status != 1:
        raise HTTPException(status_code=400, detail="该实验室正在维护中,无法预约")

    # 检查时间段是否存在
    slot = db.query(TimeSlot).filter(TimeSlot.id == req.slot_id).first()
    if not slot:
        raise HTTPException(status_code=404, detail="时间段不存在")

    # 检查时间冲突(同一实验室、同一日期、同一时间段,待审核或已通过的不允许重复)
    conflict = db.query(Reservation).filter(
        Reservation.lab_id == req.lab_id,
        Reservation.slot_id == req.slot_id,
        Reservation.reserve_date == req.reserve_date,
        Reservation.status.in_(["pending", "approved"])
    ).first()
    if conflict:
        raise HTTPException(status_code=400, detail="该时段已被预约,请选择其他时段")

    reservation = Reservation(
        user_id=current_user.id,
        lab_id=req.lab_id,
        slot_id=req.slot_id,
        reserve_date=req.reserve_date,
        purpose=req.purpose,
        status="pending"
    )
    db.add(reservation)
    db.commit()
    db.refresh(reservation)
    return ResponseModel(message="预约提交成功,等待审核", data=_build_reservation_response(reservation))


@router.get("/my", summary="我的预约")
def my_reservations(
    page: int = Query(1, ge=1),
    size: int = Query(10, ge=1, le=100),
    status: str = Query("", description="状态筛选"),
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """查询当前用户的预约列表"""
    query = db.query(Reservation).filter(Reservation.user_id == current_user.id)
    if status:
        query = query.filter(Reservation.status == status)
    total = query.count()
    items = query.order_by(Reservation.id.desc()).offset((page - 1) * size).limit(size).all()
    return ResponseModel(data=PageResult(
        total=total,
        items=[_build_reservation_response(r) for r in items]
    ))


@router.get("/list", summary="预约列表(管理员)")
def list_reservations(
    page: int = Query(1, ge=1),
    size: int = Query(10, ge=1, le=100),
    status: str = Query("", description="状态筛选"),
    keyword: str = Query("", description="搜索关键词"),
    db: Session = Depends(get_db),
    _: User = Depends(get_admin_user)
):
    """管理员分页查询所有预约"""
    query = db.query(Reservation)
    if status:
        query = query.filter(Reservation.status == status)
    if keyword:
        query = query.join(User).join(Lab).filter(
            (User.real_name.like(f"%{keyword}%")) | (Lab.name.like(f"%{keyword}%"))
        )
    total = query.count()
    items = query.order_by(Reservation.id.desc()).offset((page - 1) * size).limit(size).all()
    return ResponseModel(data=PageResult(
        total=total,
        items=[_build_reservation_response(r) for r in items]
    ))


@router.put("/cancel/{reservation_id}", summary="取消预约")
def cancel_reservation(
    reservation_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """用户取消自己的预约(仅待审核状态可取消)"""
    reservation = db.query(Reservation).filter(
        Reservation.id == reservation_id,
        Reservation.user_id == current_user.id
    ).first()
    if not reservation:
        raise HTTPException(status_code=404, detail="预约不存在")
    if reservation.status != "pending":
        raise HTTPException(status_code=400, detail="只能取消待审核的预约")
    reservation.status = "canceled"
    db.commit()
    return ResponseModel(message="预约已取消")


@router.put("/review/{reservation_id}", summary="审核预约(管理员)")
def review_reservation(
    reservation_id: int,
    req: ReservationReview,
    db: Session = Depends(get_db),
    _: User = Depends(get_admin_user)
):
    """管理员审核预约(通过/驳回)"""
    reservation = db.query(Reservation).filter(Reservation.id == reservation_id).first()
    if not reservation:
        raise HTTPException(status_code=404, detail="预约不存在")
    if reservation.status != "pending":
        raise HTTPException(status_code=400, detail="该预约已处理,无法重复审核")

    if req.status == "approved":
        # 再次检查冲突
        conflict = db.query(Reservation).filter(
            Reservation.lab_id == reservation.lab_id,
            Reservation.slot_id == reservation.slot_id,
            Reservation.reserve_date == reservation.reserve_date,
            Reservation.status == "approved",
            Reservation.id != reservation_id
        ).first()
        if conflict:
            raise HTTPException(status_code=400, detail="该时段已有通过的预约,无法批准")

    reservation.status = req.status
    reservation.reject_reason = req.reject_reason if req.status == "rejected" else None
    reservation.review_time = datetime.now()
    db.commit()
    msg = "审核通过" if req.status == "approved" else "已驳回"
    return ResponseModel(message=msg)


@router.get("/check", summary="检查时段是否可用")
def check_availability(
    lab_id: int = Query(...),
    slot_id: int = Query(...),
    reserve_date: str = Query(...),
    db: Session = Depends(get_db)
):
    """检查指定实验室、日期、时间段是否可预约"""
    conflict = db.query(Reservation).filter(
        Reservation.lab_id == lab_id,
        Reservation.slot_id == slot_id,
        Reservation.reserve_date == reserve_date,
        Reservation.status.in_(["pending", "approved"])
    ).first()
    return ResponseModel(data={"available": conflict is None})
<template>
  <div class="page-container">
    <div class="page-header">
      <h2>实验室管理</h2>
      <p>管理实验室信息和状态</p>
    </div>

    <div class="search-bar">
      <el-input v-model="keyword" placeholder="搜索名称/编号" clearable style="width: 240px" @keyup.enter="loadData" />
      <el-button type="primary" @click="loadData"><el-icon><Search /></el-icon> 搜索</el-button>
      <el-button type="success" @click="openDialog()"><el-icon><Plus /></el-icon> 新增实验室</el-button>
    </div>

    <div class="card-box">
      <el-table :data="labs" stripe>
        <el-table-column label="图片" width="80">
          <template #default="{ row }">
            <el-image v-if="row.image" :src="row.image" :preview-src-list="[row.image]" fit="cover" style="width: 50px; height: 50px; border-radius: 4px" />
            <el-icon v-else :size="24" color="#c0c4cc"><Picture /></el-icon>
          </template>
        </el-table-column>
        <el-table-column prop="lab_no" label="编号" width="100" />
        <el-table-column prop="name" label="名称" />
        <el-table-column prop="category" label="类别" width="100" />
        <el-table-column prop="location" label="位置" />
        <el-table-column prop="capacity" label="容量" width="80" />
        <el-table-column prop="status" label="状态" width="90">
          <template #default="{ row }">
            <el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 1 ? '可用' : '维护中' }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column label="操作" width="160" fixed="right">
          <template #default="{ row }">
            <el-button type="primary" link @click="openDialog(row)">编辑</el-button>
            <el-button type="danger" link @click="handleDelete(row.id)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
      <div class="pagination-wrap">
        <el-pagination v-model:current-page="page" :total="total" layout="total, prev, pager, next" @current-change="loadData" />
      </div>
    </div>

    <el-dialog v-model="dialogVisible" :title="editId ? '编辑实验室' : '新增实验室'" width="560px" destroy-on-close>
      <el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
        <el-form-item label="编号" prop="lab_no"><el-input v-model="form.lab_no" /></el-form-item>
        <el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
        <el-form-item label="类别"><el-input v-model="form.category" /></el-form-item>
        <el-form-item label="位置"><el-input v-model="form.location" /></el-form-item>
        <el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" /></el-form-item>
        <el-form-item label="图片">
          <div class="image-upload">
            <el-upload
              class="lab-uploader"
              :show-file-list="false"
              accept="image/jpeg,image/png,image/gif"
              :http-request="handleUpload"
            >
              <img v-if="form.image" :src="form.image" class="lab-preview" />
              <el-icon v-else class="lab-uploader-icon"><Plus /></el-icon>
            </el-upload>
            <el-button v-if="form.image" type="danger" link @click="form.image = ''">删除图片</el-button>
          </div>
        </el-form-item>
        <el-form-item label="状态">
          <el-radio-group v-model="form.status">
            <el-radio :label="1">可用</el-radio>
            <el-radio :label="0">维护中</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" :loading="saving" @click="handleSave">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
/**
 * 实验室管理页面
 * 管理员对实验室进行增删改查
 */
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { labApi, uploadApi } from '@/api'

const labs = ref([])
const keyword = ref('')
const page = ref(1)
const total = ref(0)
const dialogVisible = ref(false)
const editId = ref(null)
const formRef = ref(null)
const saving = ref(false)

const form = reactive({ lab_no: '', name: '', category: '', location: '', capacity: 30, image: '', description: '', status: 1 })
const rules = {
  lab_no: [{ required: true, message: '请输入编号', trigger: 'blur' }],
  name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
}

async function loadData() {
  const res = await labApi.list({ page: page.value, size: 10, keyword: keyword.value })
  labs.value = res.data.items
  total.value = res.data.total
}

function openDialog(row = null) {
  editId.value = row?.id || null
  Object.assign(form, { lab_no: '', name: '', category: '', location: '', capacity: 30, image: '', description: '', status: 1 })
  if (row) Object.assign(form, { ...row, image: row.image || '' })
  dialogVisible.value = true
}

async function handleUpload({ file }) {
  const formData = new FormData()
  formData.append('file', file)
  const res = await uploadApi.file(formData)
  form.image = res.data.url
  ElMessage.success('上传成功')
}

async function handleSave() {
  await formRef.value.validate()
  saving.value = true
  try {
    if (editId.value) await labApi.update(editId.value, form)
    else await labApi.create(form)
    ElMessage.success('保存成功')
    dialogVisible.value = false
    loadData()
  } finally { saving.value = false }
}

async function handleDelete(id) {
  await ElMessageBox.confirm('确定删除该实验室吗?', '提示', { type: 'warning' })
  await labApi.delete(id)
  ElMessage.success('删除成功')
  loadData()
}

onMounted(loadData)
</script>

<style scoped>
.pagination-wrap { display: flex; justify-content: flex-end; margin-top: 16px; }

.image-upload {
  display: flex;
  align-items: flex-end;
  gap: 12px;
}

.lab-uploader :deep(.el-upload) {
  border: 1px dashed #d9d9d9;
  border-radius: 8px;
  cursor: pointer;
  overflow: hidden;
  transition: border-color 0.3s;
}

.lab-uploader :deep(.el-upload:hover) {
  border-color: #409eff;
}

.lab-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  width: 120px;
  height: 120px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.lab-preview {
  width: 120px;
  height: 120px;
  object-fit: cover;
  display: block;
}
</style>

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐