Преглед на файлове

feat: 更新考勤打卡功能,新增移动端Toast组件,优化打卡状态显示和用户交互体验。修改页面样式,提升用户体验。

master
lizhuang преди 1 седмица
родител
ревизия
4fd71d2a9a

+ 1
- 1
.env.development Целия файл

@@ -1,5 +1,5 @@
# 页面标题
VUE_APP_TITLE = 逐世后台管理系统
VUE_APP_TITLE = Digital Office Automation System

# 开发环境配置
ENV = 'development'

+ 177
- 0
src/components/MobileToast/index.vue Целия файл

@@ -0,0 +1,177 @@
<template>
<transition name="toast-fade">
<div v-if="visible" class="mobile-toast" :class="toastClass">
<div class="toast-icon" v-if="showIcon">
<i :class="iconClass"></i>
</div>
<div class="toast-message">{{ message }}</div>
</div>
</transition>
</template>

<script>
export default {
name: 'MobileToast',
data() {
return {
visible: false,
message: '',
type: 'info', // info, success, error, warning
duration: 3000,
timer: null
}
},
computed: {
/**
* 获取Toast样式类
* @returns {Object} 样式类对象
*/
toastClass() {
return {
[`toast-${this.type}`]: true
}
},
/**
* 是否显示图标
* @returns {boolean} 是否显示图标
*/
showIcon() {
return this.type !== 'info'
},
/**
* 获取图标类名
* @returns {string} 图标类名
*/
iconClass() {
const iconMap = {
success: 'el-icon-success',
error: 'el-icon-error',
warning: 'el-icon-warning'
}
return iconMap[this.type] || ''
}
},
methods: {
/**
* 显示Toast提示
* @param {Object} options 配置选项
*/
show(options = {}) {
const { message = '', type = 'info', duration = 3000 } = options
this.message = message
this.type = type
this.duration = duration
this.visible = true
// 清除之前的定时器
if (this.timer) {
clearTimeout(this.timer)
}
// 设置自动隐藏
this.timer = setTimeout(() => {
this.hide()
}, this.duration)
},
/**
* 隐藏Toast提示
*/
hide() {
this.visible = false
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
},
beforeDestroy() {
// 组件销毁前清理定时器
if (this.timer) {
clearTimeout(this.timer)
}
}
}
</script>

<style lang="scss" scoped>
.mobile-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
max-width: 280px;
min-width: 120px;
padding: 16px 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
text-align: center;
word-wrap: break-word;
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}

.toast-icon {
font-size: 18px;
flex-shrink: 0;
}

.toast-message {
flex: 1;
}

// 不同类型的样式
.toast-success {
background: rgba(76, 175, 80, 0.9);
}

.toast-error {
background: rgba(244, 67, 54, 0.9);
}

.toast-warning {
background: rgba(255, 152, 0, 0.9);
}

.toast-info {
background: rgba(0, 0, 0, 0.8);
}

// 过渡动画
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: all 0.3s ease;
}

.toast-fade-enter-from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}

.toast-fade-leave-to {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}

// 响应式适配
@media (max-width: 480px) {
.mobile-toast {
max-width: calc(100vw - 40px);
}
}
</style>

+ 2
- 0
src/main.js Целия файл

@@ -13,6 +13,7 @@ import router from './router'
import directive from './directive' // directive
import plugins from './plugins' // plugins
import { download } from '@/utils/request'
import Toast from '@/utils/toast' // 移动端Toast组件

import './assets/icons' // icon
import './permission' // permission control
@@ -78,6 +79,7 @@ Vue.use(directive)
Vue.use(plugins)
Vue.use(VueMeta)
Vue.use(FormDesigner)
Vue.use(Toast) // 注册Toast插件
DictData.install()

