Kaynağa Gözat

feat: 新增时间服务模块,提供服务器时间同步功能,优化考勤相关组件使用服务器时间,确保时间准确性。更新了多个组件以使用服务器时间,提升用户体验。

master
lizhuang 2 hafta önce
ebeveyn
işleme
eb94d4641e

+ 11
- 0
src/api/oa/attendance/clockIn.js Dosyayı Görüntüle

@@ -55,6 +55,17 @@ export function getCurrentDayRecord(query) {
})
}

/**
* 获取服务器当前时间
* @returns Promise<Object> 服务器时间信息
*/
export function getServerTime() {
return request({
url: '/dk/app/getRealTime',
method: 'get'
})
}

/**
* 逆地理编码根据经纬度获取地址
* @param {*} longitude 经度

+ 384
- 0
src/utils/timeService.js Dosyayı Görüntüle

@@ -0,0 +1,384 @@
/**
* 时间服务模块
* 提供准确的服务器时间,避免依赖设备本地时间和时区
*/
import { getServerTime } from "@/api/oa/attendance/clockIn";

class TimeService {
constructor() {
// 服务器基准时间信息
this.serverBaseTime = {
year: 0,
month: 0,
day: 0,
hour: 0,
minute: 0,
second: 0,
timestamp: 0
};
// 本地性能计时器基准点(performance.now()的值)
this.performanceTimeBase = 0;
// 最后一次同步的performance时间
this.lastSyncPerformanceTime = 0;
// 同步间隔(5分钟)
this.syncInterval = 5 * 60 * 1000;
// 是否正在同步
this.syncing = false;
// 是否已成功同步过服务器时间
this.hasSyncedServer = false;
// 时间更新回调函数列表
this.updateCallbacks = [];
// 更新循环是否已启动
this.updateLoopStarted = false;
}

/**
* 解析服务器时间字符串为时间组件
* @param {string} timeString 时间字符串 (YYYY-MM-DD HH:mm:ss 格式)
* @returns {Object} 时间组件对象
*/
parseTimeString(timeString) {
const parts = timeString.split(' ');
const datePart = parts[0].split('-');
const timePart = parts[1].split(':');
return {
year: parseInt(datePart[0]),
month: parseInt(datePart[1]),
day: parseInt(datePart[2]),
hour: parseInt(timePart[0]),
minute: parseInt(timePart[1]),
second: parseInt(timePart[2]),
timestamp: new Date(timeString.replace(/-/g, '/')).getTime()
};
}

/**
* 格式化时间组件为字符串
* @param {Object} timeComponents 时间组件
* @returns {string} 格式化的时间字符串
*/
formatTimeComponents(timeComponents) {
const pad = (num) => String(num).padStart(2, '0');
return `${timeComponents.year}-${pad(timeComponents.month)}-${pad(timeComponents.day)} ${pad(timeComponents.hour)}:${pad(timeComponents.minute)}:${pad(timeComponents.second)}`;
}

/**
* 给时间组件添加毫秒数(纯数学计算,不依赖Date对象)
* @param {Object} baseTime 基准时间组件
* @param {number} milliseconds 要添加的毫秒数
* @returns {Object} 新的时间组件
*/
addMilliseconds(baseTime, milliseconds) {
// 复制基准时间
let result = { ...baseTime };
// 将毫秒转换为秒并添加
const totalSeconds = Math.floor(milliseconds / 1000);
result.second += totalSeconds;
// 处理秒的进位
if (result.second >= 60) {
const additionalMinutes = Math.floor(result.second / 60);
result.second = result.second % 60;
result.minute += additionalMinutes;
}
// 处理分钟的进位
if (result.minute >= 60) {
const additionalHours = Math.floor(result.minute / 60);
result.minute = result.minute % 60;
result.hour += additionalHours;
}
// 处理小时的进位
if (result.hour >= 24) {
const additionalDays = Math.floor(result.hour / 24);
result.hour = result.hour % 24;
result.day += additionalDays;
// 处理跨月、跨年的复杂情况(简化处理)
// 这里可以根据需要进一步完善
result = this.handleDayOverflow(result);
}
// 更新时间戳
result.timestamp = baseTime.timestamp + milliseconds;
return result;
}

/**
* 处理日期的进位(月、年)
* @param {Object} timeComponents 时间组件
* @returns {Object} 处理后的时间组件
*/
handleDayOverflow(timeComponents) {
const daysInMonth = this.getDaysInMonth(timeComponents.year, timeComponents.month);
if (timeComponents.day > daysInMonth) {
const monthsToAdd = Math.floor((timeComponents.day - 1) / daysInMonth);
timeComponents.day = ((timeComponents.day - 1) % daysInMonth) + 1;
timeComponents.month += monthsToAdd;
if (timeComponents.month > 12) {
const yearsToAdd = Math.floor((timeComponents.month - 1) / 12);
timeComponents.month = ((timeComponents.month - 1) % 12) + 1;
timeComponents.year += yearsToAdd;
}
}
return timeComponents;
}

/**
* 获取指定年月的天数
* @param {number} year 年份
* @param {number} month 月份 (1-12)
* @returns {number} 该月的天数
*/
getDaysInMonth(year, month) {
// 使用数组而不是Date对象来避免时区问题
const daysInMonths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (month === 2 && this.isLeapYear(year)) {
return 29;
}
return daysInMonths[month - 1];
}

/**
* 判断是否为闰年
* @param {number} year 年份
* @returns {boolean} 是否为闰年
*/
isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
}

