从零搭建智能打卡系统(三):前端界面开发与用户体验

SailTrack
2026-01-07
点 赞
0
热 度
3
评 论
0
  1. 首页
  2. 学习
  3. 从零搭建智能打卡系统(三):前端界面开发与用户体验

从零搭建智能打卡系统(三):前端界面开发与用户体验

1. 前端技术栈选择

1.1 为什么选择 Vue.js 3.x

最近在开发智能打卡系统时,我面临一个关键决策:前端框架选什么?经过对比 React、Angular 和 Vue.js,最终选择了 Vue.js 3.x,原因如下:

渐进式框架,学习曲线平缓
组合式 API 让逻辑复用更灵活
响应式系统简单直观
生态完善,Element Plus 组件库成熟

1.2 配套技术选型

除了 Vue.js 3.x,我还搭配了以下技术:

Element Plus 2.12.0:企业级 UI 组件库
Vite 7.2.7:极速构建工具
Vue Router 4.x:路由管理
ECharts 6.0.0:数据可视化
dayjs 1.11.19:日期处理

2. 项目结构设计

2.1 目录结构规划

web/
├── src/
│   ├── views/          # 页面组件
│   │   ├── Home.vue    # 首页
│   │   ├── Login.vue   # 登录页
│   │   ├── Dashboard.vue # 仪表板
│   │   ├── Attendance.vue # 考勤打卡
│   │   ├── Records.vue  # 考勤记录
│   │   ├── Leave.vue    # 请假管理
│   │   ├── Approval.vue # 请假审批
│   │   └── Profile.vue  # 个人中心
│   ├── components/     # 公共组件
│   │   ├── Attendance.vue
│   │   └── Login.vue
│   ├── services/       # API 服务
│   │   └── api.js
│   ├── router/         # 路由配置
│   │   └── index.js
│   ├── App.vue         # 根组件
│   └── main.js         # 入口文件

