27 Commits

Author SHA1 Message Date
caozehui
27b593ba01 微调 2026-06-02 11:21:55 +08:00
caozehui
8c7b164166 微调 2026-06-02 09:25:17 +08:00
caozehui
bdc45b8890 微调 2026-06-01 18:37:31 +08:00
caozehui
2705bedc71 微调 2026-06-01 14:12:47 +08:00
caozehui
ae970d048c 微调 2026-06-01 11:34:39 +08:00
caozehui
29f57c80ef 数据库调整 2026-06-01 11:25:37 +08:00
caozehui
80072bf7e0 资源管理前后端 2026-06-01 11:20:11 +08:00
caozehui
8d377dfed7 微调 2026-06-01 11:09:32 +08:00
caozehui
633b6ffd29 自动播放视频 2026-06-01 11:07:47 +08:00
caozehui
cf3141198b 微调 2026-06-01 09:26:01 +08:00
caozehui
c05d329614 补充观看教学视频路由跳转功能,检测页面微调 2026-05-29 10:39:12 +08:00
caozehui
ee08263e4a 检测计划统计弹窗下拉框内容调整 2026-05-29 09:58:24 +08:00
19ea08d5e0 feat(detection): 添加检测锁机制防止多用户同时操作
- 新增 detectionLock store 管理检测锁状态
- 实现检测锁相关的弹窗提示功能
- 添加 DETECTION_BUSY 错误码处理多人竞争逻辑
- 在 websocket 中集成检测锁超时处理
- 修改程序源控制接口以同步锁状态
- 更新项目标题和图标配置
- 添加 docs 目录到忽略列表
2026-05-28 20:44:53 +08:00
caozehui
f9809197e8 微调 2026-05-28 16:33:07 +08:00
caozehui
0090a922c6 资源管理页面微调 2026-05-28 14:37:40 +08:00
caozehui
0b26de20b9 资源管理 2026-05-28 13:26:35 +08:00
caozehui
1202f64bfc 检测计划统计功能 2026-05-28 08:44:15 +08:00
caozehui
ce1738daf0 微调 2026-05-27 11:20:12 +08:00
caozehui
a41d824ca3 归档 2026-05-26 15:45:08 +08:00
caozehui
ac5a8450e8 微调 2026-05-26 14:23:59 +08:00
caozehui
01e817a5d6 微调 2026-05-26 13:45:23 +08:00
caozehui
01bf07fc42 检测计划统计功能 2026-05-26 09:22:38 +08:00
caozehui
633e914c9a Revert "下拉多选报告模版"
This reverts commit 37e69e7bda.
2026-05-25 18:38:46 +08:00
caozehui
37e69e7bda 下拉多选报告模版 2026-05-25 14:25:57 +08:00
caozehui
19fb90432a 归档 2026-05-25 09:51:42 +08:00
caozehui
4a3c81a792 归档 2026-05-25 09:50:16 +08:00
caozehui
12d3073241 统一sourceId 2026-05-13 09:47:10 +08:00
109 changed files with 2003 additions and 235 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ public/electron/
pnpm-lock.yaml pnpm-lock.yaml
CLAUDE.md CLAUDE.md
/public/dist/ /public/dist/
/docs/

View File

@@ -33,9 +33,9 @@ mybatis-plus:
#驼峰命名 #驼峰命名
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
#配置sql日志输出 #配置sql日志输出
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
#关闭日志输出 #关闭日志输出
# log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
global-config: global-config:
db-config: db-config:
#指定主键生成策略 #指定主键生成策略
@@ -55,30 +55,32 @@ webSocket:
#源参数下发,暂态数据默认值 #源参数下发,暂态数据默认值
Dip: Dip:
#暂态前时间s # 暂态前时间s
fPreTime: 2f # fPreTime: 2f
#写入时间s #写入时间s
fRampIn: 0.001f fRampIn: 0.001f
#写出时间s #写出时间s
fRampOut: 0.001f fRampOut: 0.001f
#暂态后时间s # 暂态后时间s
fAfterTime: 3f # fAfterTime: 3f
Flicker: #Flicker:
waveFluType: CPM # waveFluType: CPM
waveType: SQU # waveType: SQU
fDutyCycle: 50f # fDutyCycle: 50f
log: #log:
homeDir: {{APP_DATA_PATH}}\logs # homeDir: D:\logs
commonLevel: info # commonLevel: info
report: report:
template: {{APP_DATA_PATH}}\template # template: D:\template
reportDir: {{APP_DATA_PATH}}\report # reportDir: D:\report
dateFormat: yyyy年MM月dd日 dateFormat: yyyy年MM月dd日
data: #data:
homeDir: {{APP_DATA_PATH}}\data # homeDir: D:\data
#resource:
# videoDir: ${data.homeDir}\resources\videos
qr: qr:
cloud: http://pqmcc.com:18082/api/file cloud: http://pqmcc.com:18082/api/file
dev: dev:

View File

@@ -1 +1 @@
53820 116212

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -12,3 +12,7 @@
.\binlog.000034 .\binlog.000034
.\binlog.000035 .\binlog.000035
.\binlog.000036 .\binlog.000036
.\binlog.000037
.\binlog.000038
.\binlog.000039
.\binlog.000040

View File

@@ -19,9 +19,10 @@ VITE_API_URL=/api
# 开发环境跨域代理,支持配置多个 # 开发环境跨域代理,支持配置多个
VITE_PROXY=[["/api","http://127.0.0.1:18093/"]] VITE_PROXY=[["/api","http://127.0.0.1:18092/"]]
#VITE_PROXY=[["/api","http://192.168.1.124:18092/"]] #VITE_PROXY=[["/api","http://192.168.1.124:18092/"]]
#VITE_PROXY=[["/api","http://192.168.2.125:18092/"]] #VITE_PROXY=[["/api","http://192.168.2.125:18092/"]]
# VITE_PROXY=[["/api","http://192.168.1.138:8080/"]]张文 # VITE_PROXY=[["/api","http://192.168.1.138:8080/"]]张文
VITE_IS_SHOW_RAW_DATA=true
# 开启激活验证 # 开启激活验证
VITE_ACTIVATE_OPEN=true VITE_ACTIVATE_OPEN=false

View File

@@ -23,6 +23,7 @@ VITE_PWA=true
# 线上环境接口地址 # 线上环境接口地址
#VITE_API_URL="/api" # 打包时用 #VITE_API_URL="/api" # 打包时用
VITE_API_URL="http://127.0.0.1:18093/" VITE_API_URL="http://127.0.0.1:18092/"
VITE_IS_SHOW_RAW_DATA=true
# 开启激活验证 # 开启激活验证
VITE_ACTIVATE_OPEN=true VITE_ACTIVATE_OPEN=false

View File

