Vue 3 + TS + Element Plus 实战:如何从零搭建企业级违章记录管理 SaaS 前端

特种作业现场管不好,事故就来敲门。本文完整复盘一个真实的工业安全场景 SaaS 前端项目,涵盖多级联动、复杂表单校验、文件上传与 Mock 机制,手把手带你从项目结构设计到生产对接。

一、业务背景与痛点

工业场景下的特种作业(高处作业、动火作业、受限空间作业等),如果管理松散,轻则罚款整改,重则人员伤亡。传统的管理方式靠纸质作业票,靠人工登记违章记录,查找困难、追溯更难。

这个项目解决的核心问题是:把"谁在什么时间、什么地点、干了什么违章的事、罚了多少钱"这件事,用系统管起来。

目标用户是一线的安全管理人员------他们不需要懂技术,只需要:

  • 能快速录一条违章记录
  • 能按时间、人员、违章类型等多个维度查询历史记录
  • 能看到完整的处罚过程和附件证据

二、技术选型与项目结构

2.1 核心技术栈

技术 版本 用途
Vue 3 ^3.4.0 核心框架,Composition API
TypeScript ^5.3.0 类型安全
Element Plus ^2.5.0 UI 组件库
Vue Router ^4.2.0 路由管理,动态导入
Pinia ^2.1.0 状态管理(本项目暂未深度使用)
Axios ^1.6.0 HTTP 请求
Vite ^5.0.0 构建工具
SCSS ^1.69.0 样式预处理

2.2 项目结构

code复制

bash 复制代码
src/
├── api/           # 所有接口调用,统一管理
│   └── violation.ts
├── directives/    # 自定义指令(如 v-permission 权限控制)
├── layouts/       # 布局组件
├── router/        # 路由配置(路由懒加载)
├── types/         # TypeScript 类型定义
│   └── violation.ts
├── utils/         # 工具函数(axios 封装、通用函数)
│   └── request.ts
├── views/         # 页面视图
│   └── violation/
│       ├── ViolationList.vue       # 列表页
│       ├── ViolationAdd.vue        # 新增页
│       └── components/
│           ├── ViolationDetailDialog.vue   # 详情弹窗
│           └── SpecialWorkDialog.vue       # 作业票选择弹窗
├── App.vue
└── main.ts

设计原则:

  • 一个功能一个文件夹,按模块划分,不把全部页面堆在一起
  • 类型定义和接口调用严格分开,修改接口不影响组件
  • 组件用 <script setup lang="ts"> 语法,代码量减少约 30%

三、三大核心模块设计与实现

3.1 模块一:违章记录列表页(多条件搜索 + 分页)

列表页是安全管理人员每天都会打开的页面,核心诉求是:快、准、稳。

搜索表单设计

一个违章记录有 9 个可筛选项:违章名称、项目名称、承包商、违章作业类型、违章地点、违章人员、违章行为描述、处罚措施、时间范围。

vue复制

ini 复制代码
<el-form :model="searchForm" ref="searchFormRef" class="search-form">
  <el-row :gutter="20">
    <el-col :span="6">
      <el-form-item label="违章名称">
        <el-input v-model="searchForm.violationName" placeholder="请输入" clearable />
      </el-form-item>
    </el-col>
    <el-col :span="6">
      <el-form-item label="违章作业">
        <el-select v-model="searchForm.violationWork" placeholder="不限" clearable>
          <el-option label="高处作业" value="高处作业" />
          <el-option label="动火作业" value="动火作业" />
          <el-option label="受限空间作业" value="受限空间作业" />
        </el-select>
      </el-form-item>
    </el-col>
    <!-- 时间范围单独一行 -->
    <el-col :span="6">
      <el-form-item label="违章时间">
        <el-date-picker
          v-model="searchForm.timeRange"
          type="daterange"
          range-separator="---"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          value-format="YYYY-MM-DD"
        />
      </el-form-item>
    </el-col>
  </el-row>
</el-form>

搜索与重置逻辑

typescript复制

ini 复制代码
const fetchList = async () => {
  loading.value = true
  try {
    const params: ViolationListParams = {
      pageNum: pagination.pageNum,
      pageSize: pagination.pageSize,
      violationName: searchForm.violationName || undefined,
      violationWork: searchForm.violationWork || undefined,
      startTime: searchForm.timeRange?.[0],
      endTime: searchForm.timeRange?.[1]
      // 其他筛选项同理...
    }
    const res = await getViolationList(params)
    tableData.value = res.list
    pagination.total = res.total
  } finally {
    loading.value = false
  }
}

// 搜索:回到第1页再查
const handleSearch = () => {
  pagination.pageNum = 1
  fetchList()
}

// 重置:清空表单 + 回到第1页
const handleReset = () => {
  searchFormRef.value?.resetFields()
  pagination.pageNum = 1
  fetchList()
}

经验: 搜索和重置都要把 pageNum 重置为 1,这是最容易遗漏的细节------用户搜完忘记清空,结果看到空列表,还以为是系统 Bug。