/**
* 同步服务器时间
* @returns {Promise<boolean>} 同步是否成功
*/
async syncServerTime() {
if (this.syncing) {
return false;
}

this.syncing = true;

try {
const syncStartPerformance = performance.now();
const response = await getServerTime();
const syncEndPerformance = performance.now();

if (response.code === 200 && response.data) {
console.log(response.data);
// 计算网络延迟
const networkDelay = (syncEndPerformance - syncStartPerformance) / 2;
// 解析服务器时间并添加网络延迟补偿
this.serverBaseTime = this.parseTimeString(response.data);
this.serverBaseTime = this.addMilliseconds(this.serverBaseTime, networkDelay);
// 记录performance基准时间点
this.performanceTimeBase = syncEndPerformance;
this.lastSyncPerformanceTime = syncEndPerformance;
this.hasSyncedServer = true;

console.log("服务器时间同步成功", {
serverTime: this.formatTimeComponents(this.serverBaseTime),
performanceBase: this.performanceTimeBase,
networkDelay,
});

return true;
}
} catch (error) {
console.warn("服务器时间同步失败,将使用本地时间:", error.message);
} finally {
this.syncing = false;
}

return false;
}

/**
* 获取当前时间组件
* @returns {Object} 当前时间组件
*/
getCurrentTimeComponents() {
const currentPerformanceTime = performance.now();

// 检查是否需要重新同步(基于performance时间)
if (this.hasSyncedServer &&
currentPerformanceTime - this.lastSyncPerformanceTime > this.syncInterval) {
// 异步同步,不阻塞当前调用
this.syncServerTime();
}

// 如果已同步过服务器时间,使用服务器时间基准
if (this.hasSyncedServer) {
// 计算从基准点开始经过的时间
const elapsedTime = currentPerformanceTime - this.performanceTimeBase;
return this.addMilliseconds(this.serverBaseTime, elapsedTime);
}

// 降级到本地时间(解析当前本地时间)
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const day = now.getDate();
const hour = now.getHours();
const minute = now.getMinutes();
const second = now.getSeconds();
return {
year, month, day, hour, minute, second,
timestamp: now.getTime()
};
}

/**
* 获取当前准确时间(兼容性方法)
* @returns {Date} 当前时间Date对象
*/
getCurrentTime() {
const timeComponents = this.getCurrentTimeComponents();
return new Date(timeComponents.timestamp);
}

/**
* 获取格式化的当前时间字符串(用于显示)
* @returns {string} HH:mm:ss 格式的时间
*/
getCurrentTimeString() {
const timeComponents = this.getCurrentTimeComponents();
const pad = (num) => String(num).padStart(2, "0");
return `${pad(timeComponents.hour)}:${pad(timeComponents.minute)}:${pad(timeComponents.second)}`;
}