2.2 路由配置设计

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('../views/Home.vue')
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('../views/Login.vue')
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('../views/Dashboard.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/attendance',
    name: 'Attendance',
    component: () => import('../views/Attendance.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/records',
    name: 'Records',
    component: () => import('../views/Records.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/leave',
    name: 'Leave',
    component: () => import('../views/Leave.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/approval',
    name: 'Approval',
    component: () => import('../views/Approval.vue'),
    meta: { requiresAuth: true, requiresManager: true }
  },
  {
    path: '/profile',
    name: 'Profile',
    component: () => import('../views/Profile.vue'),
    meta: { requiresAuth: true }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 路由守卫
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  
  if (to.meta.requiresAuth && !token) {
    next('/login')
  } else if (to.meta.requiresManager) {
    // 检查用户角色是否为经理
    const userRole = localStorage.getItem('userRole')
    if (userRole !== 'MANAGER' && userRole !== 'ADMIN') {
      next('/dashboard')
    } else {
      next()
    }
  } else {
    next()
  }
})

export default router

3. 核心页面实现

3.1 登录注册页面

登录表单设计

<!-- views/Login.vue -->
<template>
  <div class="login-container">
    <el-card class="login-card">
      <h2 class="login-title">智能打卡系统</h2>
      
      <!-- 登录表单 -->
      <el-form v-if="!showRegister" ref="loginForm" :model="loginForm" :rules="loginRules">
        <el-form-item prop="username">
          <el-input v-model="loginForm.username" placeholder="用户名" prefix-icon="User" />
        </el-form-item>
        
        <el-form-item prop="password">
          <el-input v-model="loginForm.password" type="password" placeholder="密码" prefix-icon="Lock" />
        </el-form-item>
        
        <el-form-item>
          <el-button type="primary" @click="handleLogin" :loading="loading" style="width: 100%">
            登录
          </el-button>
        </el-form-item>
      </el-form>
      
      <!-- 注册表单 -->
      <el-form v-else ref="registerForm" :model="registerForm" :rules="registerRules">
        <el-form-item prop="username">
          <el-input v-model="registerForm.username" placeholder="用户名" prefix-icon="User" />
        </el-form-item>
        
        <el-form-item prop="email">
          <el-input v-model="registerForm.email" placeholder="邮箱" prefix-icon="Message" />
        </el-form-item>
        
        <el-form-item prop="captcha">
          <div class="captcha-input">
            <el-input v-model="registerForm.captcha" placeholder="验证码" prefix-icon="Key" />
            <el-button @click="sendCaptcha" :disabled="captchaCountdown > 0">
              {{ captchaCountdown > 0 ? `${captchaCountdown}秒后重试` : '获取验证码' }}
            </el-button>
          </div>
        </el-form-item>
        
        <el-form-item prop="password">
          <el-input v-model="registerForm.password" type="password" placeholder="密码" prefix-icon="Lock" />
        </el-form-item>
        
        <el-form-item prop="confirmPassword">
          <el-input v-model="registerForm.confirmPassword" type="password" placeholder="确认密码" prefix-icon="Lock" />
        </el-form-item>
        
        <el-form-item prop="departmentId">
          <el-select v-model="registerForm.departmentId" placeholder="选择部门" style="width: 100%">
            <el-option v-for="dept in departments" :key="dept.id" :label="dept.name" :value="dept.id" />
          </el-select>
        </el-form-item>
        
        <el-form-item>
          <el-button type="primary" @click="handleRegister" :loading="loading" style="width: 100%">
            注册
          </el-button>
        </el-form-item>
      </el-form>
      
      <div class="switch-form">
        <el-link @click="showRegister = !showRegister">
          {{ showRegister ? '已有账号?去登录' : '没有账号?去注册' }}
        </el-link>
      </div>
    </el-card>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import api from '../services/api'

const router = useRouter()
const showRegister = ref(false)
const loading = ref(false)
const captchaCountdown = ref(0)
const departments = ref([])

// 登录表单
const loginForm = reactive({
  username: '',
  password: ''
})

// 注册表单
const registerForm = reactive({
  username: '',
  email: '',
  captcha: '',
  password: '',
  confirmPassword: '',
  departmentId: ''
})

// 获取部门列表
onMounted(async () => {
  try {
    const response = await api.getDepartments()
    departments.value = response.data
  } catch (error) {
    console.error('获取部门列表失败:', error)
  }
})

// 发送验证码
const sendCaptcha = async () => {
  if (!registerForm.email) {
    ElMessage.warning('请输入邮箱')
    return
  }
  
  try {
    await api.sendCaptcha(registerForm.email)
    ElMessage.success('验证码已发送到邮箱')
    captchaCountdown.value = 60
    const timer = setInterval(() => {
      captchaCountdown.value--
      if (captchaCountdown.value <= 0) {
        clearInterval(timer)
      }
    }, 1000)
  } catch (error) {
    ElMessage.error('发送验证码失败')
  }
}

// 处理登录
const handleLogin = async () => {
  try {
    loading.value = true
    const response = await api.login(loginForm)
    localStorage.setItem('token', response.data.token)
    localStorage.setItem('userRole', response.data.role)
    ElMessage.success('登录成功')
    router.push('/dashboard')
  } catch (error) {
    ElMessage.error(error.message || '登录失败')
  } finally {
    loading.value = false
  }
}

// 处理注册
const handleRegister = async () => {
  if (registerForm.password !== registerForm.confirmPassword) {
    ElMessage.error('两次输入的密码不一致')
    return
  }
  
  try {
    loading.value = true
    await api.register(registerForm)
    ElMessage.success('注册成功,请登录')
    showRegister.value = false
    // 清空注册表单
    Object.keys(registerForm).forEach(key => {
      registerForm[key] = ''
    })
  } catch (error) {
    ElMessage.error(error.message || '注册失败')
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login-container {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.login-card {
  width: 400px;
  padding: 30px;
}

.login-title {
  text-align: center;
  margin-bottom: 30px;
  color: #333;
}

.captcha-input {
  display: flex;
  gap: 10px;
}

.switch-form {
  text-align: center;
  margin-top: 20px;
}
</style>

3.2 仪表板页面

仪表板核心功能

仪表板是用户进入系统后的主界面,需要展示关键信息:

今日考勤状态
人脸识别打卡按钮
月度统计卡片
最近考勤记录

<!-- views/Dashboard.vue -->
<template>
  <div class="dashboard-container">
    <!-- 顶部欢迎信息 -->
    <el-card class="welcome-card">
      <div class="welcome-content">
        <div>
          <h2>欢迎回来,{{ userInfo.realName || userInfo.username }}!</h2>
          <p class="welcome-subtitle">{{ currentTime }}</p>
        </div>
        <el-avatar :size="60" :src="userInfo.faceImageUrl || defaultAvatar" />
      </div>
    </el-card>
    
    <!-- 今日考勤状态 -->
    <el-row :gutter="20" class="attendance-row">
      <el-col :span="12">
        <el-card class="attendance-card">
          <div class="attendance-status">
            <h3>今日考勤状态</h3>
            <div v-if="todayAttendance" class="status-details">
              <p><strong>签到时间:</strong>{{ formatTime(todayAttendance.checkInTime) }}</p>
              <p><strong>签退时间:</strong>{{ todayAttendance.checkOutTime ? formatTime(todayAttendance.checkOutTime) : '未签退' }}</p>
              <p><strong>工作时长:</strong>{{ todayAttendance.workHours || '0' }} 小时</p>
              <p><strong>加班时长:</strong>{{ todayAttendance.overtimeHours || '0' }} 小时</p>
            </div>
            <div v-else class="no-attendance">
              <p>今日尚未打卡</p>
            </div>
          </div>
        </el-card>
      </el-col>
      
      <el-col :span="12">
        <el-card class="face-check-card">
          <h3>人脸识别打卡</h3>
          <div class="face-check-buttons">
            <el-button 
              type="primary" 
              size="large" 
              @click="handleFaceCheckIn(1)"
              :disabled="!hasFaceImage || (todayAttendance && todayAttendance.checkInTime)"
            >
              <el-icon><Camera /></el-icon>
              人脸签到
            </el-button>
            
            <el-button 
              type="success" 
              size="large" 
              @click="handleFaceCheckIn(2)"
              :disabled="!hasFaceImage || !todayAttendance || todayAttendance.checkOutTime"
            >
              <el-icon><Camera /></el-icon>
              人脸签退
            </el-button>
          </div>
          
          <div v-if="!hasFaceImage" class="face-warning">
            <el-alert type="warning" show-icon>
              请先上传人脸照片才能使用人脸识别打卡
              <template #action>
                <el-button type="primary" size="small" @click="goToProfile">去上传</el-button>
              </template>
            </el-alert>
          </div>
        </el-card>
      </el-col>
    </el-row>
    
    <!-- 月度统计卡片 -->
    <el-row :gutter="20" class="stats-row">
      <el-col :span="6" v-for="stat in monthlyStats" :key="stat.title">
        <el-card class="stat-card">
          <div class="stat-content">
            <div class="stat-icon" :style="{ backgroundColor: stat.color }">
              <el-icon :size="24"><component :is="stat.icon" /></el-icon>
            </div>
            <div class="stat-info">
              <h3>{{ stat.value }}</h3>
              <p>{{ stat.title }}</p>
            </div>
          </div>
        </el-card>
      </el-col>
    </el-row>
    
    <!-- 最近考勤记录 -->
    <el-card class="recent-records-card">
      <template #header>
        <div class="card-header">
          <span>最近考勤记录</span>
          <el-button type="primary" text @click="goToRecords">查看全部</el-button>
        </div>
      </template>
      
      <el-table :data="recentRecords" style="width: 100%">
        <el-table-column prop="attendanceDate" label="日期" width="120">
          <template #default="{ row }">
            {{ formatDate(row.attendanceDate) }}
          </template>
        </el-table-column>
        <el-table-column prop="checkInTime" label="签到时间" width="120">
          <template #default="{ row }">
            {{ formatTime(row.checkInTime) }}
          </template>
        </el-table-column>
        <el-table-column prop="checkOutTime" label="签退时间" width="120">
          <template #default="{ row }">
            {{ row.checkOutTime ? formatTime(row.checkOutTime) : '未签退' }}
          </template>
        </el-table-column>
        <el-table-column prop="workHours" label="工作时长" width="100">
          <template #default="{ row }">
            {{ row.workHours || '0' }} 小时
          </template>
        </el-table-column>
        <el-table-column prop="status" label="状态" width="100">
          <template #default="{ row }">
            <el-tag :type="getStatusType(row.status)">
              {{ getStatusText(row.status) }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="remark" label="备注" />
      </el-table>
    </el-card>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Camera, Timer, Calendar, User, Clock } from '@element-plus/icons-vue'
import api from '../services/api'
import dayjs from 'dayjs'

const router = useRouter()

// 响应式数据
const userInfo = ref({})
const todayAttendance = ref(null)
const monthlyStats = ref([])
const recentRecords = ref([])
const currentTime = ref('')

// 计算属性
const hasFaceImage = computed(() => {
  return userInfo.value.faceImageUrl && userInfo.value.faceImageUrl.length > 0
})

// 初始化数据
onMounted(async () => {
  await Promise.all([
    loadUserInfo(),
    loadTodayAttendance(),
    loadMonthlyStats(),
    loadRecentRecords()
  ])
  
  // 更新时间
  updateCurrentTime()
  setInterval(updateCurrentTime, 60000)
})

// 加载用户信息
const loadUserInfo = async () => {
  try {
    const response = await api.getUserInfo()
    userInfo.value = response.data
  } catch (error) {
    console.error('加载用户信息失败:', error)
  }
}

// 加载今日考勤
const loadTodayAttendance = async () => {
  try {
    const response = await api.getTodayAttendance()
    todayAttendance.value = response.data
  } catch (error) {
    console.error('加载今日考勤失败:', error)
  }
}

// 加载月度统计
const loadMonthlyStats = async () => {
  try {
    const currentMonth = dayjs().format('YYYY-MM')
    const response = await api.getMonthlyStats(currentMonth)
    
    monthlyStats.value = [
      {
        title: '工作时长',
        value: `${response.data.totalWorkHours || 0} 小时`,
        icon: Timer,
        color: '#409EFF'
      },
      {
        title: '请假天数',
        value: `${response.data.leaveDays || 0} 天`,
        icon: Calendar,
        color: '#67C23A'
      },
      {
        title: '加班时长',
        value: `${response.data.overtimeHours || 0} 小时`,
        icon: Clock,
        color: '#E6A23C'
      },
      {
        title: '出勤率',
        value: `${response.data.attendanceRate || 0}%`,
        icon: User,
        color: '#F56C6C'
      }
    ]
  } catch (error) {
    console.error('加载月度统计失败:', error)
  }
}

// 加载最近记录
const loadRecentRecords = async () => {
  try {
    const response = await api.getRecentRecords(10)
    recentRecords.value = response.data
  } catch (error) {
    console.error('加载最近记录失败:', error)
  }
}

// 人脸识别打卡
const handleFaceCheckIn = async (type) => {
  try {
    const { value: file } = await ElMessageBox.prompt(
      type === 1 ? '请上传签到照片' : '请上传签退照片',
      '人脸识别打卡',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        inputType: 'file',
        beforeClose: async (action, instance, done) => {
          if (action === 'confirm') {
            const file = instance.inputValue
            if (!file) {
              ElMessage.warning('请选择照片')
              return
            }
            
            const formData = new FormData()
            formData.append('faceImage', file)
            formData.append('type', type)
            
            try {
              const response = await api.faceCheckIn(formData)
              ElMessage.success(type === 1 ? '签到成功' : '签退成功')
              await loadTodayAttendance()
              await loadRecentRecords()
              done()
            } catch (error) {
              ElMessage.error(error.message || '打卡失败')
            }
          } else {
            done()
          }
        }
      }
    )
  } catch (error) {
    // 用户取消
  }
}

// 工具函数
const formatDate = (date) => {
  return dayjs(date).format('YYYY-MM-DD')
}

const formatTime = (time) => {
  return dayjs(time).format('HH:mm:ss')
}

const updateCurrentTime = () => {
  currentTime.value = dayjs().format('YYYY年MM月DD日 HH:mm:ss')
}

const getStatusType = (status) => {
  const types = {
    1: 'success', // 正常
    2: 'warning', // 迟到
    3: 'danger',  // 早退
    4: 'info'     // 缺卡
  }
  return types[status] || 'info'
}

const getStatusText = (status) => {
  const texts = {
    1: '正常',
    2: '迟到',
    3: '早退',
    4: '缺卡'
  }
  return texts[status] || '未知'
}

// 导航函数
const goToProfile = () => {
  router.push('/profile')
}

const goToRecords = () => {
  router.push('/records')
}
</script>

<style scoped>
.dashboard-container {
  padding: 20px;
}

.welcome-card {
  margin-bottom: 20px;
}

.welcome-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.welcome-subtitle {
  color: #666;
  margin-top: 5px;
}

.attendance-row {
  margin-bottom: 20px;
}

.attendance-card, .face-check-card {
  height: 100%;
}

.face-check-buttons {
  display: flex;
  gap: 20px;
  margin: 20px 0;
  justify-content: center;
}

.face-warning {
  margin-top: 20px;
}

.stats-row {
  margin-bottom: 20px;
}

.stat-card {
  height: 100%;
}

.stat-content {
  display: flex;
  align-items: center;
  gap: 15px;
}

.stat-icon {
  width: 50px;
  height: 50px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
}

.stat-info h3 {
  margin: 0;
  font-size: 24px;
  color: #333;
}

.stat-info p {
  margin: 5px 0 0;
  color: #666;
}

.recent-records-card {
  margin-top: 20px;
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.status-details p {
  margin: 8px 0;
}

.no-attendance {
  text-align: center;
  padding: 20px;
  color: #999;
}
</style>

4. API 服务封装

4.1 统一 API 调用

// services/api.js
import { ElMessage } from 'element-plus'

const API_BASE_URL = 'http://localhost:8080/api'

// 统一请求函数
async function request(url, options = {}) {
  const token = localStorage.getItem('token')
  
  const headers = {
    'Content-Type': 'application/json',
    ...options.headers
  }
  
  if (token) {
    headers['Authorization'] = `Bearer ${token}`
  }
  
  const config = {
    ...options,
    headers
  }
  
  try {
    const response = await fetch(`${API_BASE_URL}${url}`, config)
    
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}))
      throw new Error(errorData.message || `请求失败: ${response.status}`)
    }
    
    return await response.json()
  } catch (error) {
    console.error('API请求错误:', error)
    ElMessage.error(error.message || '网络请求失败')
    throw error
  }
}

// 认证相关 API
export const authApi = {
  // 发送验证码
  sendCaptcha: (email) => 
    request(`/auth/send-captcha?email=${encodeURIComponent(email)}`, {
      method: 'POST'
    }),
  
  // 用户注册
  register: (data) => 
    request('/auth/register', {
      method: 'POST',
      body: JSON.stringify(data)
    }),
  
  // 用户登录
  login: (data) => 
    request('/auth/login', {
      method: 'POST',
      body: JSON.stringify(data)
    })
}

// 考勤相关 API
export const attendanceApi = {
  // 获取今日考勤
  getTodayAttendance: () => 
    request('/attendance/today'),
  
  // 人脸识别打卡
  faceCheckIn: (formData) => 
    request('/attendance/check-with-face', {
      method: 'POST',
      headers: {}, // FormData 会自动设置 Content-Type
      body: formData
    }),
  
  // 获取月度统计
  getMonthlyStats: (month) => 
    request(`/attendance/monthly-stats?month=${month}`),
  
  // 获取最近记录
  getRecentRecords: (limit = 10) => 
    request(`/attendance/recent-records?limit=${limit}`),
  
  // 补卡申请
  applySupplement: (data) => 
    request('/attendance/supplement-apply', {
      method: 'POST',
      body: JSON.stringify(data)
    }),
  
  // 获取补卡记录
  getSupplementRecords: () => 
    request('/attendance/supplement-records'),
  
  // 撤销补卡申请
  cancelSupplement: (id) => 
    request(`/attendance/supplement-apply/${id}`, {
      method: 'DELETE'
    })
}

// 用户相关 API
export const userApi = {
  // 获取用户信息
  getUserInfo: () => 
    request('/user/info'),
  
  // 获取部门列表
  getDepartments: () => 
    request('/user/departments'),
  
  // 获取角色列表
  getRoles: () => 
    request('/user/roles'),
  
  // 更新个人信息
  updateProfile: (data) => 
    request('/user/update-profile', {
      method: 'PUT',
      body: JSON.stringify(data)
    }),
  
  // 修改密码
  changePassword: (data) => 
    request('/user/change-password', {
      method: 'PUT',
      body: JSON.stringify(data)
    }),
  
  // 上传人脸照片
  uploadFaceImage: (formData) => 
    request('/user/upload-face', {
      method: 'POST',
      headers: {},
      body: formData
    }),
  
  // 获取用户统计
  getUserStats: () => 
    request('/user/stats')
}

// 请假相关 API
export const leaveApi = {
  // 申请请假
  applyLeave: (data) => 
    request('/leave/apply', {
      method: 'POST',
      body: JSON.stringify(data)
    }),
  
  // 获取我的请假记录
  getMyLeaveRecords: () => 
    request('/leave/my-records'),
  
  // 获取部门请假记录(经理权限)
  getDepartmentLeaveRecords: () => 
    request('/leave/department-records'),
  
  // 获取待审批列表(经理权限)
  getPendingApprovals: () => 
    request('/leave/pending-approvals'),
  
  // 审批请假
  approveLeave: (data) => 
    request('/leave/approve', {
      method: 'POST',
      body: JSON.stringify(data)
    })
}

// 统一导出
export default {
  ...authApi,
  ...attendanceApi,
  ...userApi,
  ...leaveApi
}

5. 用户体验优化

5.1 响应式设计

/* 全局响应式样式 */
@media (max-width: 768px) {
  .dashboard-container {
    padding: 10px;
  }
  
  .attendance-row .el-col {
    margin-bottom: 20px;
  }
  
  .stats-row .el-col {
    margin-bottom: 15px;
  }
  
  .face-check-buttons {
    flex-direction: column;
    gap: 10px;
  }
  
  .welcome-content {
    flex-direction: column;
    text-align: center;
    gap: 15px;
  }
}

@media (max-width: 480px) {
  .login-card {
    width: 90%;
    padding: 20px;
  }
  
  .captcha-input {
    flex-direction: column;
  }
}

5.2 加载状态优化

<!-- 全局加载组件 -->
<template>
  <div v-if="loading" class="global-loading">
    <div class="loading-content">
      <el-icon class="loading-icon" :size="40"><Loading /></el-icon>
      <p>加载中...</p>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { Loading } from '@element-plus/icons-vue'

const loading = ref(false)

// 全局设置加载状态
const setLoading = (isLoading) => {
  loading.value = isLoading
}

defineExpose({ setLoading })
</script>

<style scoped>
.global-loading {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.8);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 9999;
}