/**

+ 244
- 0
src/utils/attendance/AttendanceService.js Целия файл

@@ -0,0 +1,244 @@
import { parseTime } from '@/utils/tools'
import {
queryAttendanceGroupByUserId,
checkIn,
checkOut,
getCurrentDayRecord
} from '@/api/checkin/punch-card'

// 打卡状态常量
export const PUNCH_STATUS = {
NOT_CHECKED: 0,
CHECKED_IN: 1,
CHECKED_OUT: 2,
LATE_IN: 3,
EARLY_OUT: 4,
UPDATE_OUT: 5
}

// 打卡类型常量
export const PUNCH_TYPE = {
CLOCK_IN: 'clockIn',
CLOCK_OUT: 'clockOut'
}

/**
* 考勤服务类
* 处理所有考勤相关的业务逻辑
*/
export class AttendanceService {
/**
* 计算两个经纬度之间的距离
* @param {number} lat1 纬度1
* @param {number} lng1 经度1
* @param {number} lat2 纬度2
* @param {number} lng2 经度2
* @returns {number} 距离,单位为公里
*/
static calculateDistance(lat1, lng1, lat2, lng2) {
const R = 6371 // 地球半径
const dLat = (lat2 - lat1) * (Math.PI / 180)
const dLng = (lng2 - lng1) * (Math.PI / 180)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * (Math.PI / 180)) *
Math.cos(lat2 * (Math.PI / 180)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}

/**
* 判断是否在打卡范围内
* @param {Object} userLocation 用户位置
* @param {Object} attendanceGroup 考勤组信息
* @returns {boolean} 是否在范围内
*/
static isInAttendanceRange(userLocation, attendanceGroup) {
const { latitude, longitude } = userLocation
const { lat, lng, radius } = attendanceGroup
if (!latitude || !longitude || !lat || !lng) {
return false
}
const distance = this.calculateDistance(latitude, longitude, lat, lng)
return distance <= radius
}

/**
* 比较当前时间与计划时间
* @param {string} scheduleTime 计划时间 (HH:mm格式)
* @returns {number} 比较结果 (-1: 早于, 0: 等于, 1: 晚于)
*/
static compareTimeWithSchedule(scheduleTime) {
const now = new Date()
const [scheduleHour, scheduleMinute] = scheduleTime.split(':').map(Number)
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const scheduleMinutes = scheduleHour * 60 + scheduleMinute
return Math.sign(currentMinutes - scheduleMinutes)
}

/**
* 判断当前时间是否晚于上班时间
* @param {string} workStartTime 上班时间
* @returns {boolean} 是否迟到
*/
static isLateForWork(workStartTime) {
return this.compareTimeWithSchedule(workStartTime) > 0
}

/**
* 判断当前时间是否早于下班时间
* @param {string} workEndTime 下班时间
* @returns {boolean} 是否早退
*/
static isEarlyForLeaving(workEndTime) {
return this.compareTimeWithSchedule(workEndTime) < 0
}

/**
* 获取打卡状态文本
* @param {number} status 状态码
* @returns {string} 状态文本
*/
static getStatusText(status) {
const statusTexts = {
[PUNCH_STATUS.NOT_CHECKED]: '未打卡',
[PUNCH_STATUS.CHECKED_IN]: '已打卡',
[PUNCH_STATUS.CHECKED_OUT]: '已打卡',
[PUNCH_STATUS.LATE_IN]: '迟到打卡',
[PUNCH_STATUS.EARLY_OUT]: '早退打卡',
[PUNCH_STATUS.UPDATE_OUT]: '更新打卡'
}
return statusTexts[status] || '未打卡'
}

/**
* 格式化日期时间
* @param {Date} date 日期对象
* @returns {string} 格式化后的日期时间
*/
static formatDateTime(date) {
return parseTime(date, '{y}-{m}-{d} {h}:{i}:{s}')
}

/**
* 格式化时间显示
* @param {string} time 时间字符串
* @returns {string} 格式化后的时间
*/
static formatTime(time) {
return time ? parseTime(time, '{h}:{i}:{s}') : ''
}

/**
* 构建打卡请求数据
* @param {Object} params 参数对象
* @returns {Object} 打卡数据
*/
static buildPunchData(params) {
const { type, userInfo, userLocation, remark } = params
const statusMap = {
morning: PUNCH_STATUS.CHECKED_IN,
evening: PUNCH_STATUS.CHECKED_OUT,
morning_late: PUNCH_STATUS.LATE_IN,
evening_early: PUNCH_STATUS.EARLY_OUT
}
const isClockIn = type.includes('morning')
const currentTime = this.formatDateTime(new Date())
return {
userId: userInfo.userId,
userName: userInfo.nickName,
lng: userLocation.longitude,
lat: userLocation.latitude,
checkInStatus: statusMap[type],
checkInType: isClockIn ? PUNCH_TYPE.CLOCK_IN : PUNCH_TYPE.CLOCK_OUT,
[isClockIn ? 'clockIn' : 'clockOut']: currentTime,
description: remark || ''
}
}

/**
* 处理考勤记录数据
* @param {Array} records 考勤记录数组
* @returns {Object} 处理后的考勤状态
*/
static processAttendanceRecords(records) {
const attendanceStatus = {
clockInTime: null,
clockOutTime: null,
clockInStatus: PUNCH_STATUS.NOT_CHECKED,
clockOutStatus: PUNCH_STATUS.NOT_CHECKED
}
records.forEach(record => {
if (record.checkInType === PUNCH_TYPE.CLOCK_IN) {
attendanceStatus.clockInTime = record.checkInTime
attendanceStatus.clockInStatus = parseInt(record.checkInStatus)
} else if (record.checkInType === PUNCH_TYPE.CLOCK_OUT) {
attendanceStatus.clockOutTime = record.checkInTime
attendanceStatus.clockOutStatus = parseInt(record.checkInStatus)
}
})
return attendanceStatus
}

/**
* 获取考勤组信息
* @param {string} userId 用户ID
* @returns {Promise<Object>} 考勤组信息
*/
static async getAttendanceGroup(userId) {
const response = await queryAttendanceGroupByUserId({ userId })
if (response.code !== 200) {
throw new Error(response.msg || '获取考勤组信息失败')
}
if (!response.data) {
throw new Error('未配置考勤组,请联系管理员')
}
return response.data
}

/**
* 获取今日考勤记录
* @param {string} userId 用户ID
* @returns {Promise<Array>} 考勤记录数组
*/
static async getTodayAttendance(userId) {
const response = await getCurrentDayRecord({ userId })
if (response.code !== 200) {
throw new Error(response.msg || '获取考勤状态失败')
}
return response.data || []
}

/**
* 提交打卡
* @param {Object} punchData 打卡数据
* @returns {Promise<Object>} API响应
*/
static async submitPunch(punchData) {
const apiCall = punchData.checkInType === PUNCH_TYPE.CLOCK_IN ? checkIn : checkOut
const response = await apiCall(punchData)
if (response.code !== 200) {
throw new Error(response.msg || '打卡失败')
}
return response
}
}