3.2 模块二:新增违章页(级联选择 + 复合处罚 + 文件上传)

这是复杂度最高的页面,涉及多个联动逻辑和表单校验。

承包商 → 项目级联选择

承包商和项目是父子级联关系:选了承包商,才会出现对应的项目列表;换承包商,项目和作业人员都要清空。

typescript复制

ini 复制代码
const handleContractorChange = async (val: string) => {
  // 切换承包商时,重置下游字段
  form.projectId = ''
  form.projectName = ''

  if (val) {
    // 根据新承包商ID加载项目列表
    projectOptions.value = await getProjectOptions(val)
    // 记录承包商名称(用于后续提交)
    const contractor = contractorOptions.value.find((item: any) => item.id === val)
    form.contractorName = contractor?.name || ''
  }
}

级联选择的 UI 用的是 Element Plus 原生 el-select,不依赖第三方级联组件,保持了极低的依赖复杂度。

违章名称实时防抖校验

违章名称要求全局唯一(同一个项目下不能有重名违章)。用防抖 + 异步 API 来做实时校验:

typescript复制

typescript 复制代码
let nameCheckTimer: ReturnType<typeof setTimeout> | null = null

function validateViolationName(_rule: any, value: string, callback: any) {
  if (!value) { callback(); return }

  // 防抖:500ms 内重复输入只发一次请求
  if (nameCheckTimer) clearTimeout(nameCheckTimer)

  nameCheckTimer = setTimeout(async () => {
    try {
      const res = await checkViolationNameUnique(value)
      if (!res.unique) {
        callback(new Error('该违章名称已存在,请使用其他名称'))
      } else {
        callback()
      }
    } catch {
      callback(new Error('校验失败,请重试'))
    }
  }, 500)
}

复合处罚措施设计

处罚方式有 5 种,且可以叠加------既罚款又停工整改是常见场景。设计成复选框 + 条件显示输入框:

vue复制

ini 复制代码
<el-checkbox v-model="form.punishmentWarning">警告</el-checkbox>

<el-checkbox v-model="form.punishmentFine">
  罚款
  <el-input-number
    v-if="form.punishmentFine"
    v-model="form.punishmentFineAmount"
    :precision="1"
    :min="0"
    controls-position="right"
    style="width: 100px"
  />
</el-checkbox>

提交时,用 buildPunishmentMeasure() 函数把多个处罚项合并成一个展示字符串:

typescript复制

kotlin 复制代码
function buildPunishmentMeasure(data: ViolationForm): string {
  const measures: string[] = []
  if (data.punishmentWarning) measures.push('警告')
  if (data.punishmentFine && data.punishmentFineAmount)
    measures.push(`罚款${data.punishmentFineAmount}元`)
  if (data.punishmentDeduct && data.punishmentDeductScore)
    measures.push(`扣分${data.punishmentDeductScore}分`)
  if (data.punishmentStop) measures.push('停工整改')
  if (data.punishmentOther && data.punishmentOtherContent)
    measures.push(data.punishmentOtherContent)
  return measures.join('、') || '-'
}

文件上传触发表单验证

文件上传有点特殊------它是手动点按钮触发 on-change 回调上传,上传成功后才把 URL 填入表单。由于 el-form-itemprop 绑定的是 form.violationPhotos 数组,所以上传成功后要主动触发一次校验:

typescript复制

javascript 复制代码
const handleFileChange = async (file: UploadFile) => {
  if (file.raw) {
    try {
      const res = await uploadViolationPhoto(file.raw)
      form.violishmentPhotos.push(res.url)
      // 关键:上传成功后手动触发表单校验
      formRef.value?.validateField('violationPhotos')
    } catch {
      ElMessage.error('上传失败')
    }
  }
}

3.3 模块三:特殊作业选择弹窗

违章记录必须关联到一张具体的特殊作业票(高处作业票、动火作业票等),不能凭空创建。这个弹窗的设计要点是:

  • 单行选中 :表格配置 highlight-current-row,点击一行高亮选中
  • 多条件筛选:作业类型、编号、内容、报备人、状态、日期范围等 7 个筛选项
  • 状态标签 :用 <el-tag> 展示审核状态,不同状态配不同颜色

vue复制

sql 复制代码
<el-table
  highlight-current-row
  @current-change="handleCurrentChange"
>
  <el-table-column prop="status" label="状态">
    <template #default="{ row }">
      <el-tag :type="getStatusType(row.status)">{{ row.status }}</el-tag>
    </template>
  </el-table-column>
</el-table>

typescript复制

lua 复制代码
const getStatusType = (status: string) => ({
  '待审核': 'info',
  '审核通过': 'success',
  '验收中': 'warning',
  '验收完成': 'success'
}[status] || 'info')

四、TypeScript 类型体系设计

类型定义是本项目的亮点之一。违章记录涉及 3 种核心类型,边界清晰:

typescript复制