.loading-content {
  text-align: center;
}

.loading-icon {
  animation: rotate 2s linear infinite;
}

@keyframes rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
</style>

5.3 错误处理优化

// 全局错误处理
const setupGlobalErrorHandling = () => {
  // 未捕获的 Promise 错误
  window.addEventListener('unhandledrejection', (event) => {
    console.error('未处理的 Promise 错误:', event.reason)
    ElMessage.error('系统发生错误,请刷新页面重试')
  })
  
  // 全局错误
  window.addEventListener('error', (event) => {
    console.error('全局错误:', event.error)
    ElMessage.error('系统发生错误,请刷新页面重试')
  })
  
  // 网络错误处理
  const originalFetch = window.fetch
  window.fetch = async function(...args) {
    try {
      return await originalFetch.apply(this, args)
    } catch (error) {
      if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
        ElMessage.error('网络连接失败,请检查网络设置')
      }
      throw error
    }
  }
}

6. 开发经验总结

6.1 遇到的挑战

跨域问题

开发初期遇到最头疼的问题就是跨域。虽然后端已经配置了 CORS,但前端开发时还是遇到各种问题:

开发服务器端口不同(Vite 默认 3000,后端 8080)
需要处理预检请求(OPTIONS)
携带凭证(Cookie、Authorization)需要特殊配置