+ 123
- 0
src/utils/toast.js Целия файл

@@ -0,0 +1,123 @@
import Vue from 'vue'
import MobileToast from '@/components/MobileToast/index.vue'

// 创建Toast构造器
const ToastConstructor = Vue.extend(MobileToast)

let toastInstance = null

/**
* 创建Toast实例
* @returns {Object} Toast实例
*/
function createToastInstance() {
const instance = new ToastConstructor()
instance.$mount()
document.body.appendChild(instance.$el)
return instance
}

/**
* 获取Toast实例(单例模式)
* @returns {Object} Toast实例
*/
function getToastInstance() {
if (!toastInstance) {
toastInstance = createToastInstance()
}
return toastInstance
}

/**
* 显示Toast提示
* @param {string|Object} options 提示内容或配置对象
* @param {string} type 提示类型
* @param {number} duration 显示时长
*/
function showToast(options, type = 'info', duration = 3000) {
const instance = getToastInstance()
if (typeof options === 'string') {
instance.show({
message: options,
type,
duration
})
} else {
instance.show({
type,
duration,
...options
})
}
}

/**
* Toast工具类
* 提供移动端友好的提示功能
*/
const Toast = {
/**
* 显示信息提示
* @param {string} message 提示内容
* @param {number} duration 显示时长
*/
info(message, duration = 3000) {
showToast(message, 'info', duration)
},

/**
* 显示成功提示
* @param {string} message 提示内容
* @param {number} duration 显示时长
*/
success(message, duration = 3000) {
showToast(message, 'success', duration)
},

/**
* 显示错误提示
* @param {string} message 提示内容
* @param {number} duration 显示时长
*/
error(message, duration = 3000) {
showToast(message, 'error', duration)
},

/**
* 显示警告提示
* @param {string} message 提示内容
* @param {number} duration 显示时长
*/
warning(message, duration = 3000) {
showToast(message, 'warning', duration)
},

/**
* 隐藏Toast
*/
hide() {
if (toastInstance) {
toastInstance.hide()
}
},

/**
* 销毁Toast实例
*/
destroy() {
if (toastInstance) {
toastInstance.hide()
document.body.removeChild(toastInstance.$el)
toastInstance.$destroy()
toastInstance = null
}
}
}

