feat(detection): 添加检测锁机制防止多用户同时操作

- 新增 detectionLock store 管理检测锁状态
- 实现检测锁相关的弹窗提示功能
- 添加 DETECTION_BUSY 错误码处理多人竞争逻辑
- 在 websocket 中集成检测锁超时处理
- 修改程序源控制接口以同步锁状态
- 更新项目标题和图标配置
- 添加 docs 目录到忽略列表
This commit is contained in:
2026-05-28 20:44:53 +08:00
parent f9809197e8
commit 19ea08d5e0
12 changed files with 192 additions and 9 deletions

1
.gitignore vendored
View File

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

View File

@@ -4,7 +4,8 @@
<meta charset="utf-8">
<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" />
<title></title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<title>NPQS-9100</title>
<!-- 优化vue渲染未完成之前先加一个css动画 -->
<style>
#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 http from '@/api'
import { useDetectionLockStore } from '@/stores/modules/detectionLock'
/**
* @name 程控源管理模块
@@ -17,8 +18,11 @@ export const startSimulateTest = (params: controlSource.ResControl) => {
}
//停止
export const closeSimulateTest = (params: controlSource.ResControl) => {
return http.post(`/prepare/closeSimulateTest`,params,{loading:false})
export const closeSimulateTest = async (params: controlSource.ResControl) => {
const result = await http.post(`/prepare/closeSimulateTest`,params,{loading:false})
// 主动终止 → 释放本地持锁标记
useDetectionLockStore().clearHolder()
return result
}

View File

@@ -12,6 +12,12 @@ import { type ResultData } from '@/api/interface'
import { ResultEnum } from '@/enums/httpEnum'
import { checkStatus } from './helper/checkStatus'
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 { refreshToken } from '@/api/user/login'
import { EventSourcePolyfill } from 'event-source-polyfill'
@@ -107,6 +113,32 @@ class RequestHttp {
}
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 直接报错)
if (data.code && data.code !== ResultEnum.SUCCESS) {
if (data.message.includes('&')) {

View File

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

View File

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

View File

@@ -19,3 +19,6 @@ export const DICT_STORE_KEY = "cn-dictData";
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,72 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import type { DetectionLockHolder } from '@/stores/modules/detectionLock'
/**
* S1:他人正在做检测,自己抢锁被挡
*/
export const showLockBusyDialog = (holder: DetectionLockHolder) => {
ElMessageBox.confirm(
`${holder.holderUserName}」正在做检测,请稍后。`,
'检测进行中',
{
confirmButtonText: '观看检测视频教学',
cancelButtonText: '我知道了',
type: 'warning',
distinguishCancelAndClose: true,
customClass: 'detection-lock-busy-dialog'
}
)
.then(() => {
// 视频教学跳转 URL 暂未配置,先用 Toast 兜底
ElMessage.info('视频教学功能开发中,敬请期待')
})
.catch(() => {
// 用户点了"我知道了"或关闭,什么都不做
})
}
/**
* S2:未开始检测就调中间接口
*/
export const showLockNotStartedToast = () => {
ElMessage.warning('请先点击"开始检测"按钮启动本轮检测')
}
/**
* S3:自己暂停超 10 分钟,被 WS 推 STOP_TIMEOUT 强制结束
*/
export const showPauseTimeoutDialog = () => {
ElMessageBox.alert('暂停超过 10 分钟未恢复,系统已自动结束本次检测。\n\n如需继续,请重新发起检测。', '本次检测已结束', {
confirmButtonText: '我知道了',
type: 'warning'
}).catch(() => {})
}
/**
* S4:被管理员强制释放
* - holder 为 null → 强释后无人接手
* - holder 非 null → 强释后被别人立刻抢占
*/
export const showForceReleasedDialog = (holder: DetectionLockHolder | null) => {
if (holder) {
ElMessageBox.confirm(
`当前「${holder.holderUserName}」正在做检测,您无法继续检测,请稍后。`,
'检测已被中止',
{
confirmButtonText: '观看检测视频教学',
cancelButtonText: '我知道了',
type: 'warning',
distinguishCancelAndClose: true
}
)
.then(() => {
ElMessage.info('视频教学功能开发中,敬请期待')
})
.catch(() => {})
} else {
ElMessageBox.alert('您的检测已被管理员强制结束。\n如需继续,请重新发起检测。', '检测已被中止', {
confirmButtonText: '我知道了',
type: 'warning'
}).catch(() => {})
}
}

View File

@@ -9,6 +9,8 @@
import { ElMessage } from "element-plus";
import { jwtUtil } from "./jwtUtil";
import { useDetectionLockStore } from "@/stores/modules/detectionLock";
import { showPauseTimeoutDialog } from "@/utils/detectionLockDialog";
// ============================================================================
// 类型定义 (Types & Interfaces)
@@ -190,8 +192,8 @@ export default class SocketService {
* WebSocket连接配置
*/
private config: SocketConfig = {
url: 'ws://127.0.0.1:7778/hello',
//url: 'ws://192.168.1.124:7777/hello',
// url: 'ws://127.0.0.1:7778/hello',
url: 'ws://127.0.0.1:7777/hello',
heartbeatInterval: 9000, // 9秒心跳间隔
reconnectDelay: 5000, // 5秒重连延迟
maxReconnectAttempts: 5, // 最多重连5次
@@ -546,6 +548,18 @@ export default class SocketService {
// 检查是否为JSON格式
if (typeof event.data === 'string' && (event.data.startsWith('{') || event.data.startsWith('['))) {
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]) {
this.callBackMapping[message.type](message);
} else {