原型图要求​ 1. 整个系统中只有一个运行中的计时器全局计时器和页面计时器和页面计时器在id相同是保持同频。​ 2. 多页面打开所有全局计时器运行状态为同一个。​ 3. 在页面关闭浏览器关闭用户退出电脑关闭时计时器已经记录时间不能丢失。​ 4. 同一个计时器同一时间只能让一个人操作。注意事项​ 1. 30s同步一次时间保证时间误差在1s内​ 2. 多个页面计时器同步​ 3. 当有计时器运行时其他计时器不能点击​ 4. 其他人可以给别人添加计时在有其他人添加的计时时当前人能添加新的计时​ 5. 异常场景当页面异常关闭时或刷新是还会继续计不停止计时器不能退出登录​ 6. 页面聚焦时更新时间​ 7. 给其他人计时还能给自己计时吗给其他人计时占用自己的全局计时器其他人可以计时自己不能计时了实现方案步骤1开发单个计时器组件步骤2使用PiniaWebSocket定义全局计时器保证全局只有一个运行的计时器步骤3页面的定时器非全局根据定时器id判断该定时器是否是正在运行的全局计时器步骤4页面初始化时调用接口获取正在运行的全局计时器步骤5点击开始计时时发送 http 请求服务端开始计并通过websocket通知其他全部客户端停止与开发同理步骤6服务端30s发送矫正前后端计时器时间事件保证时间误差在1s内模块整体架构代码结构. ├── common-package # 框架公共包Git子模块由中台维护 │ └── components # 框架公共基础组件 │ └── timer │ ├── store │ │ └── useTimerStore.ts # 全局计时器的 hooks │ ├── CTimer.vue # 全局计时器组件 │ └── enum.ts # 全局计时器枚举 ├── src # 主目录 │ ├── api # 接口文件 │ │ ├── timer.ts # 计时器接口 │ │ └── types # 接口类型定义文件 │ ├── components # 项目公共组件 │ │ └── SaveTimerDialog.vue # 保存计时器弹框 │ ├── layouts # 布局文件 │ │ │ ├── DefaultLayoutWithHorizontalNav.vue # 纵向布局导航文件引入全局计时器组件 │ │ │ └── DefaultLayoutWithVerticalNav.vue # 横向布局导航文件引入全局计时器组件 │ │ └── default.vue # 默认布局文件引入计时器弹框 │ ├── pages # 页面入口文件 │ │ └── sample-page # 示例菜单 │ │ └── Timer.vue # 计时器页面 │ ├── App.vue # 主入口模板文件 │ └── main.ts # 主入口 └── ytt.config.ts # 配置生成接口组件结构数据流向图计时器同步规则前后端对接方案需要长连接的接口网易云信IM通知全局计时器状态更新不需要长连接的接口客户端获取全局定时器状态返回值{id: , action: , 计时开始时间戳已经进行的时间是否开启计时器}页面初始化开始计时器停止计时器获取全局计时器状态获取计时器列表核心代码全局计时器使用 Pinia 存贮文件位置为common-package/components/timer/store/useTimerStore.ts为解决内存占用不同造成的定时器时间误差问题采取每秒钟取一次当前时间的方式更新时间// import { defineStore } from pinia; export interface BaseTimerData { id: number // 定时器ID duration: number // 定时器时间秒 status: number // 定时器状态 0: 停止 1: 运行 } /** * 将秒转化成HH:mm:ss的格式 * param seconds 秒数 */ export const formatterSecond2Str (seconds: number) { let hours: number | string Math.floor(seconds / 3600) let minutes: number | string Math.floor((seconds % 3600) / 60) let secs: number | string Math.floor(seconds % 60) // 将小时、分钟和秒数格式化为两位数 hours String(hours).padStart(2, 0) minutes String(minutes).padStart(2, 0) secs String(secs).padStart(2, 0) return ${hours}:${minutes}:${secs} } export const useTimerStore defineStore(timer, () { const SDK (window as any).SDK const nim refany(null) const isConnected ref(false) const timerData refBaseTimerData({ id: 0, // 定时器ID duration: 0, // 定时器时间秒 status: 0, // 0: 停止 1: 运行 }) // 定时器数据 let timerInstance: any null // 定时器实例 let isInitialized false // 防止多次调用连接方法 let currentTimestamp: number 0 // 当前秒的时间戳 let initSecond: number 0 let cycleIndex: number 0 // 30s同步一次数据 // 计算属性组件显示时间或调用接口传参使用 const formatterHour computed(() timerData.value.duration / 3600 || 0) // 将秒转化成HH:mm:ss的格式 const formatterTimer computed(() { // 确保输入是数字并且是非负数 const seconds Number.isNaN(timerData.value.duration) ? 0 : Math.abs(timerData.value.duration || 0) return formatterSecond2Str(seconds) }) // 将秒转化成HH:mm:ss的格式 // 连接websocket const initNim (account: string, token: string) { if (isInitialized) return isInitialized true nim.value SDK.NIM.getInstance({ appKey: 3edf7e3907b1a0dba7c6af6470d8f2d6, // 替换为你的应用 AppKey account, token, debug: true, onconnect: () { isConnected.value true console.log(连接成功) }, onerror: (error: any) { isConnected.value false console.error(连接失败, error) }, ondisconnect: (error: any) { isConnected.value false console.log(连接断开, error) }, onmsg: (msg: any) { console.log(收到消息, msg) eventBus.emit(c-update-timer) }, }) } // 发送消息 const sendMessage (to: string, text: string) { if (!nim.value) { console.error(NIM SDK 未初始化) return } nim.value.sendText({ scene: p2p, to, text, done: (error: any, msg: any) { if (error) console.error(发送消息失败, error) else console.log(发送消息成功, msg) }, }) } // 停止计时器 const stopTimer (emit: boolean true, data: BaseTimerData | undefined | null) { if (!timerData.value.status) return if (data) { // 停止计时以传入的数据为准更新各个计时器时间 eventBus.emit(c-stop-timer, JSON.parse(JSON.stringify(data))) } else if (emit) { eventBus.emit(c-stop-timer, JSON.parse(JSON.stringify(timerData.value))) } timerData.value { id: 0, duration: 0, status: 0, } if (timerInstance) { clearInterval(timerInstance) timerInstance null } } /** * 同步更新定时器时间 * param id 定时器ID * param sec 定时器秒数 */ const updateTimer (data: BaseTimerData) { // 如果status是0则定时器已经停止 if (!data.status) { // 自动同步计时器数据停止时不触发停止事件防止多次触发弹框 stopTimer(false, null) return } // 当前秒的时间戳 currentTimestamp Math.floor(new Date().getTime() / 1000) initSecond data.duration || 0 timerData.value data if (timerInstance) { clearInterval(timerInstance) timerInstance null } timerInstance setInterval(() { // 计时器中通过电脑本地时间更新计时器减小因内存占用不同造成的setInterval的间隔不同的问题 timerData.value.duration Math.floor(new Date().getTime() / 1000) - currentTimestamp initSecond // 30s同步一次数据 cycleIndex if (cycleIndex 30) { cycleIndex 0 eventBus.emit(c-update-timer) // updateTimer(timerId.value) } }, 1000) } return { nim, isConnected, timerData, formatterHour, formatterTimer, initNim, sendMessage, updateTimer, stopTimer, } })全局计时器组件定义在 common-package/component/timer/CTimer.vue 中数据以Pinia数据和props数据为基础做显示script setup langts import { TIMER_TYPE } from ./enum import type { BaseTimerData } from ./store/useTimerStore import { formatterSecond2Str, useTimerStore } from ./store/useTimerStore interface Props { type: string data: BaseTimerData } interface Emit { (e: handleStop, value: number): void (e: handleUpdate): void (e: handleClick, value: BaseTimerData, type: string): void (e: handleUpdateDuration, value: number): void } const props withDefaults(definePropsProps(), { type: , data() { return { duration: 0, status: 0, id: 0 } }, }) const emit defineEmitsEmit() const timeStore useTimerStore() // 计时器显示的时间 const timeStr computed(() { // 如果是全局计时器则显示全局计时器时间 if (props.type TIMER_TYPE.ALL_TIMER) return timeStore.formatterTimer // 如果是页面计时器且和全局计时器ID相同则显示全局计时器时间 if (props.data.id timeStore.timerData.id) return timeStore.formatterTimer // 如果是页面计时器且和全局计时器ID不同则显示页面计时器时间 return formatterSecond2Str(props.data.duration || 0) }) // 计时器是否禁用 const timerDisabled computed(() { return timeStore.timerData.status props.data.id ! timeStore.timerData.id }) // 监听全局计时器停止事件 const handleStopTimer ({ id, duration }: BaseTimerData) { // 更新子计时器时间 if (props.data props.data.id id) emit(handleUpdateDuration, duration) // 如果是全局计时器则显示全局计时器时间 if (props.type TIMER_TYPE.ALL_TIMER) emit(handleStop, id) } // 监听全局计时器更新事件 const handleUpdateTimer () { // 如果是全局计时器则显示全局计时器时间 if (props.type TIMER_TYPE.ALL_TIMER) emit(handleUpdate) } // 页面获取焦点时更新计时器时间 const handleVisibilityChange () { if (document.visibilityState visible props.type TIMER_TYPE.ALL_TIMER) { console.log(页面重新获取焦点变为可见) handleUpdateTimer() } } onMounted(() { eventBus.on(c-stop-timer, handleStopTimer) eventBus.on(c-update-timer, handleUpdateTimer) if (props.type TIMER_TYPE.ALL_TIMER) document.addEventListener(visibilitychange, handleVisibilityChange) }) onUnmounted(() { eventBus.off(c-stop-timer, handleStopTimer) eventBus.off(c-update-timer, handleUpdateTimer) if (props.type TIMER_TYPE.ALL_TIMER) document.removeEventListener(visibilitychange, handleVisibilityChange) }) // 点击计时器如果当前计时器运行中则暂停如果没有运行中的计时器则启动计时器运行中的计时器非当前计时器不可点击 const clickTimer () { if (timerDisabled.value) return emit(handleClick, JSON.parse(JSON.stringify(props.data)), props.type) } /script template div classcursor-pointer :class{ disabled: timerDisabled } clickclickTimer {{ timeStr }} /div /template style langscss .disabled { cursor: not-allowed !important; } /style在 layout/default.vue 中做全局计时器操作template CTimer :datatimeStore.timerData :typeTIMER_TYPE.ALL_TIMER handle-clickclickTimer handle-updategetGlobalTimer handle-stophandleStopTimer / !-- 定时器数据保存弹框 -- SaveTimerDialog/SaveTimerDialog /template script langts setup import { createTimer, deleteTimer, getAllTimers, getTimerDetail } from /api/timer import type { CalculagraphRes } from /api/types/calculagraph.ts import { TIMER_TYPE } from common-package/components/timer/enum import type { BaseTimerData } from common-package/components/timer/store/useTimerStore import { useTimerStore } from common-package/components/timer/store/useTimerStore const timeStore useTimerStore() const uid ref(63) onMounted(() { pageVisibility() // 初始化网易IM timeStore.initNim(accid-${uid.value}, token-${uid.value}) getGlobalTimer() }); // 页面初始化获取定时器数据 const getGlobalTimer () { const res await getTimerDetail(uid.value) if (res.code ! 200) { toast({ message: res.msg, color: error, }) } else if (res.data res.data.status) { // 如果有正在运行的全局计时器则更新全局计时器 const data res.data as CalculagraphRes BaseTimerData timeStore.updateTimer(data) } else if (timeStore.timerData.status) { // 接口全局计时状态关闭时且前端计时器正在运行时停止计时器 const data res.data as CalculagraphRes BaseTimerData timeStore.stopTimer(true, data) } } // 停止计时器弹出确认框 const stopGlobalTimer (id: number) { console.log(handleStopTimer, id) } /** * 点击计时器判断停止还是开启计时器 * param data 计时器数据 */ const clickTimer async (data: BaseTimerData) { if (timeStore.timerData.status) { const res await deleteTimer(timeStore.timerData.id) if (res.code 200) { // 停止计时器 timeStore.stopTimer(true, null) getTimerList() timeStore.sendMessage(accid-${uid.value}, stop) } } else { data.status 1 const res await createTimer(data) if (res.code 200 res.data) { // 开启计时器 timeStore.updateTimer(res.data) getTimerList() timeStore.sendMessage(accid-${uid.value}, start) } } } /script在 sample-page/timer.vue 中做页面计时器操作script langts setup import { createTimer, deleteTimer, getAllTimers } from /api/timer import type { CalculagraphRes } from /api/types/calculagraph.ts import type { CalculagraphPageAllRes } from /api/types/calculagraph/pageAll.ts import { TIMER_TYPE } from common-package/components/timer/enum import type { BaseTimerData } from common-package/components/timer/store/useTimerStore import { useTimerStore } from common-package/components/timer/store/useTimerStore const timeStore useTimerStore() const currentTimerData refCalculagraphPageAllRes({}) const uid ref(63) const getTimerList async () { const res await getAllTimers({ pageNum: 1, pageSize: 100 }) if (res.code 200) currentTimerData.value res.data as CalculagraphPageAllRes } onMounted(() { getTimerList() }) // 停止计时器弹出确认框 const handleStopTimer (id: number) { console.log(handleStopTimer, id) } /** * 点击计时器判断停止还是开启计时器 * param data 计时器数据 */ const clickTimer async (data: BaseTimerData) { if (timeStore.timerData.status) { const res await deleteTimer(timeStore.timerData.id) if (res.code 200) { // 停止计时器 timeStore.stopTimer(true, null) getTimerList() timeStore.sendMessage(accid-${uid.value}, stop) } } else { data.status 1 const res await createTimer(data) if (res.code 200 res.data) { // 开启计时器 timeStore.updateTimer(res.data) getTimerList() timeStore.sendMessage(accid-${uid.value}, start) } } } /script template VCard title计时器 VCardText div classtext-h5 计时器列表 /div VRow VCol v-foritem in currentTimerData.records :keyitem.id cols3 div classtext-body-1 ID {{ item.id }} /div CTimer :dataitem :typeTIMER_TYPE.PAGE_TIMER handle-clickclickTimer handle-update-durationitem.duration $event / /VCol /VRow /VCardText /VCard /template