@@ -4,7 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0" />
<title></title> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<title>NPQS-9100</title>
<!-- 优化vue渲染未完成之前先加一个css动画 --> <!-- 优化vue渲染未完成之前先加一个css动画 -->
<style> <style>
#loadingPage { #loadingPage {

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,15 @@
import http from '@/api'
import type { DetectionLockHolder } from '@/stores/modules/detectionLock'
/**
* 查询当前检测锁持有状态
* - data 为 null → 锁空闲
* - data 非 null → 锁被某账号持有
*
* 本接口只读,不抢锁、不会返回 DETECTION_BUSY
*/
export const getCurrentLock = () => {
return http.get<DetectionLockHolder | null>('/detection/lock/current', undefined, {
loading: false
})
}

View File

@@ -1,6 +1,7 @@
import type { controlSource } from '@/api/device/interface/controlSource' import type { controlSource } from '@/api/device/interface/controlSource'
import http from '@/api' import http from '@/api'
import { useDetectionLockStore } from '@/stores/modules/detectionLock'
/** /**
* @name 程控源管理模块 * @name 程控源管理模块
@@ -17,8 +18,11 @@ export const startSimulateTest = (params: controlSource.ResControl) => {
} }
//停止 //停止
export const closeSimulateTest = (params: controlSource.ResControl) => { export const closeSimulateTest = async (params: controlSource.ResControl) => {
return http.post(`/prepare/closeSimulateTest`,params,{loading:false}) const result = await http.post(`/prepare/closeSimulateTest`,params,{loading:false})
// 主动终止 → 释放本地持锁标记
useDetectionLockStore().clearHolder()
return result
} }

View File

@@ -2,43 +2,44 @@ import type { ReqPage, ResPage } from '@/api/interface'
// 检测源模块 // 检测源模块
export namespace TestSource { export namespace TestSource {
/**
/** * 检测源表格分页查询参数
* 检测脚本表格分页查询参数 */
*/ export interface ReqTestSourceParams extends ReqPage {
export interface ReqTestSourceParams extends ReqPage{ id: string
id: string; // 装置序号id 必填 name: string
name: string; pattern: string
pattern: string; }
}
// 检测源接口 // 检测源接口
export interface ResTestSource { export interface ResTestSource {
id: string; //检测源ID id: string
name?: string; //检测源名称(检测源类型 + 设备类型 + 数字自动生成) name?: string
pattern: string;//检测源模式(字典表Code字段数字、模拟、比对) pattern: string
type: string; //检测源类型(字典表Code字段标准源、高精度设备) type: string
devType: string;//检测源设备类型(字典表Code字段) devType: string
parameter?: string;//源参数JSON字符串 maxVoltage?: number
state:number;// maxCurrent?: number
createBy?: string; parameter?: string
createTime?: string; state: number
updateBy?: string; createBy?: string
updateTime?: string; createTime?: string
updateBy?: string
updateTime?: string
} }
/* 检测脚本查询分页返回的对象; /*
*/ * 检测源查询分页返回的对象
export interface ResTestSourcePage extends ResPage<ResTestSource> { */
export interface ResTestSourcePage extends ResPage<ResTestSource> {}
}
export interface ParameterType{ export interface ParameterType {
id:string; id: string
type:string; type: string
desc:string; desc: string
value:string|null; value: string | null
sort:number; sort: number
pId:string; pId: string
children?:ParameterType[]; children?: ParameterType[]
} }
} }

View File

