@@ -1,5 +1,5 @@ | |||
# 页面标题 | |||
VUE_APP_TITLE = 逐世后台管理系统 | |||
VUE_APP_TITLE = Digital Office Automation System | |||
# 开发环境配置 | |||
ENV = 'development' |
@@ -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> |
@@ -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() | |||
/** |
@@ -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 | |||
} | |||
} |
@@ -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 |
@@ -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> |
@@ -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> |
@@ -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> |