/**
* 获取完整的当前日期时间字符串(用于提交)
* @returns {string} YYYY-MM-DD HH:mm:ss 格式的时间
*/
getCurrentDateTimeString() {
const timeComponents = this.getCurrentTimeComponents();
const pad = (num) => String(num).padStart(2, "0");
return `${timeComponents.year}-${pad(timeComponents.month)}-${pad(timeComponents.day)} ${pad(timeComponents.hour)}:${pad(timeComponents.minute)}:${pad(timeComponents.second)}`;
}

/**
* 比较当前时间与计划时间
* @param {string} scheduleTime 计划时间 (HH:mm格式)
* @returns {number} 比较结果 (-1: 早于, 0: 等于, 1: 晚于)
*/
compareTimeWithSchedule(scheduleTime) {
const timeComponents = this.getCurrentTimeComponents();
const [scheduleHour, scheduleMinute] = scheduleTime.split(":").map(Number);

const currentMinutes = timeComponents.hour * 60 + timeComponents.minute;
const scheduleMinutes = scheduleHour * 60 + scheduleMinute;

return Math.sign(currentMinutes - scheduleMinutes);
}

/**
* 初始化时间服务
* @returns {Promise<boolean>} 返回初始同步是否成功
*/
async initialize() {
const syncSuccess = await this.syncServerTime();

// 使用基于performance时间的定期同步检查
// 而不是依赖系统时间的setInterval
this.startPeriodicSync();

return syncSuccess;
}

/**
* 启动基于performance时间的定期同步
* 避免受设备时间修改影响
*/
startPeriodicSync() {
const checkSync = () => {
const currentPerformanceTime = performance.now();
// 如果距离上次同步超过间隔时间,执行同步
if (this.hasSyncedServer &&
currentPerformanceTime - this.lastSyncPerformanceTime > this.syncInterval) {
this.syncServerTime();
}
// 每30秒检查一次是否需要同步
setTimeout(checkSync, 30 * 1000);
};
// 30秒后开始第一次检查
setTimeout(checkSync, 30 * 1000);
}

/**
* 添加时间更新回调
* @param {Function} callback 回调函数
* @returns {Function} 用于移除回调的函数
*/
onTimeUpdate(callback) {
this.updateCallbacks.push(callback);
// 启动更新循环(如果还没启动)
if (!this.updateLoopStarted) {
this.startUpdateLoop();
}
// 返回移除回调的函数
return () => {
const index = this.updateCallbacks.indexOf(callback);
if (index > -1) {
this.updateCallbacks.splice(index, 1);
}
};
}

/**
* 启动基于requestAnimationFrame的更新循环
* 确保稳定的1秒更新频率,不受系统时间影响
*/
startUpdateLoop() {
if (this.updateLoopStarted) return;
this.updateLoopStarted = true;
let lastUpdateTime = performance.now();
const updateLoop = () => {
const currentTime = performance.now();
// 每秒触发一次更新(基于performance时间)
if (currentTime - lastUpdateTime >= 1000) {
// 执行所有回调
this.updateCallbacks.forEach(callback => {
try {
callback();
} catch (error) {
console.error('时间更新回调执行失败:', error);
}
});
lastUpdateTime = currentTime;
}
requestAnimationFrame(updateLoop);
};
requestAnimationFrame(updateLoop);
}
}

// 创建全局时间服务实例
const timeService = new TimeService();

export default timeService;

+ 0
- 4
src/views/index.vue Dosyayı Görüntüle

@@ -12,10 +12,6 @@
<span>{{ userinfo.userName }}</span>
<el-divider direction="vertical" />
<span>{{ userinfo.dept.deptName || $t('home.dept.noSet') }}</span>
<template v-if="userinfo.joinedDate">
<el-divider direction="vertical" />
<span>{{ $t('home.joinedDate') }}:{{ userinfo.joinedDate }} </span>
</template>
</p>
<p v-if="hasBirthday" class="birthday-message">
🎂{{ birthdayMessage }}🎉🎁🎈

+ 35
- 18
src/views/oa/attendance/checkin/index.vue Dosyayı Görüntüle