typescript 复制代码
// 列表页展示项(轻量,只含展示字段)
export interface ViolationRecord {
  id: string
  violationName: string
  projectName: string
  violationWork: string
  contractorName: string
  violationLocation: string
  violationPerson: string
  punishmentMeasure: string   // 处罚措施(合并后的展示文本)
  violationTime: string
}

// 详情页(包含所有字段,含数组和布尔值)
export interface ViolationDetail extends ViolationRecord {
  projectId: string
  violationWorkId: string
  violationPersonIds: string[]   // 列表是逗号拼接字符串,详情是数组
  violationRule: string
  possibleConsequence: string
  punishmentResult: string
  recorderName: string
  violationPhotos: string[]      // 图片URL列表
  // 处罚明细(布尔 + 数值分离)
  punishmentWarning: boolean
  punishmentFine: boolean
  punishmentFineAmount: number
  // ...
}

// 新增表单(不含 id,不含人员名称,只有 ID 和布尔值)
export interface ViolationForm {
  contractorId: string
  projectId: string
  violationPersonIds: string[]
  punishmentWarning: boolean
  punishmentFine: boolean
  punishmentFineAmount?: number
  // ...
}

设计思路: 列表 → 详情 → 表单,三种类型职责单一,避免一个类型塞入过多字段导致冗余。

五、Mock 数据与真实接口的平滑过渡

开发阶段没有后端接口,所有接口用 Promise + setTimeout 模拟。关键设计:Mock 逻辑和真实接口的调用方式完全一致,切换时只需要注释掉 Mock 行、取消注释真实请求行,不需要改任何调用方代码。

typescript复制

javascript 复制代码
export function getViolationList(params: ViolationListParams) {
  // ✅ 对接时:删除下面3行,取消注释上面2行即可
  // return request.get('/api/violation/list', { params })

  // Mock 实现
  return new Promise((resolve) => {
    setTimeout(() => {
      // ...过滤、分页逻辑
      resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
    }, 300)
  })
}

接口代理配置在 vite.config.ts 中,开发环境走 /api 前缀:

typescript复制

javascript 复制代码
proxy: {
  '/api': {
    target: 'http://localhost:8080',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^/api/, '')
  }
}

六、权限指令封装

系统中不同角色的用户能看到的功能不同。通过自定义 v-permission 指令控制按钮级别的权限:

vue复制

bash 复制代码
<el-button v-permission="'violation:add'" type="primary" @click="handleAdd">
  新增
</el-button>

权限列表从 localStorage.permissions 中读取,指令实现的核心逻辑是:根据指令值查找权限列表中是否存在,不存在则隐藏元素。

七、总结与改进方向

已实现

  • ✅ 多条件搜索表单(9个筛选项 + 时间范围)
  • ✅ 承包商 ↔ 项目级联选择,联动清空逻辑
  • ✅ 违章作业票弹窗选择(7个筛选项)
  • ✅ 复合处罚措施(多选 + 条件输入框)
  • ✅ 文件上传与表单验证联动
  • ✅ 违章名称防抖唯一性校验
  • ✅ 完整 TypeScript 类型体系
  • ✅ Mock → 真实接口平滑过渡
  • ✅ 权限指令封装

改进方向

  • 状态管理:引入 Pinia,把表单状态和搜索状态集中管理,支持页面刷新后恢复
  • 编辑功能:当前只有新增和查看,编辑功能可以复用新增页面的表单结构
  • 导出功能:列表页加导出 Excel,支持把查询结果导出存档
  • 批量操作:支持批量选择记录后批量处理(如批量标记已整改)
  • 消息通知:违章记录新增后,通过 WebSocket 推送给相关安全管理人员

项目地址violation-record-system

如果你觉得这篇文章有帮助,欢迎点赞、收藏!有任何问题欢迎评论区交流 👇

相关推荐
minimoon_jojo1 小时前
Ant Design 树形表格渲染原理
前端
海天鹰1 小时前
屏幕颜色检测
javascript·html5
paopaokaka_luck1 小时前
基于springboot3+vue3的音乐推荐系统(协同过滤算法、Echarts图形化分析)
前端·echarts
用户7917286761972 小时前
opencode-plugin-peers完全指南:OpenCode如何实现 Claude Code式跨会话消息
javascript
八号当铺2 小时前
使用 Figma Agent Kit:插件 + MCP + 还原 Skill,打通本地设计协作
前端·人工智能·ai编程
一心只读圣贤书2 小时前
AI 辅助前端国际化实践:从文案梳理到多语言资源治理
前端
无责任此方_修行中2 小时前
搓了一个国产大模型与 AI Agent 比价工具
前端·后端·ai编程
szephyr3 小时前
腾讯云 ADP 智能体的 Skills 配置变更后没有走灰度,直接全量替换出了问题怎么定位?
java·前端·腾讯云·腾讯云adp
夏雪coding3 小时前
nginx SPA 回落把 CSS 变成了 HTML:一个返回 200 却让样式失效的坑
前端·nginx