// 安装Vue插件
Toast.install = function(Vue) {
Vue.prototype.$toast = Toast
}

export default Toast

+ 95
- 0
src/views/m/checkin/components/LocationInfo.vue Целия файл

@@ -0,0 +1,95 @@
<template>
<div>
<!-- 范围,地点提示 -->
<div class="location-info">
<div class="location-text">
<em :class="isInRange ? 'text-success' : 'text-error'">
<i :class="isInRange ? 'el-icon-success' : 'el-icon-error'" />
{{ isInRange ? '已进入打卡范围' : '未进入打卡范围' }}
</em>
<span>{{ areaName }}</span>
</div>
</div>

<!-- 调试信息 -->
<p class="coordinate-info">
经度:{{ longitude }} <br>
纬度:{{ latitude }}
</p>
</div>
</template>

<script>
export default {
name: 'LocationInfo',
props: {
isInRange: {
type: Boolean,
default: false
},
areaName: {
type: String,
default: ''
},
longitude: {
type: Number,
default: null
},
latitude: {
type: Number,
default: null
}
}
}
</script>

<style lang="scss" scoped>
.location-info {
background: white;
margin: 10px auto;
padding: 15px 20px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
max-width: 280px;
}

.text-success {
color: #4caf50;
}

.text-error {
color: #f56c6c;
}

.location-text {
flex: 1;
font-size: 14px;
color: #666;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
em {
font-style: normal;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-weight: 600;
i {
font-size: 20px;
}
}
}

.coordinate-info {
text-align: center;
font-size: 12px;
color: #999;
margin: 10px 0;
}
</style>

+ 169
- 0
src/views/m/checkin/components/RemarkDialog.vue Целия файл

@@ -0,0 +1,169 @@
<template>
<transition name="slide-up">
<div v-if="visible" class="remark-panel">
<div class="remark-mask" @click="$emit('close')" />
<div class="remark-content">
<div class="remark-header">
<div class="remark-title">{{ title }}</div>
<div class="remark-close" @click="$emit('close')">×</div>
</div>
<div class="remark-body">
<div class="remark-input">
<textarea
:value="remark"
@input="$emit('update:remark', $event.target.value)"
placeholder="请输入备注信息(选填)"
maxlength="200"
rows="4"
/>
<div class="remark-count">{{ remark.length }}/200</div>
</div>
</div>
<div class="remark-footer">
<button class="remark-submit" @click="$emit('submit')">
确认打卡
</button>
</div>
</div>
</div>
</transition>
</template>

