# 页面标题 | # 页面标题 | ||||
VUE_APP_TITLE = 逐世后台管理系统 | |||||
VUE_APP_TITLE = Digital Office Automation System | |||||
# 开发环境配置 | # 开发环境配置 | ||||
ENV = 'development' | ENV = 'development' |
<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> |
import directive from './directive' // directive | import directive from './directive' // directive | ||||
import plugins from './plugins' // plugins | import plugins from './plugins' // plugins | ||||
import { download } from '@/utils/request' | import { download } from '@/utils/request' | ||||
import Toast from '@/utils/toast' // 移动端Toast组件 | |||||
import './assets/icons' // icon | import './assets/icons' // icon | ||||
import './permission' // permission control | import './permission' // permission control | ||||
Vue.use(plugins) | Vue.use(plugins) | ||||
Vue.use(VueMeta) | Vue.use(VueMeta) | ||||
Vue.use(FormDesigner) | Vue.use(FormDesigner) | ||||
Vue.use(Toast) // 注册Toast插件 | |||||
DictData.install() | DictData.install() | ||||
/** | /** |
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 | |||||
} | |||||
} |
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 |
<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> |
<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> |
<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> |