@@ -12,6 +12,12 @@ import { type ResultData } from '@/api/interface'
import { ResultEnum } from '@/enums/httpEnum' import { ResultEnum } from '@/enums/httpEnum'
import { checkStatus } from './helper/checkStatus' import { checkStatus } from './helper/checkStatus'
import { useUserStore } from '@/stores/modules/user' import { useUserStore } from '@/stores/modules/user'
import { useDetectionLockStore, type DetectionLockHolder } from '@/stores/modules/detectionLock'
import {
showForceReleasedDialog,
showLockBusyDialog,
showLockNotStartedToast
} from '@/utils/detectionLockDialog'
import router from '@/routers' import router from '@/routers'
import { refreshToken } from '@/api/user/login' import { refreshToken } from '@/api/user/login'
import { EventSourcePolyfill } from 'event-source-polyfill' import { EventSourcePolyfill } from 'event-source-polyfill'
@@ -107,6 +113,32 @@ class RequestHttp {
} }
return Promise.reject(data) return Promise.reject(data)
} }
// 单用户检测互斥:命中 DETECTION_BUSY 时根据 data 和本地持锁状态分发到 4 种文案
if (data.code === ResultEnum.DETECTION_BUSY) {
const lockStore = useDetectionLockStore()
const holder = (data.data ?? null) as DetectionLockHolder | null
const currentUserId = userStore.userInfo?.id
const localDetecting = lockStore.iAmHolder
if (!localDetecting && holder) {
// S1:他人持锁
showLockBusyDialog(holder)
} else if (!localDetecting && !holder) {
// S2:未开始检测就调中间接口
showLockNotStartedToast()
} else if (localDetecting && holder && holder.holderUserId !== currentUserId) {
// S4-a:被强释 + 别人接手
showForceReleasedDialog(holder)
lockStore.clearHolder()
} else if (localDetecting && !holder) {
// S4-b:被强释,无人接手
showForceReleasedDialog(null)
lockStore.clearHolder()
}
// 阻断默认错误提示,不再走下面的 ElMessage.error
return Promise.reject(data)
}
// 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错) // 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
if (data.code && data.code !== ResultEnum.SUCCESS) { if (data.code && data.code !== ResultEnum.SUCCESS) {
if (data.message.includes('&')) { if (data.message.includes('&')) {

View File

@@ -69,5 +69,37 @@ export namespace Plan {
maxTime: number; maxTime: number;
} }
export interface PlanStatisticsItem {
itemId: string;
itemName: string;
unqualifiedCount: number;
}
export interface PlanStatisticsOption {
id: string;
name: string;
}
export interface PlanStatistics {
planId: string;
planName: string;
totalCheckCount: number;
checkedDeviceCount: number;
uncheckedDeviceCount: number;
firstQualifiedDeviceCount: number;
secondQualifiedDeviceCount: number;
thirdOrMoreQualifiedDeviceCount: number;
qualifiedDeviceCount: number;
unqualifiedDeviceCount: number;
unqualifiedItemCount: number;
firstPassRate: number;
secondPassRate: number;
thirdOrMorePassRate: number;
unqualifiedRate: number;
itemDistributions: PlanStatisticsItem[];
manufacturerOptions: PlanStatisticsOption[];
devTypeOptions: PlanStatisticsOption[];
}
} }

View File

@@ -94,6 +94,10 @@ export const staticsAnalyse = (params: { id: string[] }) => {
return http.download('/adPlan/analyse', params) return http.download('/adPlan/analyse', params)
} }
export const getPlanStatistics = (params: { planId: string; manufacturer?: string; devType?: string }) => {
return http.post<Plan.PlanStatistics>(`/adPlan/statistics`, params)
}
//根据计划id分页查询被检设 //根据计划id分页查询被检设
export const getDevListByPlanId = (params: any) => { export const getDevListByPlanId = (params: any) => {
return http.post(`/adPlan/listDevByPlanId`, params) return http.post(`/adPlan/listDevByPlanId`, params)
@@ -159,4 +163,4 @@ export const importAndMergePlanCheckData = (params: Plan.ResPlan) => {
return http.upload(`/adPlan/importAndMergePlanCheckData`, params, { return http.upload(`/adPlan/importAndMergePlanCheckData`, params, {
timeout: 60000 * 20 timeout: 60000 * 20
}) })
} }

View File

@@ -0,0 +1,18 @@
import http from '@/api'
import type { ResourceManage } from '@/api/resourceManage/interface'
export const getResourceManageList = (params: ResourceManage.ReqResourceManageParams) => {
return http.post<ResourceManage.ResResourceManagePage>('/resourceManage/list', params)
}
export const addResourceManage = (params: FormData) => {
return http.upload('/resourceManage/add', params)
}
export const updateResourceManage = (params: ResourceManage.ReqUpdateResourceManage) => {
return http.post('/resourceManage/update', params)
}
export const getResourceManagePlayUrl = (id: string) => {
return http.get<ResourceManage.PlayVO>(`/resourceManage/play?id=${id}`)
}

View File

@@ -0,0 +1,34 @@
import type { ReqPage, ResPage } from '@/api/interface'
export namespace ResourceManage {
export interface ReqResourceManageParams extends ReqPage {
name?: string
fileName?: string
}
export interface ResResourceManage {
id: string
name: string
fileName: string
fileSize: number
relativePath: string
remark: string
state: number
createBy?: string | null
createTime?: string | null
updateBy?: string | null
updateTime?: string | null
}
export interface ResResourceManagePage extends ResPage<ResResourceManage> {}
export interface ReqUpdateResourceManage {
id: string
name: string
remark: string
}
export interface PlayVO {
url: string
}
}

View File

@@ -1,8 +1,12 @@
import http from '@/api' import http from '@/api'
import { useDetectionLockStore } from '@/stores/modules/detectionLock'
export const startPreTest = (params) => { export const startPreTest = async (params) => {
return http.post(`/prepare/startPreTest`, params, {loading: false}) const result = await http.post(`/prepare/startPreTest`, params, {loading: false})
// 抢锁成功 → 标记本地为持锁者
useDetectionLockStore().setAsHolder()
return result
} }
export const closePreTest = (params) => { export const closePreTest = (params) => {
@@ -37,8 +41,11 @@ export const resumeTest = (params) => {
* 比对式通道配对 * 比对式通道配对
* @param params * @param params
*/ */
export const contrastTest = (params: any) => { export const contrastTest = async (params: any) => {
return http.post(`/prepare/startContrastTest`,params) const result = await http.post(`/prepare/startContrastTest`, params)
// 抢锁成功 → 标记本地为持锁者
useDetectionLockStore().setAsHolder()
return result
} }
export const exportAlignData= () => { export const exportAlignData= () => {

View File

@@ -0,0 +1,9 @@
import http from '@/api'
export const startSntpService = () => {
return http.post('/sntp/start', {})
}
export const stopSntpService = () => {
return http.post('/sntp/stop', {})
}

View File

@@ -6,6 +6,7 @@ export enum ResultEnum {
ERROR = 500, ERROR = 500,
ACCESSTOKEN_EXPIRED = "A0024", ACCESSTOKEN_EXPIRED = "A0024",
OVERDUE = "A0025", OVERDUE = "A0025",
DETECTION_BUSY = "A020042",
TIMEOUT = 30000, TIMEOUT = 30000,
TYPE = "success" TYPE = "success"
} }

View File

@@ -19,3 +19,6 @@ export const DICT_STORE_KEY = "cn-dictData";
export const CHECK_STORE_KEY = "cn-check"; export const CHECK_STORE_KEY = "cn-check";
// pinia中detectionLock store的key
export const DETECTION_LOCK_STORE_KEY = "cn-detectionLock";

View File

@@ -0,0 +1,33 @@
import { defineStore } from 'pinia'
import { DETECTION_LOCK_STORE_KEY } from '@/stores/constant'
export interface DetectionLockHolder {
holderUserId: string
holderUserName: string
acquireTime: string
expireAt: string
}
export const useDetectionLockStore = defineStore(DETECTION_LOCK_STORE_KEY, {
state: () => ({
iAmHolder: false,
holder: null as DetectionLockHolder | null
}),
actions: {
setAsHolder(holder?: Partial<DetectionLockHolder> | null) {
this.iAmHolder = true
if (holder) {
this.holder = {
holderUserId: holder.holderUserId ?? '',
holderUserName: holder.holderUserName ?? '',
acquireTime: holder.acquireTime ?? '',
expireAt: holder.expireAt ?? ''
}
}
},
clearHolder() {
this.iAmHolder = false
this.holder = null
}
}
})

View File

@@ -0,0 +1,85 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import router from '@/routers'
import type { DetectionLockHolder } from '@/stores/modules/detectionLock'
import mittBus, { STOP_DETECTION_TIMER_EVENT } from '@/utils/mittBus'
import { requestResourceManageAutoplayFirst } from '@/utils/resourceManageAutoplay'
const stopDetectionTimer = () => {
mittBus.emit(STOP_DETECTION_TIMER_EVENT)
}
const goResourceManage = async () => {
requestResourceManageAutoplayFirst()
if (router.hasRoute('resourceManage')) {
await router.push({ name: 'resourceManage' })
return
}
await router.push('/resourceManage')
}
/**
* S1: 他人正在做检测, 自己抢锁被挡
*/
export const showLockBusyDialog = (holder: DetectionLockHolder) => {
stopDetectionTimer()
ElMessageBox.confirm(`${holder.holderUserName}」正在做检测,请稍后。`, '检测进行中', {
confirmButtonText: '观看检测视频教学',
cancelButtonText: '我知道了',
type: 'warning',
distinguishCancelAndClose: true,
customClass: 'detection-lock-busy-dialog'
})
.then(() => {
return goResourceManage()
})
.catch(() => {
// 用户点了"我知道了"或关闭,什么都不做
})
}
/**
* S2: 未开始检测就调中间接口
*/
export const showLockNotStartedToast = () => {
ElMessage.warning('请先点击"开始检测"按钮启动本轮检测')
}
/**
* S3: 自己暂停超 10 分钟, 被 WS 推 STOP_TIMEOUT 强制结束
*/
export const showPauseTimeoutDialog = () => {
stopDetectionTimer()
ElMessageBox.alert('暂停超过 10 分钟未恢复,系统已自动结束本次检测。\n\n如需继续,请重新发起检测。', '本次检测已结束', {
confirmButtonText: '我知道了',
type: 'warning'
}).catch(() => {})
}
/**
* S4: 被管理员强制释放
* - holder 为 null: 强释后无人接手
* - holder 非 null: 强释后被别人立刻抢占
*/
export const showForceReleasedDialog = (holder: DetectionLockHolder | null) => {
stopDetectionTimer()
if (holder) {
ElMessageBox.confirm(`当前「${holder.holderUserName}」正在做检测,您无法继续检测,请稍后。`, '检测已被中止', {
confirmButtonText: '观看检测视频教学',
cancelButtonText: '我知道了',
type: 'warning',
distinguishCancelAndClose: true
})
.then(() => {
return goResourceManage()
})
.catch(() => {})
} else {
ElMessageBox.alert('您的检测已被管理员强制结束。\n如需继续,请重新发起检测。', '检测已被中止', {
confirmButtonText: '我知道了',
type: 'warning'
}).catch(() => {})
}
}

View File

@@ -1,4 +1,11 @@
import mitt from "mitt"; import mitt from "mitt";
const mittBus = mitt(); export const STOP_DETECTION_TIMER_EVENT = "stopDetectionTimer";
type MittBusEvents = {
openThemeDrawer: undefined;
[STOP_DETECTION_TIMER_EVENT]: undefined;
};
const mittBus = mitt<MittBusEvents>();
export default mittBus; export default mittBus;

View File

@@ -0,0 +1,13 @@
let shouldAutoplayFirstVideo = false
export const requestResourceManageAutoplayFirst = () => {
shouldAutoplayFirstVideo = true
}
export const hasPendingResourceManageAutoplayFirst = () => shouldAutoplayFirstVideo
export const consumeResourceManageAutoplayFirst = () => {
if (!shouldAutoplayFirstVideo) return false
shouldAutoplayFirstVideo = false
return true
}

View File

@@ -9,6 +9,8 @@
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { jwtUtil } from "./jwtUtil"; import { jwtUtil } from "./jwtUtil";
import { useDetectionLockStore } from "@/stores/modules/detectionLock";
import { showPauseTimeoutDialog } from "@/utils/detectionLockDialog";
// ============================================================================ // ============================================================================
// 类型定义 (Types & Interfaces) // 类型定义 (Types & Interfaces)
@@ -190,8 +192,8 @@ export default class SocketService {
* WebSocket连接配置 * WebSocket连接配置
*/ */
private config: SocketConfig = { private config: SocketConfig = {
// url: 'ws://127.0.0.1:7778/hello',
url: 'ws://127.0.0.1:7778/hello', url: 'ws://127.0.0.1:7778/hello',
//url: 'ws://192.168.1.124:7777/hello',
heartbeatInterval: 9000, // 9秒心跳间隔 heartbeatInterval: 9000, // 9秒心跳间隔
reconnectDelay: 5000, // 5秒重连延迟 reconnectDelay: 5000, // 5秒重连延迟
maxReconnectAttempts: 5, // 最多重连5次 maxReconnectAttempts: 5, // 最多重连5次
@@ -546,6 +548,18 @@ export default class SocketService {
// 检查是否为JSON格式 // 检查是否为JSON格式
if (typeof event.data === 'string' && (event.data.startsWith('{') || event.data.startsWith('['))) { if (typeof event.data === 'string' && (event.data.startsWith('{') || event.data.startsWith('['))) {
const message: WebSocketMessage = JSON.parse(event.data); const message: WebSocketMessage = JSON.parse(event.data);
// 全局拦截:暂停 10 分钟超时,后端推 STOP_TIMEOUT 表示锁已被释放
// 不管页面是否注册了对应回调,都要做"清本地持锁标记 + 弹窗告知"
if (message?.operateCode === 'STOP_TIMEOUT') {
try {
useDetectionLockStore().clearHolder();
showPauseTimeoutDialog();
} catch (e) {
console.error('STOP_TIMEOUT 全局处理失败:', e);
}
}
if (message?.type && this.callBackMapping[message.type]) { if (message?.type && this.callBackMapping[message.type]) {
this.callBackMapping[message.type](message); this.callBackMapping[message.type](message);
} else { } else {

View File

@@ -18,7 +18,7 @@
<template #operation='scope'> <template #operation='scope'>
<el-button v-auth.role="'edit'" type='primary' link :icon='EditPen' @click="openDrawer('edit', scope.row)" :disabled="scope.row.code == 'root'">编辑</el-button> <el-button v-auth.role="'edit'" type='primary' link :icon='EditPen' @click="openDrawer('edit', scope.row)" :disabled="scope.row.code == 'root'">编辑</el-button>
<el-button v-auth.role="'delete'" type='primary' link :icon='Delete' @click='deleteAccount(scope.row)' :disabled="scope.row.code == 'root'">删除</el-button> <el-button v-auth.role="'delete'" type='primary' link :icon='Delete' @click='deleteAccount(scope.row)' :disabled="scope.row.code == 'root'">删除</el-button>
<el-button v-auth.role="'SetPermissions'" type='primary' link :icon='Share' @click="openDrawer('设置权限', scope.row)" :disabled="scope.row.code == 'root'">设置权限</el-button> <el-button v-auth.role="'SetPermissions'" type='primary' link :icon='Share' @click="openDrawer('设置权限', scope.row)" >设置权限</el-button>
</template> </template>
</ProTable> </ProTable>

View File

@@ -122,7 +122,8 @@
<script lang="tsx" setup name="test"> <script lang="tsx" setup name="test">
import {InfoFilled, Loading} from '@element-plus/icons-vue' import {InfoFilled, Loading} from '@element-plus/icons-vue'
import CompareDataCheckSingleChannelSingleTestPopup from './compareDataCheckSingleChannelSingleTestPopup.vue' import CompareDataCheckSingleChannelSingleTestPopup from './compareDataCheckSingleChannelSingleTestPopup.vue'
import {computed, ComputedRef, nextTick, onBeforeMount, onMounted, reactive, ref, toRef, watch} from 'vue' import {computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, reactive, ref, toRef, watch} from 'vue'
import type { ComputedRef } from 'vue'
import {dialogBig} from '@/utils/elementBind' import {dialogBig} from '@/utils/elementBind'
import {CheckData} from '@/api/check/interface' import {CheckData} from '@/api/check/interface'
import {useCheckStore} from '@/stores/modules/check' import {useCheckStore} from '@/stores/modules/check'
@@ -132,6 +133,7 @@ import {getAutoGenerate, getCanCoefficient, startCoefficient} from '@/api/user/l
import { generateDevReport } from '@/api/plan/plan' import { generateDevReport } from '@/api/plan/plan'
import {useModeStore} from '@/stores/modules/mode' // 引入模式 store import {useModeStore} from '@/stores/modules/mode' // 引入模式 store
import {useDictStore} from '@/stores/modules/dict' import {useDictStore} from '@/stores/modules/dict'
import mittBus, { STOP_DETECTION_TIMER_EVENT } from '@/utils/mittBus'
const checkStore = useCheckStore() const checkStore = useCheckStore()
const modeStore = useModeStore() const modeStore = useModeStore()
const dictStore = useDictStore() const dictStore = useDictStore()
@@ -740,6 +742,10 @@ const stopTimeCount = (type: number) => {
} }
} }
const handleStopDetectionTimer = () => {
stopTimeCount(1)
}
// 将秒数转换为 HH:MM:SS 格式 // 将秒数转换为 HH:MM:SS 格式
const secondToTime = (second: number) => { const secondToTime = (second: number) => {
let h: string | number = Math.floor(second / 3600) // 小时 let h: string | number = Math.floor(second / 3600) // 小时
@@ -898,6 +904,7 @@ const initializeParameters = async () => {
// //
onMounted(() => { onMounted(() => {
mittBus.on(STOP_DETECTION_TIMER_EVENT, handleStopDetectionTimer)
if (!checkStore.selectTestItems.preTest) { if (!checkStore.selectTestItems.preTest) {
// 判断是否预检测 // 判断是否预检测
@@ -905,6 +912,10 @@ onMounted(() => {
} }
}) })
onBeforeUnmount(() => {
mittBus.off(STOP_DETECTION_TIMER_EVENT, handleStopDetectionTimer)
})
defineExpose({ defineExpose({
initializeParameters, initializeParameters,
handlePause, handlePause,

View File

@@ -148,6 +148,7 @@ import { useCheckStore } from '@/stores/modules/check'
import { contrastTest, pauseTest, resumeTest, startPreTest } from '@/api/socket/socket' import { contrastTest, pauseTest, resumeTest, startPreTest } from '@/api/socket/socket'
import { useUserStore } from '@/stores/modules/user' import { useUserStore } from '@/stores/modules/user'
import { JwtUtil } from '@/utils/jwtUtil' import { JwtUtil } from '@/utils/jwtUtil'
import mittBus, { STOP_DETECTION_TIMER_EVENT } from '@/utils/mittBus'
const userStore = useUserStore() const userStore = useUserStore()
const checkStore = useCheckStore() const checkStore = useCheckStore()
@@ -165,6 +166,14 @@ const preTestStatus = ref('waiting') //预检测执行状态
const TestStatus = ref('waiting') //正式检测执行状态 const TestStatus = ref('waiting') //正式检测执行状态
const webMsgSend = ref() //webSocket推送的数据 const webMsgSend = ref() //webSocket推送的数据
const hideInitializingButton = () => {
if (TestStatus.value === 'test_init') {
TestStatus.value = 'waiting'
}
}
mittBus.on(STOP_DETECTION_TIMER_EVENT, hideInitializingButton)
const dialogTitle = ref('') const dialogTitle = ref('')
const showComponent = ref(true) const showComponent = ref(true)
const preTestRef = ref<InstanceType<typeof ComparePreTest> | null>(null) const preTestRef = ref<InstanceType<typeof ComparePreTest> | null>(null)
@@ -187,6 +196,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
mittBus.off(STOP_DETECTION_TIMER_EVENT, hideInitializingButton)
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
}) })

View File

@@ -206,6 +206,23 @@ function handleWarningError(stepRef: any, logRef: any, message: string) {
watch(webMsgSend, function (newValue, oldValue) { watch(webMsgSend, function (newValue, oldValue) {
if (testStatus.value !== 'waiting') { if (testStatus.value !== 'waiting') {
switch (newValue.requestId) { switch (newValue.requestId) {
case 'overloadTest':
if (newValue.code === 1) {
handleFatalError(step1, step1InitLog, '电压过载!')
}
if (newValue.code === 2) {
handleFatalError(step1, step1InitLog, '电流过载!')
}
if (newValue.code === 3) {
handleFatalError(step1, step1InitLog, '电压和电流过载!')
}
if (newValue.code === 4) {
step1InitLog.value.push({
type: 'info',
log: '过载测试成功!',
})
}
break;
case 'yjc_ytxjy': case 'yjc_ytxjy':
switch (newValue.operateCode) { switch (newValue.operateCode) {
case 'INIT_GATHER': case 'INIT_GATHER':

View File

@@ -91,7 +91,7 @@
type="primary" type="primary"
icon="Clock" icon="Clock"
@click="handleTest('手动检测')" @click="handleTest('手动检测')"
v-if="form.activeTabs === 0 && modeStore.currentMode == '模拟式'" v-if="form.activeTabs === 0 && modeStore.currentMode != '比对式'"
> >
手动检测 手动检测
</el-button> </el-button>
@@ -483,7 +483,7 @@ const columns = reactive<ColumnProps<Device.ResPqDev>[]>([
sortable: true, sortable: true,
isShow: checkStateShow, isShow: checkStateShow,
render: scope => { render: scope => {
return scope.row.checkState === 0 ? '未检' : scope.row.checkState === 1 ? '检测中' : '检测完成' return scope.row.checkState === 0 ? '未检' : scope.row.checkState === 1 ? '检测中' : scope.row.checkState === 2 ? '检测完成':'归档'
} }
}, },
{ {
@@ -494,10 +494,12 @@ const columns = reactive<ColumnProps<Device.ResPqDev>[]>([
render: scope => { render: scope => {
if (scope.row.checkResult === 0) { if (scope.row.checkResult === 0) {
return <el-tag type="danger">不符合</el-tag> return <el-tag type="danger">不符合</el-tag>
} else if (scope.row.checkResult === 0) {
return '不符合'
} else if (scope.row.checkResult === 1) { } else if (scope.row.checkResult === 1) {
return '符合' return '符合'
} else if (scope.row.checkResult === 2) { }else if(scope.row.checkResult === 2) {
return '未检' return '未检'
} }
return '' return ''
} }
@@ -539,7 +541,6 @@ const columns = reactive<ColumnProps<Device.ResPqDev>[]>([
{ prop: 'operation', label: '操作', fixed: 'right', minWidth :200,isShow: operationShow } { prop: 'operation', label: '操作', fixed: 'right', minWidth :200,isShow: operationShow }
]) ])
let testType = 'test' // 检测类型:'test'-检测 'reTest'-复检 let testType = 'test' // 检测类型:'test'-检测 'reTest'-复检
let qualifiedCount = 0 //合格数量
//比对单个报告生成 //比对单个报告生成
@@ -575,8 +576,6 @@ const handleSelectionChange = (selection: any[]) => {
} else { } else {
testType = 'reTest' testType = 'reTest'
} }
qualifiedCount=selection.filter(item => item.checkResult == 1).length
let devices: CheckData.Device[] = selection.map((item: any) => { let devices: CheckData.Device[] = selection.map((item: any) => {
return { return {
deviceId: item.id, deviceId: item.id,
@@ -599,6 +598,19 @@ const handleSelectionChange = (selection: any[]) => {
} }
} }
const isUncheckedDevice = (device: Device.ResPqDev) => Number(device.checkState) === 0 || Number(device.checkResult) === 2
const hasCheckedSelectedDevice = () => channelsSelection.value.some(device => !isUncheckedDevice(device))
const hasUncheckedSelectedDevice = () => channelsSelection.value.some(device => isUncheckedDevice(device))
const hasCheckedUnqualifiedSelectedDevice = () =>
channelsSelection.value.some(device => !isUncheckedDevice(device) && Number(device.checkResult) === 0)
const shouldShowRecheckModeDialog = () => hasCheckedSelectedDevice()
const canUseUnqualifiedItemRecheck = () => hasCheckedUnqualifiedSelectedDevice() && !hasUncheckedSelectedDevice()
//查询 //查询
const handleSearch = () => { const handleSearch = () => {
proTable.value?.getTableList() proTable.value?.getTableList()
@@ -923,12 +935,12 @@ const handleTest = async (val: string) => {
dialogTitle.value = val dialogTitle.value = val
if (val === '手动检测') { if (val === '手动检测') {
checkStore.setShowDetailType(2) checkStore.setShowDetailType(2)
if (testType === 'reTest') { if (shouldShowRecheckModeDialog()) {
ElMessageBox.confirm('请选择复检检测方式', '设备复检', { ElMessageBox.confirm('请选择复检检测方式', '设备复检', {
distinguishCancelAndClose: true, distinguishCancelAndClose: true,
confirmButtonText: '不合格项复检', confirmButtonText: '不合格项复检',
cancelButtonText: '全部复检', cancelButtonText: '全部复检',
showConfirmButton:qualifiedCount<=0, showConfirmButton: canUseUnqualifiedItemRecheck(),
type: 'warning' type: 'warning'
}) })
.then(() => { .then(() => {
@@ -963,11 +975,12 @@ const handleTest = async (val: string) => {
checkStore.setCheckType(1) checkStore.setCheckType(1)
checkStore.initSelectTestItems() checkStore.initSelectTestItems()
// 一键检测 // 一键检测
if (testType === 'reTest' && modeStore.currentMode != '比对式') { if (shouldShowRecheckModeDialog() && modeStore.currentMode != '比对式') {
ElMessageBox.confirm('请选择复检检测方式', '设备复检', { ElMessageBox.confirm('请选择复检检测方式', '设备复检', {
distinguishCancelAndClose: true, distinguishCancelAndClose: true,
confirmButtonText: '不合格项复检', confirmButtonText: '不合格项复检',
cancelButtonText: '全部复检', cancelButtonText: '全部复检',
showConfirmButton: canUseUnqualifiedItemRecheck(),
type: 'warning' type: 'warning'
}) })
.then(() => { .then(() => {
@@ -1087,7 +1100,7 @@ const openDrawer = async (title: string, row: any) => {
if (title === '检测数据查询') { if (title === '检测数据查询') {
checkStore.setShowDetailType(0) checkStore.setShowDetailType(0)
if (modeStore.currentMode == '模拟式') { if (modeStore.currentMode == '模拟式'||modeStore.currentMode == '数字式') {
dataCheckPopupRef.value?.open(row.id, '-1', null) dataCheckPopupRef.value?.open(row.id, '-1', null)
} else if (modeStore.currentMode == '比对式') { } else if (modeStore.currentMode == '比对式') {
dataCheckSingleChannelSingleTestPopupRef.value?.open(row, null, row.id, 2) dataCheckSingleChannelSingleTestPopupRef.value?.open(row, null, row.id, 2)
@@ -1095,7 +1108,7 @@ const openDrawer = async (title: string, row: any) => {
} }
if (title === '误差体系更换') { if (title === '误差体系更换') {
checkStore.setShowDetailType(1) checkStore.setShowDetailType(1)
if (modeStore.currentMode == '模拟式') { if (modeStore.currentMode == '模拟式'||modeStore.currentMode == '数字式') {
dataCheckPopupRef.value?.open(row.id, '-1', null) dataCheckPopupRef.value?.open(row.id, '-1', null)
} else if (modeStore.currentMode == '比对式') { } else if (modeStore.currentMode == '比对式') {
dataCheckSingleChannelSingleTestPopupRef.value?.open(row, null, row.id, 2) dataCheckSingleChannelSingleTestPopupRef.value?.open(row, null, row.id, 2)

View File

@@ -105,7 +105,7 @@ import { InfoFilled, Loading } from '@element-plus/icons-vue'
// 单通道单测试项详情弹窗组件 // 单通道单测试项详情弹窗组件
import dataCheckSingleChannelSingleTestPopup from './dataCheckSingleChannelSingleTestPopup.vue' import dataCheckSingleChannelSingleTestPopup from './dataCheckSingleChannelSingleTestPopup.vue'
// Vue 3 Composition API // Vue 3 Composition API
import { computed, reactive, ref, toRef, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, toRef, watch } from 'vue'
// 对话框大小绑定工具 // 对话框大小绑定工具
import { dialogBig } from '@/utils/elementBind' import { dialogBig } from '@/utils/elementBind'
// 检测数据类型定义 // 检测数据类型定义
@@ -120,6 +120,7 @@ import { getAutoGenerate } from '@/api/user/login'
import { generateDevReport } from '@/api/plan/plan' import { generateDevReport } from '@/api/plan/plan'
import { useModeStore } from '@/stores/modules/mode' // 引入模式 store import { useModeStore } from '@/stores/modules/mode' // 引入模式 store
import { useDictStore } from '@/stores/modules/dict' import { useDictStore } from '@/stores/modules/dict'
import mittBus, { STOP_DETECTION_TIMER_EVENT } from '@/utils/mittBus'
// 获取检测状态管理实例 // 获取检测状态管理实例
const checkStore = useCheckStore() const checkStore = useCheckStore()
@@ -1176,6 +1177,10 @@ const stopTimeCount = () => {
} }
} }
const handleStopDetectionTimer = () => {
stopTimeCount()
}
// 恢复计时(用于暂停后继续) // 恢复计时(用于暂停后继续)
const resumeTimeCount = () => { const resumeTimeCount = () => {
@@ -1199,8 +1204,14 @@ const secondToTime = (second: number) => {
return h + ':' + m + ':' + s return h + ':' + m + ':' + s
} }
onMounted(() => {
mittBus.on(STOP_DETECTION_TIMER_EVENT, handleStopDetectionTimer)
})
// 组件卸载前清理定时器和响应式引用 // 组件卸载前清理定时器和响应式引用
onBeforeUnmount(() => { onBeforeUnmount(() => {
mittBus.off(STOP_DETECTION_TIMER_EVENT, handleStopDetectionTimer)
// 清理定时器 // 清理定时器
if (timer) { if (timer) {
clearInterval(timer) clearInterval(timer)

View File

@@ -172,6 +172,7 @@ import { useCheckStore } from '@/stores/modules/check'
import { pauseTest, resumeTest, startPreTest } from '@/api/socket/socket' import { pauseTest, resumeTest, startPreTest } from '@/api/socket/socket'
import { useUserStore } from '@/stores/modules/user' import { useUserStore } from '@/stores/modules/user'
import { JwtUtil } from '@/utils/jwtUtil' import { JwtUtil } from '@/utils/jwtUtil'
import mittBus, { STOP_DETECTION_TIMER_EVENT } from '@/utils/mittBus'
// ====================== 状态管理 ====================== // ====================== 状态管理 ======================
const userStore = useUserStore() const userStore = useUserStore()
@@ -200,6 +201,14 @@ const channelsTestStatus = ref('waiting') // 通道系数校准执行状态
const TestStatus = ref('waiting') // 正式检测执行状态 const TestStatus = ref('waiting') // 正式检测执行状态
const webMsgSend = ref() // webSocket推送的数据用于组件间通信 const webMsgSend = ref() // webSocket推送的数据用于组件间通信
const hideInitializingButton = () => {
if (TestStatus.value === 'test_init') {
TestStatus.value = 'waiting'
}
}
mittBus.on(STOP_DETECTION_TIMER_EVENT, hideInitializingButton)
// ====================== WebSocket 相关 ====================== // ====================== WebSocket 相关 ======================
const dataSocket = reactive<{ const dataSocket = reactive<{
socketServe: typeof socketClient.Instance | null socketServe: typeof socketClient.Instance | null
@@ -705,6 +714,7 @@ const handleClose = () => {
* 确保路由切换或组件销毁时正确关闭WebSocket连接 * 确保路由切换或组件销毁时正确关闭WebSocket连接
*/ */
onBeforeUnmount(() => { onBeforeUnmount(() => {
mittBus.off(STOP_DETECTION_TIMER_EVENT, hideInitializingButton)
closeWebSocket() // 组件销毁前关闭WebSocket连接 closeWebSocket() // 组件销毁前关闭WebSocket连接
}) })
@@ -767,4 +777,4 @@ defineExpose({ open }) // 只暴露open方法供父组件调用
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
</style> </style>

View File

@@ -29,7 +29,7 @@
@node-click="handleNodeClick" @node-click="handleNodeClick"
> >
<template #default="{ node, data }"> <template #default="{ node, data }">
<span class="custom-tree-node" style="display: flex; align-items: center;"> <span class="custom-tree-node">
<!-- 父节点图标 --> <!-- 父节点图标 -->
<Platform <Platform
v-if="!data.pid" v-if="!data.pid"
@@ -39,56 +39,59 @@
}" }"
/> />
<!-- 节点名称 --> <!-- 节点名称 -->
<span>{{ node.label }}</span> <span class="node-label">{{ node.label }}</span>
<!-- 子节点右侧图标 + tooltip --> <span class="node-actions">
<el-tooltip <PieChart
v-if=" v-if="!isCompareMode && isCompletedPlanNode(node.data)"
node.label != '未检' && class="node-action-icon"
node.label != '检测中' && @click.stop="openStatistics(node.data)"
node.label != '检测完成' && style="margin-right: 8px"
hasChildrenInPlanTable(node.data)
"
placement="top"
:manual="true"
content="子计划信息"
>
<List
@click.stop="childDetail(node.data)"
style="
width: 16px;
height: 16px;
margin-left: 8px;
cursor: pointer;
color: var(--el-color-primary);
"
/> />
</el-tooltip> <!-- 子节点右侧图标 + tooltip -->
<el-tooltip
v-if="
node.label != '未检' &&
node.label != '检测中' &&
node.label != '检测完成' &&
hasChildrenInPlanTable(node.data)
"
placement="top"
:manual="true"
content="子计划信息"
>
<List class="node-action-icon" @click.stop="childDetail(node.data)" />
</el-tooltip>
</span>
</span> </span>
</template> </template>
</el-tree> </el-tree>
</div> </div>
</div> </div>
<SourceOpen ref="openSourceView" :width="width" :height="height + 175"></SourceOpen> <SourceOpen ref="openSourceView" :width="width" :height="height + 175"></SourceOpen>
<PlanStatisticsPopup ref="planStatisticsPopupRef" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { type Plan } from '@/api/plan/interface' import { type Plan } from '@/api/plan/interface'
import { List, Menu, Platform } from '@element-plus/icons-vue' import { List, Menu, PieChart, Platform } from '@element-plus/icons-vue'
import { nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useCheckStore } from '@/stores/modules/check' import { useCheckStore } from '@/stores/modules/check'
import { ElTooltip } from 'element-plus' import { ElTooltip } from 'element-plus'
import SourceOpen from '@/views/plan/planList/components/childrenPlan.vue' import SourceOpen from '@/views/plan/planList/components/childrenPlan.vue'
import PlanStatisticsPopup from '@/views/plan/planList/components/planStatisticsPopup.vue'
import { getPlanList } from '@/api/plan/plan.ts' import { getPlanList } from '@/api/plan/plan.ts'
import { useModeStore } from '@/stores/modules/mode' // 引入模式 store import { useModeStore } from '@/stores/modules/mode' // 引入模式 store
import { useDictStore } from '@/stores/modules/dict' import { useDictStore } from '@/stores/modules/dict'
const openSourceView = ref() const openSourceView = ref()
const planStatisticsPopupRef = ref<InstanceType<typeof PlanStatisticsPopup> | null>(null)
const router = useRouter() const router = useRouter()
const checkStore = useCheckStore() const checkStore = useCheckStore()
const filterText = ref('') const filterText = ref('')
const treeRef = ref() const treeRef = ref()
const data: any = ref([]) const data: any = ref([])
const modeStore = useModeStore() const modeStore = useModeStore()
const isCompareMode = computed(() => modeStore.currentMode === '比对式')
const dictStore = useDictStore() const dictStore = useDictStore()
const defaultProps = { const defaultProps = {
@@ -211,6 +214,14 @@ const childDetail = (data: Plan.ResPlan) => {
} }
} }
const isCompletedPlanNode = (data: Partial<Plan.ResPlan>) => {
return [1, 2].includes(Number(data.testState))
}
const openStatistics = (data: Partial<Plan.ResPlan>) => {
planStatisticsPopupRef.value?.open(data)
}
function buildTree(flatList: any[]): any[] { function buildTree(flatList: any[]): any[] {
const map = new Map() const map = new Map()
const tree: any[] = [] const tree: any[] = []
@@ -293,6 +304,40 @@ defineExpose({ getTreeData, clickTableToTree })
margin-top: 12px; margin-top: 12px;
} }
:deep(.el-tree-node__content) {
padding-right: 6px;
}
.custom-tree-node {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
}
.node-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-actions {
flex: none;
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 8px;
}
.node-action-icon {
width: 16px;
height: 16px;
cursor: pointer;
color: var(--el-color-primary);
}
//.filter-tree span { //.filter-tree span {
// font-size: 16px; // font-size: 16px;
// display:block; // display:block;

View File

@@ -464,9 +464,11 @@ const getPieData = async (id: string) => {
planName.value = '所选计划:' planName.value = '所选计划:'
} }
pieRef1.value.init() if (pieRef1.value && pieRef2.value && pieRef3.value) {
pieRef2.value.init() pieRef1.value.init()
pieRef3.value.init() pieRef2.value.init()
pieRef3.value.init()
}
} }
/** /**
* 初始化树组件数据 * 初始化树组件数据

View File

@@ -0,0 +1,368 @@
<template>
<div class="sntp-page">
<section class="sntp-panel">
<el-row :gutter="16" class="time-list">
<el-col :xs="24" :md="12">
<div class="time-item">
<div class="time-label">当前电脑时间</div>
<div class="time-content">{{ computerTime }}</div>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="time-item">
<div class="time-label">装置返回时间</div>
<div class="time-content">{{ deviceTime }}</div>
</div>
</el-col>
</el-row>
<div class="action-row">
<el-button type="primary" :loading="starting" :disabled="running || stopping" @click="handleStart">
启动SNTP对时服务
</el-button>
<el-button type="danger" plain :loading="stopping" :disabled="!running || starting" @click="handleStop">
停止SNTP对时服务
</el-button>
</div>
<div class="history-section">
<div class="history-header">
<span class="history-title">历史记录</span>
<el-button
plain
type="danger"
:disabled="historyList.length === 0"
@click="clearHistory"
>
清空
</el-button>
</div>
<div class="history-table">
<div class="history-table__head history-row">
<div class="col-order">序号</div>
<div>当前电脑时间</div>
<div>装置返回时间</div>
<div>误差ms</div>
</div>
<div v-if="historyList.length === 0" class="history-empty">
<span>暂无数据</span>
</div>
<div v-else class="history-table__body">
<div v-for="(item, index) in historyList" :key="item.id" class="history-row">
<div class="col-order">{{ index + 1 }}</div>
<div>{{ item.computerTime }}</div>
<div>{{ item.deviceTime }}</div>
<div>{{ formatErrorMs(item.errorMs) }}</div>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts" name="sntp">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Delete } from '@element-plus/icons-vue'
import socketClient from '@/utils/webSocketClient'
import { startSntpService, stopSntpService } from '@/api/system/sntp'
interface SntpTimeMessage {
type: string
computerTime?: string
deviceTime?: string
computerTimestampMs?: number
deviceTimestampMs?: number
errorMs?: number
}
interface SntpHistoryItem {
id: string
computerTime: string
deviceTime: string
computerTimestampMs: number | null
deviceTimestampMs: number | null
errorMs: number | null
}
defineOptions({
name: 'sntp'
})
const messageType = 'sntp_time_update'
const maxHistoryCount = 50
const running = ref(false)
const starting = ref(false)
const stopping = ref(false)
const computerTimeValue = ref('--')
const deviceTimeValue = ref('--')
const historyList = ref<SntpHistoryItem[]>([])
const computerTime = computed(() => computerTimeValue.value)
const deviceTime = computed(() => deviceTimeValue.value)
const formatErrorMs = (errorMs: number | null) => {
if (errorMs === null || Number.isNaN(errorMs))
return '--'
if (errorMs > 0)
return `+${errorMs}`
return `${errorMs}`
}
const appendHistory = (
computerTimeText: string,
deviceTimeText: string,
computerTimestampMs: number | null,
deviceTimestampMs: number | null,
errorMs: number | null
) => {
const nextItem: SntpHistoryItem = {
id: `${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
computerTime: computerTimeText,
deviceTime: deviceTimeText,
computerTimestampMs,
deviceTimestampMs,
errorMs
}
historyList.value = [nextItem, ...historyList.value].slice(0, maxHistoryCount)
}
const handleTimeUpdate = (message: SntpTimeMessage) => {
const nextComputerTime = message.computerTime || '--'
const nextDeviceTime = message.deviceTime || '--'
const nextComputerTimestampMs = typeof message.computerTimestampMs === 'number' ? message.computerTimestampMs : null
const nextDeviceTimestampMs = typeof message.deviceTimestampMs === 'number' ? message.deviceTimestampMs : null
const nextErrorMs = typeof message.errorMs === 'number' ? message.errorMs : null
computerTimeValue.value = nextComputerTime
deviceTimeValue.value = nextDeviceTime
appendHistory(
nextComputerTime,
nextDeviceTime,
nextComputerTimestampMs,
nextDeviceTimestampMs,
nextErrorMs
)
}
const ensureSocketConnection = () => {
socketClient.Instance.connect()
socketClient.Instance.registerCallBack(messageType, (message: SntpTimeMessage) => {
handleTimeUpdate(message)
})
}
const clearHistory = () => {
historyList.value = []
}
const handleStart = async () => {
starting.value = true
try {
await startSntpService()
running.value = true
} finally {
starting.value = false
}
}
const handleStop = async () => {
stopping.value = true
try {
await stopSntpService()
running.value = false
} finally {
stopping.value = false
}
}
onMounted(() => {
ensureSocketConnection()
})
onBeforeUnmount(() => {
socketClient.Instance.unRegisterCallBack(messageType)
})
</script>
<style scoped lang="scss">
.sntp-page {
height: 100%;
min-height: 100%;
padding: 16px;
background: #f5f7fa;
display: flex;
}
.sntp-panel {
flex: 1;
min-height: 0;
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
display: flex;
flex-direction: column;
gap: 20px;
}
.time-list {
flex-shrink: 0;
}
.time-item {
min-height: 168px;
padding: 18px 20px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.time-label {
font-size: 14px;
color: #606266;
}
.time-content {
font-size: 26px;
line-height: 1.35;
color: #303133;
word-break: break-word;
}
.action-row {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.history-section {
flex: 1;
min-height: 0;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
background: #ffffff;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #e5e7eb;
background: #fafafa;
flex-shrink: 0;
}
.history-title {
font-size: 14px;
color: #303133;
font-weight: 500;
}
.history-empty {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 14px;
}
.history-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.history-table__head {
background: #fafafa;
flex-shrink: 0;
}
.history-table__body {
flex: 1;
min-height: 0;
overflow: auto;
}
.history-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr) minmax(0, 1fr) 200px;
}
.history-row > div {
padding: 14px 16px;
border-bottom: 1px solid #f0f2f5;
color: #303133;
word-break: break-word;
}
.history-row > div + div {
border-left: 1px solid #f0f2f5;
}
.history-table__head > div {
font-size: 13px;
color: #606266;
font-weight: 500;
}
.history-table__body .history-row:last-child > div {
border-bottom: none;
}
.col-order {
text-align: center;
}
@media (max-width: 900px) {
.sntp-page {
padding: 12px;
}
.sntp-panel {
padding: 16px;
gap: 16px;
}
.time-item {
min-height: 132px;
margin-bottom: 16px;
}
.time-content {
font-size: 22px;
}
.action-row {
flex-direction: column;
align-items: stretch;
}
.history-row {
grid-template-columns: 1fr;
}
.history-row > div + div {
border-left: none;
border-top: 1px solid #f0f2f5;
}
.col-order {
text-align: left;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More