瀏覽代碼

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

master
lizhuang 2 週之前
父節點
當前提交
eb94d4641e

+ 11
- 0
src/api/oa/attendance/clockIn.js 查看文件

}) })
} }


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

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

+ 384
- 0
src/utils/timeService.js 查看文件

/**
* 时间服务模块
* 提供准确的服务器时间,避免依赖设备本地时间和时区
*/
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 查看文件

<span>{{ userinfo.userName }}</span> <span>{{ userinfo.userName }}</span>
<el-divider direction="vertical" /> <el-divider direction="vertical" />
<span>{{ userinfo.dept.deptName || $t('home.dept.noSet') }}</span> <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>
<p v-if="hasBirthday" class="birthday-message"> <p v-if="hasBirthday" class="birthday-message">
🎂{{ birthdayMessage }}🎉🎁🎈 🎂{{ birthdayMessage }}🎉🎁🎈

+ 35
- 18
src/views/oa/attendance/checkin/index.vue 查看文件

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


// 打卡成功地址 // 打卡成功地址
formattedAddress: "", formattedAddress: "",


hasBirthday() { hasBirthday() {
if (!this.userinfo.birthday) return false; 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 birthday = this.userinfo.birthday.split("-");


const month = birthday[1]; const month = birthday[1];
const day = birthday[2]; 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; return month == todayMonth && day == todayDay;
}, },
}, },


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


/** /**
} }
await this.initializeApp(); await this.initializeApp();
} catch (error) { } catch (error) {
this.handleError(i18n.t("checkin.error.init"), error);
// this.handleError(this.$i18n.t("checkin.error.init"), error);
} }
}, },


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

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


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


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


/** /**


await AttendanceService.submitPunch(punchData); await AttendanceService.submitPunch(punchData);


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


if (this.watchId !== null) { if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId); 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 查看文件

} from "@/api/oa/attendance/clockIn"; } from "@/api/oa/attendance/clockIn";


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


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


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


/** /**
* 比较当前时间与计划时间
* 比较当前时间与计划时间(使用服务器时间)
* @param {string} scheduleTime 计划时间 (HH:mm格式) * @param {string} scheduleTime 计划时间 (HH:mm格式)
* @returns {number} 比较结果 (-1: 早于, 0: 等于, 1: 晚于) * @returns {number} 比较结果 (-1: 早于, 0: 等于, 1: 晚于)
*/ */
static compareTimeWithSchedule(scheduleTime) { 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);
} }


/** /**


/** /**
* 格式化日期时间 * 格式化日期时间
* @param {Date} date 日期对象
* @param {Date} date 日期对象,如果不传则使用服务器当前时间
* @returns {string} 格式化后的日期时间 * @returns {string} 格式化后的日期时间
*/ */
static formatDateTime(date) { 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}");
} }


/** /**


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


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



+ 27
- 0
src/views/oa/attendance/group/index.vue 查看文件

下班{{ scope.row.workEndTime }},午休:{{ scope.row.lunchTime }} 下班{{ scope.row.workEndTime }},午休:{{ scope.row.lunchTime }}
小时 小时
</p> </p>
<p>时区:{{ scope.row.timeZone || "未设置" }}</p>
<p>关联员工:{{ scope.row.members.length }}人</p> <p>关联员工:{{ scope.row.members.length }}人</p>
<p>打卡地点:{{ areaName(scope.row.areaId) || "Loading" }}</p> <p>打卡地点:{{ areaName(scope.row.areaId) || "Loading" }}</p>
</template> </template>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="10"> <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-col :span="6">
<el-form-item label="上班时间" prop="workStartTime"> <el-form-item label="上班时间" prop="workStartTime">
<el-time-picker <el-time-picker
checkInType: "location", checkInType: "location",
members: [], members: [],
areaName: "", areaName: "",
timeZone: "",
}, },
attendanceGroupModel: { attendanceGroupModel: {
id: null, id: null,
checkInType: "location", checkInType: "location",
members: [], members: [],
areaName: "", areaName: "",
timeZone: "",
}, },
// 工作日选项 // 工作日选项
workDays: [ workDays: [
areaId: [ areaId: [
{ required: true, message: "请选择打卡区域", trigger: "change" }, { required: true, message: "请选择打卡区域", trigger: "change" },
], ],
timeZone: [
{ required: true, message: "请选择考勤时区", trigger: "change" },
],
}, },
// 编辑模式 // 编辑模式
isEditMode: false, isEditMode: false,
// 提示信息 // 提示信息
successMessage: "", successMessage: "",
successMessageVisible: false, successMessageVisible: false,
// 考勤时区
timeZones: [
{ value: "Asia/Shanghai", label: "东八区(Asia/Shanghai)" },
{ value: "Asia/Tokyo", label: "东九区(Asia/Tokyo)" },
],
}; };
}, },
computed: { computed: {
areaId: this.attendanceGroup.areaId, areaId: this.attendanceGroup.areaId,
areaName: "", areaName: "",
members: this.attendanceGroup.members, members: this.attendanceGroup.members,
timeZone: this.attendanceGroup.timeZone,
}; };
add(newGroup).then((res) => { add(newGroup).then((res) => {
this.getList(); this.getList();

+ 2
- 0
src/views/oa/attendance/history/index.vue 查看文件

<el-table-column <el-table-column
label="考勤组" label="考勤组"
prop="attendanceGroupName" prop="attendanceGroupName"
width="120"
></el-table-column> ></el-table-column>
<el-table-column label="上班" width="200"> <el-table-column label="上班" width="200">
<template slot-scope="scope"> <template slot-scope="scope">
</span> </span>
</template> </template>
</el-table-column> </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-column label="备注" prop="description"></el-table-column>
</el-table> </el-table>



Loading…
取消
儲存