解决方案:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

文件上传处理

人脸照片上传需要处理 FormData,与普通的 JSON 请求不同:

// 错误的做法
const formData = {
  faceImage: file,
  type: 1
}
await api.faceCheckIn(formData) // 会发送 JSON

// 正确的做法
const formData = new FormData()
formData.append('faceImage', file)
formData.append('type', '1')
await api.faceCheckIn(formData) // 发送 FormData

6.2 最佳实践

组件设计原则

单一职责:每个组件只做一件事
可复用性:提取公共组件
可维护性:清晰的 props 和 emits
可测试性:独立的业务逻辑

状态管理

对于中小型项目,Vue 3 的组合式 API 足够管理状态:

// 使用组合式函数管理状态
export function useAttendance() {
  const todayAttendance = ref(null)
  const loading = ref(false)
  
  const loadTodayAttendance = async () => {
    loading.value = true
    try {
      const response = await api.getTodayAttendance()
      todayAttendance.value = response.data
    } finally {
      loading.value = false
    }
  }
  
  return {
    todayAttendance,
    loading,
    loadTodayAttendance
  }
}

6.3 性能优化

代码分割

Vite 默认支持代码分割,但我们可以进一步优化:

// 路由懒加载
const Dashboard = () => import('../views/Dashboard.vue')
const Attendance = () => import('../views/Attendance.vue')
// ...

