从零搭建智能打卡系统(三):前端界面开发与用户体验
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,实现真正的智能打卡功能,包括人脸验证、活体检测、照片存储等关键技术。
以上是个人开发智能打卡系统前端部分的经验总结,如有不当之处,欢迎指正。技术之路,学无止境,与君共勉。
默认评论
Halo系统提供的评论