@@ -116,7 +116,7 @@
<div v-else class="container loading">
<div class="loading-text">
<!-- 正在加载考勤信息... -->
<i class="el-icon-loading"></i>
<i class="el-icon-loading" />
</div>
</div>
</div>
@@ -129,6 +129,7 @@ import SuccessDialog from "./components/SuccessDialog.vue";
import LocationInfo from "./components/LocationInfo.vue";
import { getGeocode } from "@/api/oa/attendance/clockIn";
import moment from "moment";
import timeService from "@/utils/timeService";
export default {
name: "MCheckin",
components: {
@@ -141,7 +142,7 @@ export default {
// 时间相关
currentTime: "",
currentCompleteDate: "",
timeIntervalId: null,
removeTimeUpdateCallback: null,

// 打卡成功地址
formattedAddress: "",
@@ -179,14 +180,15 @@ export default {

hasBirthday() {
if (!this.userinfo.birthday) return false;
const today = new Date().toISOString().split("T")[0];
// 使用服务器时间组件而不是系统时间
const timeComponents = timeService.getCurrentTimeComponents();
const birthday = this.userinfo.birthday.split("-");

const month = birthday[1];
const day = birthday[2];

const todayMonth = today.split("-")[1];
const todayDay = today.split("-")[2];
const todayMonth = String(timeComponents.month).padStart(2, "0");
const todayDay = String(timeComponents.day).padStart(2, "0");

return month == todayMonth && day == todayDay;
},
@@ -199,11 +201,14 @@ export default {
},

/**
* 获取当前时区
* 获取当前时区,根据考勤组时区
* @returns {string} 时区信息
*/
timeZone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
return (
this.attendanceGroup.timeZone ||
Intl.DateTimeFormat().resolvedOptions().timeZone
);
},

/**
@@ -240,7 +245,7 @@ export default {
}
await this.initializeApp();
} catch (error) {
this.handleError(i18n.t("checkin.error.init"), error);
// this.handleError(this.$i18n.t("checkin.error.init"), error);
}
},

@@ -253,28 +258,40 @@ export default {
* 初始化应用
*/
async initializeApp() {
// 初始化时间服务(获取服务器时间)
try {
const syncSuccess = await timeService.initialize();
if (!syncSuccess) {
console.warn("服务器时间同步失败,将使用设备本地时间");
// 可以选择性地显示警告提示
// this.$toast.warning('时间同步失败,请确保网络连接正常');
}
} catch (error) {
console.error("时间服务初始化失败:", error);
// 降级到本地时间,不影响正常功能
}

this.startTimeUpdate();
await this.startLocationTracking();
await this.loadAttendanceData();
},

/**
* 开始时间更新
* 开始时间更新(使用稳定的服务器时间更新机制)
*/
startTimeUpdate() {
this.updateCurrentTime();
this.timeIntervalId = setInterval(() => {
// 使用timeService的稳定更新回调,不受系统时间影响
this.removeTimeUpdateCallback = timeService.onTimeUpdate(() => {
this.updateCurrentTime();
}, 1000);
});
},

/**
* 更新当前时间显示
* 更新当前时间显示(使用服务器时间)
*/
updateCurrentTime() {
this.currentTime = new Date().toLocaleTimeString("zh-CN", {
hour12: false,
});
this.currentTime = timeService.getCurrentTimeString();
},

/**
@@ -489,7 +506,7 @@ export default {

await AttendanceService.submitPunch(punchData);

this.currentCompleteDate = AttendanceService.formatDateTime(new Date());
this.currentCompleteDate = AttendanceService.formatDateTime();
this.showSuccessDialog = true;
this.formattedAddress = "Loading...";

@@ -567,8 +584,8 @@ export default {
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
}
if (this.timeIntervalId) {
clearInterval(this.timeIntervalId);
if (this.removeTimeUpdateCallback) {
this.removeTimeUpdateCallback();
}
},
},

+ 11
- 13
src/views/oa/attendance/checkin/service.js Dosyayı Görüntüle

@@ -7,6 +7,7 @@ import {
} from "@/api/oa/attendance/clockIn";

import i18n from "@/i18n";
import timeService from "@/utils/timeService";

// 打卡状态常量
export const PUNCH_STATUS = {
@@ -32,14 +33,16 @@ export class AttendanceService {
/**
* 创建安全的 Date 对象(兼容 IOS 系统)
* @param {string|number|Date} input 输入的日期参数
* @param {boolean} useServerTime 是否使用服务器时间(默认为true)
* @returns {Date} 有效的 Date 对象
* @throws {Error} 当无法创建有效日期时抛出错误
*/
static createSafeDate(input) {
static createSafeDate(input, useServerTime = true) {
let date;

if (!input) {
date = new Date();
// 如果没有输入参数,优先使用服务器时间
date = useServerTime ? timeService.getCurrentTime() : new Date();
} else if (input instanceof Date) {
date = input;
} else if (typeof input === "string") {
@@ -102,18 +105,12 @@ export class AttendanceService {
}

/**
* 比较当前时间与计划时间
* 比较当前时间与计划时间(使用服务器时间)
* @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);
return timeService.compareTimeWithSchedule(scheduleTime);
}

/**
@@ -154,11 +151,12 @@ export class AttendanceService {

/**
* 格式化日期时间
* @param {Date} date 日期对象
* @param {Date} date 日期对象,如果不传则使用服务器当前时间
* @returns {string} 格式化后的日期时间
*/
static formatDateTime(date) {
return parseTime(date, "{y}-{m}-{d} {h}:{i}:{s}");
const targetDate = date || timeService.getCurrentTime();
return parseTime(targetDate, "{y}-{m}-{d} {h}:{i}:{s}");
}

/**
@@ -239,7 +237,7 @@ export class AttendanceService {

const isClockIn = type.includes("morning");

// 使用安全的日期创建方法
// 使用安全的日期创建方法(优先使用服务器时间)
const currentDate = this.createSafeDate();
const currentTime = this.formatDateTime(currentDate);


+ 27
- 0
src/views/oa/attendance/group/index.vue Dosyayı Görüntüle

@@ -26,6 +26,7 @@
下班{{ scope.row.workEndTime }},午休:{{ scope.row.lunchTime }}
小时
</p>
<p>时区:{{ scope.row.timeZone || "未设置" }}</p>
<p>关联员工:{{ scope.row.members.length }}人</p>
<p>打卡地点:{{ areaName(scope.row.areaId) || "Loading" }}</p>
</template>
@@ -94,6 +95,21 @@
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="6">
<el-form-item label="考勤时区" prop="">
<el-select
v-model="attendanceGroup.timeZone"
placeholder="请选择考勤时区"
>
<el-option
v-for="item in timeZones"
:key="item.value"
:label="item.label"
:value="item.value"
></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="上班时间" prop="workStartTime">
<el-time-picker
@@ -318,6 +334,7 @@ export default {
checkInType: "location",
members: [],
areaName: "",
timeZone: "",
},
attendanceGroupModel: {
id: null,
@@ -337,6 +354,7 @@ export default {
checkInType: "location",
members: [],
areaName: "",
timeZone: "",
},
// 工作日选项
workDays: [
@@ -390,12 +408,20 @@ export default {
areaId: [
{ required: true, message: "请选择打卡区域", trigger: "change" },
],
timeZone: [
{ required: true, message: "请选择考勤时区", trigger: "change" },
],
},
// 编辑模式
isEditMode: false,
// 提示信息
successMessage: "",
successMessageVisible: false,
// 考勤时区
timeZones: [
{ value: "Asia/Shanghai", label: "东八区(Asia/Shanghai)" },
{ value: "Asia/Tokyo", label: "东九区(Asia/Tokyo)" },
],
};
},
computed: {
@@ -636,6 +662,7 @@ export default {
areaId: this.attendanceGroup.areaId,
areaName: "",
members: this.attendanceGroup.members,
timeZone: this.attendanceGroup.timeZone,
};
add(newGroup).then((res) => {
this.getList();

+ 2
- 0
src/views/oa/attendance/history/index.vue Dosyayı Görüntüle

@@ -77,6 +77,7 @@
<el-table-column
label="考勤组"
prop="attendanceGroupName"
width="120"
></el-table-column>
<el-table-column label="上班" width="200">
<template slot-scope="scope">
@@ -132,6 +133,7 @@
</span>
</template>
</el-table-column>
<el-table-column label="最后更新时间" prop="updateTime" width="200"></el-table-column>
<el-table-column label="备注" prop="description"></el-table-column>
</el-table>


Loading…
İptal
Kaydet