图片优化

使用 WebP 格式(如果浏览器支持)
实现懒加载
使用 CDN 加速

7. 结语

至此,智能打卡系统的前端部分已经基本完成。从技术选型到具体实现,整个过程让我深刻体会到:

框架选择要结合实际需求,Vue 3 的组合式 API 确实让开发更灵活
Element Plus 组件库大大提升了开发效率
良好的用户体验需要从细节入手,比如加载状态、错误提示
响应式设计是移动互联网时代的必备技能

前端开发不仅仅是实现功能,更重要的是创造良好的用户体验。在开发过程中,我不断思考:用户需要什么?如何让操作更简单?如何减少用户的等待时间?

技术如人生,总是在不断学习和改进中前进。下一篇文章,我将分享人脸识别和智能功能的实现,这是整个系统最"智能"的部分。

预告: 在第四篇文章中,我将详细介绍如何集成阿里云人脸识别 API,实现真正的智能打卡功能,包括人脸验证、活体检测、照片存储等关键技术。


以上是个人开发智能打卡系统前端部分的经验总结,如有不当之处,欢迎指正。技术之路,学无止境,与君共勉。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

具有版权性

请您在转载、复制时注明本文 作者、链接及内容来源信息。 若涉及转载第三方内容,还需一同注明。

具有时效性

文章目录

欢迎来到SailTrack的站点,为您导航全站动态

21 文章数
8 分类数
1 评论数
11标签数