<script>
export default {
name: 'RemarkDialog',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
remark: {
type: String,
default: ''
}
},
emits: ['close', 'submit', 'update:remark']
}
</script>

<style lang="scss" scoped>
.remark-panel {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;

.remark-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}

.remark-content {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 16px 16px 0 0;
transform: translateY(0);
transition: transform 0.3s ease-out;
}

.remark-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}

.remark-title {
font-size: 16px;
font-weight: 500;
color: #333;
}

.remark-close {
font-size: 24px;
color: #999;
padding: 4px;
cursor: pointer;
}

.remark-body {
padding: 20px;
}

.remark-input {
position: relative;

textarea {
width: 100%;
border: 1px solid #eee;
border-radius: 8px;
padding: 12px;
font-size: 14px;
line-height: 1.5;
resize: none;
outline: none;

&:focus {
border-color: #2196f3;
}
}

.remark-count {
position: absolute;
right: 12px;
bottom: 12px;
font-size: 12px;
color: #999;
}
}

.remark-footer {
padding: 12px 20px 20px;

.remark-submit {
width: 100%;
height: 44px;
background: #2196f3;
border: none;
border-radius: 22px;
color: #fff;
font-size: 16px;
font-weight: 500;

&:active {
opacity: 0.9;
}
}
}
}

.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease-out;
}

.slide-up-enter-from,
.slide-up-leave-to {
.remark-mask {
opacity: 0;
}

.remark-content {
transform: translateY(100%);
}
}
</style>

+ 167
- 0
src/views/m/checkin/components/SuccessDialog.vue Целия файл

@@ -0,0 +1,167 @@
<template>
<transition name="fade">
<div v-if="visible" class="success-overlay">
<div class="success-content">
<div class="success-close" @click="$emit('close')">×</div>
<div class="success-icon">
<svg viewBox="0 0 24 24" class="checkmark">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
</div>
<div class="success-title">打卡成功</div>
<div class="success-info">
<div class="info-item">
<span class="label">打卡时间</span>
<span class="value">{{ punchTime }}</span>
</div>
<div class="info-item">
<span class="label">打卡地点</span>
<span class="value">{{ location }}</span>
</div>
</div>
</div>
</div>
</transition>
</template>

<script>
export default {
name: 'SuccessDialog',
props: {
visible: {
type: Boolean,
default: false
},
punchTime: {
type: String,
default: ''
},
location: {
type: String,
default: ''
}
},
emits: ['close']
}
</script>

<style lang="scss" scoped>
.success-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}

.success-content {
background: white;
border-radius: 20px;
padding: 30px;
width: 80%;
max-width: 320px;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
animation: popIn 0.3s ease-out;
position: relative;
}

.success-close {
position: absolute;
top: 15px;
right: 20px;
font-size: 24px;
color: #999;
cursor: pointer;
padding: 4px;
border-radius: 50%;
transition: all 0.2s ease;

&:hover {
background: #f5f5f5;
color: #666;
}

&:active {
transform: scale(0.95);
}
}

.success-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background: #4caf50;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}

.checkmark {
width: 40px;
height: 40px;
fill: white;
}

.success-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}

.success-info {
background: #f8f9fa;
border-radius: 12px;
padding: 15px;
}

.info-item {
display: flex;
flex-direction: column;
margin-bottom: 10px;
text-align: left;
&:last-child {
margin-bottom: 0;
}
}

.label {
color: #666;
font-size: 14px;
}

.value {
color: #333;
font-size: 14px;
font-weight: 500;
}

@keyframes popIn {
0% {
transform: scale(0.8);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}

.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

+ 353
- 814
src/views/m/checkin/index.vue
Файловите разлики са ограничени, защото са твърде много
Целия файл


Loading…
Отказ
Запис